Compare commits

...

484 Commits

Author SHA1 Message Date
ONLY-yours da3d3d33f3 🐛 fix: restore userId in gateway dispatch, gate local-system by executionTarget, add device switcher for regular agents
- Fix GatewayHttpClient.dispatchAgentRun stripping userId from request body,
  causing 'Missing userId' error when routing Claude Code to desktop device
- Gate activeDeviceId=undefined when executionTarget='sandbox' so local-system
  tools are not injected in sandbox mode
- Add HeteroDeviceSwitcher to RuntimeConfig for regular agents (lab flag gated)
  so users can select a desktop device for local-system tool execution

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

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

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

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

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

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

Major refinements after design review on PR #15207:

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

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

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

Second iteration on PR #15207:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

Refs LOBE-5594.

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

* fix(observability): align agent runtime GenAI attributes

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore: publish the cli to 0.0.21

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

* fix: fixed the little types error

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

Wire up the per-call feedback loop foundation.

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

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

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

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

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

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

Fix:

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

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

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

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

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

Closes LOBE-9488

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

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

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

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

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

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

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

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

---------

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

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

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

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

---------

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

LOBE-9523

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

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

Three coordinated fixes:

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

LOBE-9501

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

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

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

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

LOBE-9501

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

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

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

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

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

LOBE-9501

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

Fix the gateway handler:

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

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

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

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

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

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

LOBE-9501

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

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

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

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

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

LOBE-9501

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

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

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

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

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

LOBE-9501

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

Two changes:

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

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

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

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

LOBE-9501

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

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

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

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

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

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

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

LOBE-9501

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

Used to verify LOBE-9501.

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

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

LOBE-9501

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

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

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

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

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

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

LOBE-9501

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

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

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

---------

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 01:14:12 +08:00
Arvin Xu b3d2d2fdbd feat(review-panel): group review changes by submodule (#15148)
* 🐛 fix(claude-code): show task subject in TaskUpdate inspector & header

A TaskUpdate that only sets `subject` (no status flip) was falling
through to the aggregate `Todos: x/y` chip and burying the per-call
signal. Surface the new subject like the status branch already does:
"Task updated: <subject>".

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

*  feat(review-panel): group changes by submodule with per-group collapse

Surface dirty submodules as their own groups in the agent Review panel so
users working in a parent repo with submodules see each repo's changes
clustered together (mirrors WebStorm's per-repo commit grouping). Both
Unstaged and Branch modes apply the same grouping — submodules with internal
working-tree changes (unstaged) or branch diffs against their own
origin/HEAD (branch) surface as separate groups, each tagged with its own
branch label and file/diff totals.

Backend (`GitCtr`):
- `getGitWorkingTreePatches` and `getGitBranchDiff` extracted into private
  recursive helpers that detect submodules via `git submodule status`,
  partition pointer-bump entries out of the parent's flat patches, and
  recurse one level for each dirty submodule's own patches + branch info.
- Nested submodules are not traversed (phase 1); revert routes through each
  group's absolute path so submodule files revert inside the submodule.

Renderer:
- New `GroupHeader` and `FileRow` subcomponents split out of `Review`.
  `GroupHeader` is sticky with a chevron + name + file count + diff totals +
  branch; clicking collapses the group's rows. A hover-revealed `ActionIcon`
  on the right expands/collapses all file diffs in that group
  (`e.stopPropagation` keeps it from also collapsing the surrounding header).
- Fixed `block-size: 32px` on the header so toggling the fold button on/off
  doesn't jitter the sticky height.
- Single-repo working trees keep the previous flat layout when no submodule
  groups exist.

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

*  feat(review-panel): scan all submodules in branch mode

Previously branch mode only surfaced a submodule group when the parent's
diff against base ref contained a `Subproject commit` pointer bump for it.
This missed the common case where the user has committed work in a
submodule on a feature branch but the parent's pointer hasn't yet moved
relative to its base — the submodule's own branch differences stayed
invisible in the Review panel.

`collectBranchDiff` now recurses into every registered submodule (single
level, in parallel) and keeps a group when EITHER its pointer differs in
the parent OR its own branch diverges from its own origin/HEAD. Clean-on-
both-axes submodules are dropped so the panel stays quiet for repos where
the submodule isn't actively being worked on.

Submodule count is small in practice (single digits), so the extra
per-submodule fetch + diff in parallel is an acceptable cost.

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

*  feat(agent-documents): hide .tool-results archive from user-facing lists

Auto-created tool-result archive folder and its children are now filtered
out of getAgentDocuments. Agents still discover them via the tool-oriented
listDocuments paths.

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

* 💄 style(review-panel): drop "file not found in project index" toast

Reveal-in-tree now silently no-ops when the path isn't indexed (e.g.
submodule files) instead of nagging the user with a warning toast.

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

* 🐛 fix(review-panel): keep submodule groups visible on pointer-only bumps

`isEmpty` was derived solely from `totalEntryCount`, which counts file
patches across groups. A pointer-only submodule bump (parent patch
filtered out, submodule group present but internally clean) produced
`totalEntryCount === 0`, so the panel rendered the global empty state
and silently skipped the submoduleClean group rendering — even though
git was dirty.

Now `isEmpty` also requires zero submodule groups, so pointer-only bumps
keep their GroupHeader + "submodule clean" line. The fold-all button
visibility switches to `totalEntryCount > 0` so it stays hidden when
there's nothing foldable.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 00:29:22 +08:00
Arvin Xu cce14911d1 feat: per-call llm_generation_tracing observability (#15124)
*  feat(database): add llm_generation_tracing schema + tracing package (LOBE-9462)

Foundation layer for per-call observability of `generateObject` calls.

- New Drizzle table `llm_generation_tracing` with identity / context / model /
  result / usage / storage / feedback / audit columns and full single-column
  index coverage (Postgres bitmap-scan friendly). Migration 0103 is idempotent
  (CREATE TABLE/INDEX IF NOT EXISTS) for safe re-runs.
- `LlmGenerationTracingModel` with `record` / `updateFeedback` / `findById` /
  `listRecent`, all userId-scoped to prevent cross-user leaks.
- New package `@lobechat/llm-generation-tracing` mirroring agent-tracing's
  shape: `ITracingStore` interface, `FileTracingStore` (local/dev, scenario
  subfolders + latest.json symlink), `computePromptHash` (6-char sha256 of
  systemPrompt + schema), and `TRACING_SCENARIO_REGISTRY` + `resolveScenario`
  with explicit scenario override.

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

*  feat(model-runtime): wire llm_generation_tracing into ModelRuntime.generateObject (LOBE-9462)

Per-call interception layer — one hook covers all generateObject callers.

- New `onGenerateObjectComplete` hook on `ModelRuntimeHooks`: always fires
  (success or failure) with latency, usage, output/error. Fixes the gap where
  `onGenerateObjectFinal` only fires when the runtime invokes `onUsage`.
- `S3TracingStore` (zstd level 3, key
  `llm-generation-tracing/{scenario}/{v}-{hash}/{date}/{id}.json.zst`) and
  `LLMGenerationTracingService` that does DB insert → store.save → patch
  storage_key. Store failures preserve the row with `metadata.store_error`.
- `createLLMGenerationTracingHook` + `mergeModelRuntimeHooks` wired into
  `initModelRuntimeFromDB`; tracing runs alongside business (billing) hooks
  via `next/server.after()` when available, microtask fallback otherwise.
  Unknown metadata keys (e.g. `parent_memory_trace_key`) pass through.
- Memory extractor accepts `parentMemoryTraceKey` option for the job-level
  backlink. Follow-up-action caller given an explicit `scenario: 'follow_up'`
  metadata override — it was the only OSS caller missing trigger metadata.

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

*  test(llm-generation-tracing): type vi.fn mocks so tsgo accepts mock.calls indexing

The hook + service tests destructured `mock.calls[0][0]` and accessed nested
fields, which tsgo flagged as TS2493 / TS18046 because `vi.fn()` defaults to a
zero-arg signature. Add explicit type parameters to the mocks so tsgo can
infer the call tuple, and cast `call.payload` at the access point.

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

* ♻️ refactor(model-runtime): move mergeModelRuntimeHooks into the package

It's a generic utility for composing `ModelRuntimeHooks` instances — same
import surface as `ModelRuntime` and the hooks interface — so it belongs
alongside them rather than tucked under a server-side consumer.

- New `packages/model-runtime/src/core/mergeHooks.ts` exports
  `mergeModelRuntimeHooks` and is re-exported from the package index.
- Move the unit tests to `packages/model-runtime/src/core/mergeHooks.test.ts`,
  including a new case covering the "a throws → b is skipped" load-bearing
  semantics.
- `src/server/services/llmGenerationTracing/hook.ts` drops the local copy and
  the consumer (`src/server/modules/ModelRuntime/index.ts`) imports from
  `@lobechat/model-runtime`.

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

* ♻️ refactor(llm-generation-tracing): version lives with the prompt, not in a central table

`promptVersion` was baked into `TRACING_SCENARIO_REGISTRY`, far from any
prompt definition — editing a prompt + forgetting to bump the entry in a
completely different file was an obvious foot-gun.

- Registry is now `Record<string, string>` mapping trigger → scenario only;
  it's the stable concern that rarely changes.
- `resolveScenario` always passes `promptVersion` through from the caller,
  defaulting to `UNKNOWN_PROMPT_VERSION` ('v0') when absent.
- Each call site declares its own `*_PROMPT_VERSION` constant next to the
  prompt it describes. `followUpAction` ships the first one:
  `FOLLOW_UP_PROMPT_VERSION` in `prompts/index.ts`, threaded through
  `metadata.promptVersion` at the `generateObject` call. Other callers can
  add the same constant when they next touch their prompts.

The 6-char prompt hash on the row still catches forgotten bumps.

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

*  feat(input-completion): wire prompt-version metadata at the auto-complete call site

Aligns input auto-complete with the FOLLOW_UP_PROMPT_VERSION convention so
each prompt iteration is recordable as the chat-side tracing lands.

- `INPUT_COMPLETION_PROMPT_VERSION = 'v1.0'` declared next to
  `chainInputCompletion` — bump together with the prompt body.
- `fetchPresetTaskResult` accepts optional `metadata` and forwards it to
  `getChatCompletion`; the existing chat path already plumbs metadata to
  `ModelRuntime.chat` options.
- `InputEditor` call site passes
  `{ scenario: 'input_completion', promptVersion }`.

Note: `llm_generation_tracing` currently only fires from
`onGenerateObjectComplete`. Input completion is a `chat` call, so this
metadata is forward-looking until a chat-side tracing hook lands.

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

* 🐛 fix(llm-generation-tracing): collapse bucketDir path.join args to silence turbopack glob warning

Turbopack's static analyzer treats `path.join(root, dyn1, dyn2)` as a
multi-segment glob pattern and warned that it could match ~12k files in
the project. Compose the relative subdir as a single string first, so
`path.join` only sees one dynamic segment.

Behavior unchanged — the resulting path is identical.

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

*  feat(input-completion): route auto-complete through generateObject for tracing

Auto-complete is the first preset-task caller migrated to the structured-
output path so it lands in `llm_generation_tracing` via the existing
`onGenerateObjectComplete` hook. No new server hook, no global chat-side
tracing.

- `chainInputCompletion` now returns `{ messages, schema }` with a minimal
  `{ completion: string }` schema and a stable `INPUT_COMPLETION_SCHEMA_NAME`
  constant. JSON wrapping costs ~15-30 tokens against a 100-token completion
  budget — negligible for the observability win.
- `StructureOutputSchema` / `StructureOutputParams` accept optional
  `metadata`; `aiChatRouter.outputJSON` merges caller metadata over the
  default trigger so `{ scenario, promptVersion, schemaName }` reach
  `ModelRuntime.generateObject` options unchanged.
- `IStructureSchema.description` is now optional to match the zod schema —
  previously the TS type was stricter than runtime validation accepted.
- `InputEditor` switches from `chatService.fetchPresetTaskResult` to
  `aiChatService.generateJSON`, reading `response.completion`. Streaming
  is dropped because auto-complete already buffers the full result before
  inserting; no UX change.
- Reverts the unused `metadata` field that was added to
  `fetchPresetTaskResult` in the previous commit — no current caller needs
  it now that input completion uses the generateObject path.

Bumps `INPUT_COMPLETION_PROMPT_VERSION` to v2.0 because the system prompt
gained an "output the completion field" instruction.

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

* ♻️ refactor(aiGeneration): extract the runtime-init + generateObject dance into a service

Every server-side caller that produces structured output was repeating the
same two-step ritual: `initModelRuntimeFromDB(...)` → `runtime.generateObject(payload, { metadata })`.
`AiGenerationService` collapses it into one call so future cross-cutting
concerns (default metadata, retry, observability hooks) have one place to
land.

- New `src/server/services/aiGeneration/index.ts` exposes
  `generateObject<T>(input, options)` and is unit-tested for provider
  resolution + payload/metadata pass-through.
- `aiChatRouter.outputJSON` and `FollowUpActionService.extract` migrated to
  the service (other callers move organically when next touched).
- Drops the unused `keyVaultsPayload` field from `StructureOutputParams`
  and the placeholder at the InputEditor call site — key vaults are
  server-resolved from DB, the client never supplies them.

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

* ♻️ refactor(tracing): centralize TRACING_SCENARIOS const + inject AiGenerationService via trpc ctx

- New `packages/const/src/llmGenerationTracing.ts` exports `TRACING_SCENARIOS`
  + `TracingScenario` type — the single directory where every known scenario
  name lives. Adds `@lobechat/const` as a workspace dep on llm-generation-
  tracing so `TRACING_SCENARIO_REGISTRY` can reference the same literals.
- Callers (FollowUpActionService, InputEditor) replace `'follow_up'` /
  `'input_completion'` string literals with `TRACING_SCENARIOS.FollowUp` /
  `.InputCompletion`, so a typo or a rename fails the type-check instead of
  silently drifting on the row.
- `AiGenerationService` is now injected into the `aiChatProcedure` ctx
  middleware alongside `aiChatService`; `outputJSON` consumes it via
  `ctx.aiGenerationService` instead of new-ing it inside the handler.

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

*  feat(llm-generation-tracing): add lt/llm-tracing CLI + drop local-only storage_key

- Add `lt` / `llm-tracing` CLI under @lobechat/llm-generation-tracing with
  `list` (recent records, --scenario filter, --json) and `inspect` (by
  tracing_id prefix or latest, --full, --json).
- `FileTracingStore.save` now returns `{ key: null }` so dev DB rows leave
  `storage_key` empty instead of recording a non-resolvable local path; S3
  store remains the source of truth for the real key. Add helpers
  `findByTracingId` / `getLatest` used by the CLI.
- Wire `agentId` and `topicId` into `input_completion` tracing metadata
  from the chat input auto-complete call site.
- Default `FileTracingStore` whenever NODE_ENV=development (drop the
  ENABLE_LLM_GENERATION_TRACING_LOCAL opt-in env var).

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

* 💄 style(llm-generation-tracing): prettier CLI output (tree + colors)

Mirror the @lobechat/agent-tracing viewer style:

- Inline ANSI color helpers (dim/bold/cyan/magenta/green/yellow/red).
- Compact single-line header with id, scenario, version, model, status,
  time — replaces the multi-line bullet list.
- Tree structure with `├─`/`└─` connectors instead of `── section ──`
  banners.
- input arrays render per-message (role + char count + preview) rather
  than dumping raw JSON.
- Small single-key outputs (e.g. `{ completion: "怎么样" }`) collapse
  to inline `key: "value"`.
- `lt list` switches to a colored, properly padded table.

Default view stays compact; --full expands system_prompt / input /
schema bodies.

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

* ♻️ refactor(llm-generation-tracing): split `tracing` config out of `metadata`

`options.metadata` was overloaded — half tracing-specific structured fields
(scenario / promptVersion / schemaName / agentId / topicId / ...), half
free-form jsonb passthrough. Callers couldn't tell which was which, and the
inputHint was always auto-extracted (useless when the prompt wraps the user's
text in a template).

This commit introduces a dedicated `tracing` option:

- Add `TracingOptions` to @lobechat/llm-generation-tracing — the typed shape
  callers import (agentId / topicId / inputHint / scenario / promptVersion /
  schemaName / systemPrompt / parentTracingId / metadata).
- Add loose `tracing?: Record<string, unknown>` to GenerateObjectOptions and
  StructureOutputParams / StructureOutputSchema so the field flows through
  the runtime + TRPC.
- Tracing hook now reads `context.options.tracing` for structured fields; it
  still falls back to `metadata.trigger` for the cross-cutting trigger string
  (ModelRuntime itself uses metadata.trigger for timing logs, so trigger
  stays on metadata).
- Service `record()` accepts an explicit `inputHint`; otherwise falls back
  to auto-extraction from the first user message. Always truncated.
- Free-form jsonb fields move to `tracing.metadata` (was unknown-key passthrough
  on `metadata`).
- Call sites updated:
  - FollowUpAction now passes `tracing: { scenario, promptVersion, schemaName,
    topicId }` (previously `metadata`).
  - InputCompletion now passes `tracing: { agentId, topicId, inputHint: input,
    scenario, promptVersion, schemaName }` — `inputHint` is the user's actual
    typed text, not the wrapper prompt's first user message.
  - `aiChat.outputJSON` router forwards both metadata and tracing.

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

* Update inputCompletion.ts

* 🐛 fix(llm-generation-tracing): stop duplicating provider into the row's metadata jsonb

`provider` is already a first-class column on the `llm_generation_tracing`
row, so auto-stamping it into the `metadata` jsonb column on every call was
pure noise. The hook now writes the caller-supplied `tracing.metadata`
verbatim — empty/undefined when the caller had nothing to add.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 18:14:23 +08:00
Arvin Xu ddb5794826 chore: clean up LOBE-XXX code annotations (#15135)
* chore: clean up LOBE-XXX annotations from codebase comments

- Remove 【LOBE-XXX】 bracket markers
- Remove LOBE-XXXX references from inline comments
- Clean up test descriptions containing LOBE identifiers
- Preserve linear.app URLs and code-level regex patterns
- Generated: 2026-05-23 02:30:09

* 🐛 fix(tests): restore () in arrow callbacks broken by annotation cleanup

The LOBE-XXX annotation cleanup script over-matched `(LOBE-XXXX', () =>`
and stripped the callback `()`, leaving invalid syntax like
`describe(..., => {` and `it(..., async => {` across 24 test files.

This caused parse failures in Test Packages, Test Desktop App, Test
Database lint, and Test App shard runs. Restoring `()` / `async ()`
unblocks the suites while keeping the ticket-text cleanup intact.

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

* 🐛 fix(hintFormat-test): restore label + ellipsis in stripMarkdownLinks fixture

The annotation cleanup stripped `LOBE-8516` from a markdown-link's
*label* (`[LOBE-8516](/task/T-1)` → `[](/task/T-1)`), which then survived
`stripMarkdownLinks` because the pattern requires non-empty link text —
the test expected the link to disappear and asserted equality on a
LOBE-free output. The same line also lost a `.` from the trailing
`...` indicator in both input and expected strings.

Substitute a neutral Chinese label (`发布计划`) so the link continues
to exercise the multi-link substitution path, and restore the full
`...` ellipsis.

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

---------

Co-authored-by: Arvin Xu <arvinxx@lobehub.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 17:18:18 +08:00
Innei f685d5c217 feat(agent-explorer): support multi-select delete in document tree (#15125)
*  feat(agent-explorer): support multi-select delete in document tree

- Right-click on a multi-selected row deletes the whole selection; dedupe descendants when an ancestor folder is also selected
- Reserve chevron slot in SkillsList rows so atomic and bundled skills align
- Centralize EMPTY_ARRAY (typed `never[]`, frozen) in @lobechat/const

* ♻️ refactor: migrate delete confirm dialog from antd modal to confirmModal

*  test: stabilize bun vitest environment

* 🔧 ci: avoid authenticated checkout for PR tests
2026-05-23 16:44:00 +08:00
LobeHub Bot 7eee016abe 🌐 chore: translate non-English comments to English in agent-skills-identifiers (#15137)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 12:42:23 +08:00
AmAzing- 36cc836f2b 💄 style(settings): clean up settings page copy and entries (#15117) 2026-05-23 10:04:08 +08:00
AmAzing- 1c24b9e677 feat(analytics): track onboarding step events (#15133) 2026-05-23 09:40:39 +08:00
AmAzing- a22ea78460 🧹 chore(analytics): remove unused PostHog component (#15131) 2026-05-23 02:58:58 +08:00
YuTengjing b50acaca40 🐛 fix: pin baseline-browser-mapping (#15130) 2026-05-23 01:15:12 +08:00
Arvin Xu d3faa70c94 Revert "fix(github): support both runCommand and run_command in render matching"
This reverts commit 6770d8f321.
2026-05-23 01:04:44 +08:00
Innei 8cd03c8013 ️ perf: warm route chunks after idle (#15109)
* ️ perf: warm route chunks after idle

* 🐛 fix: normalize platform route chunk ids

* ️ perf: refine route chunk preloading

* 🔧 chore: keep desktop renderer preload unchanged

* ️ perf: skip renderer chunks in route warmup

* ️ perf: preload agent route dynamic chunks

* ️ perf: align route preload deployment urls

* ️ perf: coalesce stable vendor chunks

* ️ perf: group shared data runtime chunks

* ️ perf: group model runtime chunks

* ️ perf: trim initial route preloads

* ️ perf: limit idle route micro preloads

* ️ perf: strip tiny html modulepreloads

* ️ perf: prune redundant route chunk imports

* ️ perf: enable rolldown devtools

* ️ perf: gate vite devtools output

* ️ perf: optimize react-scan integration and update global types

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

* ️ perf: support cloud route chunk preload

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-05-23 01:00:53 +08:00
Innei 8a6545f799 🐛 fix(docker): make prepare script tolerant when git is unavailable (#15129)
The `prepare` script runs `git config core.hooksPath .githooks`, which
fails inside Docker build where neither `.git` nor `git` exists, causing
`pnpm i` to abort. Guard with `git rev-parse --git-dir` and a `|| true`
fallback so the script silently no-ops outside a git working tree while
still installing the local hook path for normal development.
2026-05-23 00:39:14 +08:00
Innei de9f7e092a feat(follow-up): extend follow-up chip suggestions to general chat (#15101)
*  feat(follow-up): add foundation types for chat follow-up chips

- FollowUpExtractInput.threadId for portal thread isolation
- UserSystemAgentConfig.followUpAction (global enable + model)
- LobeAgentChatConfig.enableFollowUpChips (per-agent opt-in)
- ConversationHooks.onAssistantTurnSettled first-class member
- Remove dead onGenerationStart/Complete/Cancelled hooks
- DEFAULT_SYSTEM_AGENT_CONFIG.followUpAction off by default
- DEFAULT_AGENT_CHAT_CONFIG.enableFollowUpChips false default

* ♻️ refactor(follow-up): key follow-up store by conversation for concurrency

- Convert useFollowUpActionStore from single-slot to slots map
- conversationKey = messageMapKey(agentId, topicId, threadId?) for parity with chat store
- contextSelectors.conversationKey exposes the key from ConversationProvider
- FollowUpChips and ChatItem consume conversationKey
- Onboarding hook adopts the new keyed API
- Pass threadId through to extract (server filter lands in T3)

* 🐛 fix(follow-up): address T2 code review feedback

- Restore design-intent comments for 20s timeout and race guard
- Remove dead pendingMessageId field from FollowUpActionSlot
- Remove unused slotFor selector
- Trim chipsFor / FollowUpActionSlot JSDoc to design intent only
- Gate useOnboardingFollowUp against missing onboardingAgentId
- removeSlot uses destructure; slotStatus uses ?? for falsy safety

*  feat(follow-up): filter extract by threadId for portal thread isolation

- FollowUpActionService.extract honours optional threadId
- threadId provided → eq(messages.threadId, threadId)
- threadId absent → isNull(messages.threadId) so main topic never surfaces thread replies
- Tests cover both branches

*  feat(conversation): emit onAssistantTurnSettled hook from provider

- AssistantTurnSettledWatcher fires hooks.onAssistantTurnSettled(messageId, { reason }) once per turn
- Reason derived from the most recent terminal Operation for the message id
- Reason mapping: cancelled → stopped, type=regenerate → regenerated, type=continue → continued, else → completed
- Settlement gated on idle + no pending tool intervention (mirrors Onboarding's logic)
- Tests cover all four reason branches + intervention gating + no double-fire + fallback log
- Onboarding bespoke prop untouched (migrates in T6)

* 🐛 fix(conversation): scope settlement reason to turn-level operations

- TURN_LEVEL_TYPES filter excludes child sub-ops (callLLM, executeToolCall, etc.) before sorting by endTime
- Prevents successful regenerate/continue being misreported as 'completed' when a child finishes after the parent
- Tests cover parent/child ordering for all reason branches

*  feat(follow-up): add useChatFollowUp hook and wire chat mount sites

- New mergeConversationHooks composes multiple hooks with boolean short-circuit
- useChatFollowUp computes effective enable (global × per-agent × valid model)
- Registers onBeforeSendMessage/Continue/Regenerate to clear slot and onAssistantTurnSettled to extract
- Mount sites: agent route ConversationArea, FloatingChatPanel, Portal Thread Chat (last in chain per §4.6)
- Skips on reason='stopped'; skips when effective is false
- Group chat intentionally not mounted

* ♻️ refactor(onboarding): migrate settlement to ConversationHooks first-class

- Drop bespoke onAssistantTurnSettled prop and duplicate useEffect from AgentOnboardingConversation
- useOnboardingFollowUp returns ConversationHooks { onBeforeSendMessage, onAssistantTurnSettled }
- Split settlement work: context-sync + builtin refresh runs first, chip extract runs after
- Phase snapshot captured at memoize time preserves original prevPhase semantics
- Settlement detection now lives solely in AssistantTurnSettledWatcher

*  feat(settings): add Follow-up suggestions controls (global + per-agent)

- Global System Agent page: new Follow-up Suggestions panel (model picker + enable toggle)
- Per-agent chat controls: enableFollowUpChips toggle with hint when global not configured
- i18n keys: setting.systemAgent.followUpAction.*, setting.settingChat.enableFollowUpChips.*
- Hint surfaces when user toggles per-agent ON but global is disabled/unmodeled

* 🔧 chore(follow-up): T8 — scoped lint cleanup and comment discipline pass

* 🐛 fix(follow-up): align conversationKey selector with callsite + wrap single hook

- contextSelectors.conversationKey forwards full context (scope/isNew/groupId/subAgentId) so portal-thread NEW state matches callsite-computed keys
- ConversationArea wraps chat-follow-up via mergeConversationHooks for spec §4.6 ordering robustness
- Both per final-review Important concerns

*  test(settings): update follow-up defaults snapshots

*  feat(follow-up): surface model in service-model page + default to mini

- Add followUpAction to /service-model OPTIONAL_FEATURE_ITEMS so model/provider and enable Switch render alongside inputCompletion and promptRewrite
- Seed DEFAULT_FOLLOW_UP_ACTION_SYSTEM_AGENT_ITEM with DEFAULT_MINI model/provider so out-of-box config has a valid model; users only need to flip enabled
- Sync settings selector snapshot
2026-05-23 00:31:15 +08:00
Arvin Xu 6770d8f321 fix(github): support both runCommand and run_command in render matching 2026-05-22 16:16:48 +00:00
Arvin Xu b01e4dc257 🔨 feat(db): add llm_generation_tracing and agent eval experiment tables (#15126)
🔨 chore(db): combine llm_generation_tracing and agent eval experiment tables into 0103

Merges the schema work from #14990 with the new llm_generation_tracing
table into a single idempotent 0103 migration so the two streams can
land together without a migration-number conflict.

Also adds user_id (FK + index) to agent_eval_experiment_benchmarks so
the junction table is scoped per user, matching agent_eval_run_topics.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 00:05:15 +08:00
YuTengjing 0e346c5b72 ♻️ refactor: add shared guard helpers (#15122) 2026-05-22 23:27:26 +08:00
AnotiaWang 55452cdf42 🐛 fix(web-crawler): support Jina CN domains (#14916)
Co-authored-by: AnotiaWang <AnotiaWang@users.noreply.github.com>
2026-05-22 23:05:27 +08:00
AnotiaWang 94bd7b2f6b 🐛 fix: preserve topic pagination state after topic actions and new topic creation (#13463)
* fix: topic drawer behavior after deleting topics

* fix: `hasMoreTopics` selector

* 🐛 fix: refine topic sidebar hasMore and filter-aware pagination

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: AnotiaWang <AnotiaWang@users.noreply.github.com>
Co-authored-by: Arvin Xu <arvinx@foxmail.com>
2026-05-22 23:04:05 +08:00
Rylan Cai b09d744231 🐛 fix(cli): catch promise error to avoid agent run crash in WS mode (#14830)
* 🐛 fix cli websocket agent run crash handling

* ♻️ chore trim unrelated bm-36 diff

* ♻️ chore minimize bm-36 websocket diff
2026-05-22 22:35:33 +08:00
YuTengjing 5fe9afc681 🐛 fix: preserve Gemini image diagnostics (#15120) 2026-05-22 22:03:21 +08:00
Arvin Xu 857cf9582a 💄 style(workflow): show check with warning badge for partial-success runs (#15119)
* 💄 style(workflow): show check with warning badge for partial-success runs

When a turn finishes with a mix of successful and failed tool calls, the
overall workflow now reads as "done" (green check) with a small warning
triangle pinned to the bottom-right of the status block, instead of
flipping the whole indicator to warning.

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

* 💄 style(workflow): shrink and tuck partial-status warning badge

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 21:53:44 +08:00
AmAzing- acd3da8059 🐛 fix: guard restricted default provider selection (#15118) 2026-05-22 21:01:38 +08:00
Arvin Xu 7cad53d878 🐛 fix(agent-runtime): inject local-system template vars for regular chat (#15087)
* 🐛 fix(agent-runtime): inject local-system template vars for regular chat

Before this fix, the lobe-local-system system prompt's `<user_context>`
template (`{{workingDirectory}}` / `{{hostname}}` / `{{homePath}}`)
reached the LLM as literal `{{...}}` strings whenever a user chatted in
the regular Web UI without binding a device. The model couldn't see cwd,
home, or hostname and wasted the first N steps groping for paths
(observed: 16 wasted steps in one 120-step, 1281s op).

Root cause: `activeDeviceId` resolution at execAgent had an IM/Bot
limitation — only `(discordContext || botContext) && length===1` would
auto-activate. Regular Web chat fell to `undefined`, which gated out the
`deviceSystemInfo` fetch and left the Mustache template variables empty.
The PlaceholderVariables renderer keeps `{{...}}` literals when a
generator is missing, so the placeholders reached the LLM intact.

Fix (LOBE-9378):
- Remove the IM/Bot restriction. Regular chat and IM/Bot now share the
  same single-device auto-activate rule. Multi-device users still need
  to bind explicitly — picking by recency would be a guess that could
  route tool calls to the wrong machine.
- Extract `deviceSystemInfo` fetching into a `fetchDeviceSystemInfoForTemplate`
  helper so the template-rendering decision is structurally decoupled
  from the routing decision (future fallback policies belong in the
  helper, not in activeDeviceId resolution).

* 🐛 fix(test): assert new autoActivated field on deviceContext

The PR added `autoActivated` to the deviceContext shape forwarded to
`createServerAgentToolsEngine`. The deviceToolPipeline test in a
sibling file still used a strict `toEqual` against the old three-field
shape — single online device + no binding now auto-activates, so the
assertion missed the new field.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 20:59:38 +08:00
Arvin Xu a0fac0b700 feat(skills): recognize project-level skills in the homogeneous agent runtime (#15110) 2026-05-22 19:22:41 +08:00
LiJian a35877f676 feat(platform-agent): improve device selection UX with actionable guidance (#15111)
*  feat(platform-agent): improve device UX — copyable lh connect cmd + version-too-low hint

- No-device state now shows a copyable `lh connect` command with clearer guidance to run it on the target machine then click Refresh
- Capability check failure caused by outdated lh desktop now shows a user-friendly "lh version is too low" alert with a copyable `npm install -g @lobehub/cli` upgrade command instead of the raw internal error string
- Changed no-device alert type from warning → info (absence of device is expected, not an error)
- Add en-US / zh-CN locale keys: noDevicesCmd, versionTooLow, versionTooLowHint, upgradeCmd

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

* 📝 fix(platform-agent): correct platform card descriptions — connect not run

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

*  feat(platform-agent): desktop capability check + improved no-device onboarding

- Add checkPlatformCapability / getAgentProfile handlers in GatewayConnectionCtr so desktop devices no longer return "tool not available" error
- Redesign no-device alert: primary CTA is Desktop App download (https://lobehub.com/downloads), secondary is copyable lh connect CLI command
- Add 5 tests for new capability probing handlers (43 total, all pass)
- Add missing execa/fast-glob/fflate mocks to unblock test suite

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

* 🐛 fix(platform-agent): route openclaw/hermes to correct binary in executeAgentRun

Previously all non-codex agent types defaulted to the `claude` command.
Now maps claude-code → `claude`, all other types (openclaw, hermes, …) → their
own binary name, which matches the pattern used by checkPlatformCapability.

Also adds 6 agent-run-routing tests covering openclaw/hermes/codex/claude-code
command mapping, accepted ack + sendPrompt wiring, and rejected ack on
startSession failure.

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

*  feat(platform-agent): wire runHeteroTask/cancelHeteroTask on desktop gateway

The server dispatches openclaw/hermes via executeToolCall('runHeteroTask'),
not agent_run_request. The CLI (lh connect) handles this in its methodMap;
now the desktop gateway does too.

- Port runHeteroTask + cancelHeteroTask from CLI to GatewayConnectionCtr
  - openclaw: spawn detached process, save PID, inject notify protocol on
    first turn, send done signal via sendNotify on close
  - hermes: ensure gateway daemon is running, POST to /message endpoint
- Add in-memory platformTasks registry for cancel support
- Add sendNotify helper — calls agentNotify.notify tRPC endpoint directly
  using desktop token (desktop counterpart to `lh notify`)
- Port buildNotifyProtocol inline so desktop and CLI stay in sync
- Add resolveLhPath, openclawSessionExists, getHermesPort helpers

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

* 🐛 fix(heteroTask): always inject notify protocol and kill concurrent openclaw processes

- Remove openclawSessionExists check: always inject buildNotifyProtocol
  into every turn so openclaw can report back even after a failed session
- Before spawning openclaw, kill any existing process for the same
  topicId to prevent session file lock conflicts (exit code 1)
- Apply same fixes to both CLI (heteroTask.ts) and desktop
  (GatewayConnectionCtr.ts) to keep behaviour in sync
- Add CLI unit tests (heteroTask.test.ts, 7 cases)
- Extend desktop tests to cover always-inject and kill-concurrent
  behaviours (52 total, up from 49)

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

* 🔀 chore(cli): resolve version conflict — keep 0.0.19

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

* 🔖 chore(cli): bump version to 0.0.20

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

*  feat(desktop): implement getAgentProfile via openclaw agents list --json

Port getAgentProfile from CLI (getAgentProfile.ts) to desktop gateway:
- calls `openclaw agents list --json` to get name + emoji
- reads workspace IDENTITY.md / SOUL.md for description fallback
- falls back to 🦞 emoji when no identityEmoji set

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

* 🐛 fix(desktop): make getAgentProfile async to satisfy methodMap Promise return type

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 18:44:55 +08:00
LiJian d15651bbec 🐛 fix(hetero): fix cloud CC agent execution failures and improve error messages (#15107)
* 🐛 fix(hetero): auto-retry on stale --resume session when cloud sandbox is recycled

Cloud sandboxes are ephemeral (~1h idle TTL). When a new container is
spawned for the next conversation turn, the previous CC session files under
~/.claude/projects/<cwd>/ are gone, so --resume <staleId> fails with
"No conversation found with session ID".

Two-layer fix:

CLI (lh hetero exec)
- Detect resume-not-found errors from stream error events and stderr
- Intercept the error event (withheld from the ingester so the server
  never sees a terminal error) and transparently retry without --resume
- The retry emits a fresh CC session id via heteroFinish, replacing the
  stale heteroSessionId in topic metadata and breaking the failure loop

Server (HeterogeneousPersistenceHandler)
- When result=error and no sessionId was produced (CC never emitted
  system.init, typical for resume failures), clear the persisted
  heteroSessionId from topic metadata as a safety net
- When CC ran successfully but produced an error result, sessionId IS set
  so the valid session is preserved for resume on the next turn

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

* 🐛 fix(hetero): handle context-overflow resume failure + inject conversation history

Extends the resume auto-retry to also cover the "long conversation →
immediate next turn → Agent execution failed" scenario:

CLI (hetero exec)
- Renames RESUME_NOT_FOUND_PATTERNS → RESUME_RETRY_PATTERNS and adds
  context-overflow patterns (`/prompt.*too long/i`, `/context.*too long/i`,
  etc.) so CC's API-level "prompt too long" error triggers the same
  retry-without-resume path as the sandbox-recycled case.
- Adds a test case that verifies the context-overflow error retries cleanly.

Server (cloudHeteroContext + aiAgent)
- Exports ConversationHistoryEntry from cloudHeteroContext.ts and adds
  a conversationHistory? param that renders a <previous_conversation> block
  (user turns ≤ 1 KB, assistant turns ≤ 2 KB) in the system context.
- In execAgent, when resumeSessionId is set, fetches the last 200 messages
  for the topic, filters to the last 30 user/assistant turns, and passes
  them as conversationHistory to buildCloudHeteroContext.  This gives CC
  context about prior turns even when the native session file was reset.

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

* 🐛 fix(hetero): fix SIGTERM handler leak + remove unused ingestError binding

- Store the SIGTERM callback in a variable and process.off() it in the
  finally block alongside SIGINT, so the first run's handler is removed
  before the retry run registers its own (fixes duplicate sink.finish
  calls on SIGTERM mid-retry).
- Remove unused `ingestError` from the result destructuring (downstream
  code already uses result.ingestError directly).

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

* 🐛 fix(hetero): surface CC stderr in error message instead of generic fallback

Always collect stderr from the agent process (cap 8 KB) and pass its
tail (last 1 KB) as the `error` param to `heteroFinish` when the run
fails.  The persistence handler's `flushFinalState` overwrites the
generic "Agent execution failed" fallback with the actual CC stderr,
giving users and operators a meaningful error message.

Previously:
  {"message":"Agent execution failed","type":"AgentRuntimeError"}

After this fix, e.g.:
  {"message":"Error: API error: context window exceeded (200 000 tokens)",
   "type":"AgentRuntimeError"}

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

* 🔨 chore(cli): bump version to 0.0.18

* 🐛 fix(lint): replace inline import() type with static import type

* 🐛 fix(lint): fix import sort order for ConversationHistoryEntry

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 17:38:31 +08:00
Neko fd985d0b69 🐛 chore(builtin-tool-memory): missing sourceIds in manifest causing memory failure (#15113) 2026-05-22 17:34:49 +08:00
Innei cec72199bb 🐛 fix(onboarding): prevent agent identity from using user name (#15112) 2026-05-22 17:09:01 +08:00
Arvin Xu 1a340deb75 ♻️ refactor(local-file-shell): sink desktop search modules into shared package (#14972)
* ♻️ refactor(local-file-shell): sink desktop contentSearch + fileSearch modules

Move the entire `apps/desktop/src/main/modules/contentSearch/` and
`apps/desktop/src/main/modules/fileSearch/` trees into the shared
`@lobechat/local-file-shell` package so desktop, CLI, and cloud-sandbox
runtimes share one platform-aware implementation instead of maintaining
parallel copies that drift apart (the `.github/workflows/*.yml` hidden-segment
bug fixed in #14965 had to be patched in two places).

What moves
- `contentSearch/{base,impl/{unix,linux,macOS,windows},index}.ts` → factory
  `createContentSearchImpl()` with rg → ag → grep → nodejs fallback
- `fileSearch/{base,types,impl/{unix,linux,macOS,windows},index}.ts` →
  factory `createFileSearchModule()` with fd → find → fast-glob (Unix),
  mdfind override on macOS, fd → PowerShell → fast-glob on Windows
- All 7 corresponding test files

Abstractions introduced
- `src/logger.ts`: `Logger` interface + debug-backed `createDefaultLogger`
  (namespace `lobe-local-file-shell:*`) and a `setLoggerFactory()` escape
  hatch so desktop can keep routing through electron-log if it wants
- `src/toolDetector.ts`: minimal `ToolDetector` interface
  (`getBestTool(category): Promise<string|null>` only) — desktop's
  `ToolDetectorManager` already satisfies it structurally and is injected
  lazily via `setToolDetector()`

Type-source consolidation
- `GrepContentParams/Result`, `GlobFilesParams/Result` now live in
  `@lobechat/local-file-shell/types`; `@lobechat/electron-client-ipc`
  re-exports them so the IPC contract, the desktop service, and the CLI
  share one source of truth (with legacy aliases `cwd`, `filePattern`,
  `directory` kept for back-compat)

Desktop services collapse to thin adapters
- `contentSearchSrv.ts` / `fileSearchSrv.ts` now just delegate to the
  factories; the old `apps/desktop/src/main/modules/contentSearch/` and
  `fileSearch/` directories are deleted entirely (≈4000 LoC removed)

Legacy `globLocalFiles` / `grepContent` / `searchLocalFiles` thin functions
keep their existing lightweight fast-glob / spawned-rg implementations
(unchanged semantics for CLI + cloud-sandbox callers), but now share the
`hasHiddenSegment` helper with the factory so dot-segment fixes only need
to be applied once.

Tests
- local-file-shell: 167/167
- desktop services: 58/58
- CLI file: 7/7
- builtin-tool-local-system: 64/64

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

* 🐛 fix(local-file-shell): route sunk search logs through desktop's electron-log

Reviewer caught a regression: after #14972 sank `contentSearch` and `fileSearch`
into `@lobechat/local-file-shell`, the package's default debug-only logger took
over — so search warnings/errors no longer landed in the electron-log file that
users attach for support. The desktop `setLoggerFactory()` was defined but
never called.

Two-part fix:

1. `local-file-shell/logger.ts` — the `Logger` returned by `createLogger()` is
   now a thin proxy that re-resolves the current factory on every method call
   (with a per-namespace cache). This means `setLoggerFactory()` works even
   after module-level `const logger = createLogger('...')` declarations have
   already run — important because `local-file-shell`'s search modules are
   imported (and their loggers created) before the desktop bootstrap finishes.

2. `apps/desktop/src/main/utils/logger.ts` — calls `setLoggerFactory(createLogger)`
   as a module-load side effect, so anyone importing `@/utils/logger` (which
   App.ts does) automatically rewires the package logger into electron-log.

Tests: 169/169 in local-file-shell (added `logger.test.ts` covering the late-bind
and cache-per-namespace behaviour); desktop services 58/58.

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

* ♻️ refactor(electron-client-ipc): keep package leaf — declare grep/glob types locally

Reviewer feedback: `@lobechat/electron-client-ipc` is an IPC contract package
and shouldn't reverse-depend on the business package `@lobechat/local-file-shell`
just to share four type aliases. Declare them locally instead — the two
copies must stay structurally compatible (they describe the same IPC payload
either way), but the dependency arrow now points only one direction.

Changes
- `electron-client-ipc/src/types/localSystem.ts` — re-declare GrepContentParams,
  GrepContentResult, GlobFilesParams, GlobFilesResult locally
- `electron-client-ipc/package.json` — drop the `@lobechat/local-file-shell`
  dependency
- `local-file-shell/types.ts` — tighten `success` and `total_files`/
  `total_matches` from optional to required so the two type definitions stay
  structurally interchangeable (the IPC version had them required all along)
- `local-file-shell/file/glob.ts` + `grep.ts` — thin wrappers fill in the now-
  required `engine` / `success` / `total_files` / `total_matches` fields

Tests: local-file-shell 169/169, desktop services 58/58, CLI 7/7.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:23:42 +08:00
Arvin Xu eb1ba56024 ♻️ refactor(heterogeneous-agents): align CC adapter preset with actual spawn flags (#15102)
* ♻️ refactor(heterogeneous-agents): align CC adapter preset with actual spawn flags

The CC adapter's `claudeCodePreset` hard-coded `--include-partial-messages`
and `--permission-mode acceptEdits`, but runtime spawn args come from
`spawnAgent`'s `CLAUDE_CODE_BASE_ARGS` (with partial-messages opt-in and
permission mode chosen per-caller). CLI / sandbox runs default to no
partial deltas; only the desktop driver opts in. Trim the preset to the
invariant flags so it stops implying spawn-site-specific behavior, and
fix the matching adapter / test comments that called partial-messages
"our default".

* 🔥 chore(heterogeneous-agents): remove unused CLI preset infrastructure

`claudeCodePreset` / `codexPreset` and the `AgentCLIPreset` type were
registry metadata never consumed at runtime — the actual spawn args come
from `spawnAgent`'s `CLAUDE_CODE_BASE_ARGS` / `CODEX_REQUIRED_ARGS`. The
preset field on registry entries and the `getPreset` accessor were only
reached from `registry.test.ts`. Cloud repo and downstream consumers have
zero references.

Drop the presets, the preset field on registry entries, `getPreset`, the
`AgentCLIPreset` type, related re-exports, and the orphaned tests. The
registry now just maps agent type → adapter constructor.
2026-05-22 15:51:50 +08:00
Arvin Xu 902eb9f863 🐛 fix: add pre-flight tool-limit check for GitHub Copilot (#14909)
* fix: add pre-flight tool-limit check for GitHub Copilot (128 tools)

- Add maxToolCount / maxToolPayloadBytes to AIChatModelCard
- Set maxToolCount=128 on all githubCopilot models
- Add ExceededToolLimit error type
- Create validateToolLimits utility
- Integrate pre-flight check into LobeGithubCopilotAI

Closes LOBE-8660
Part of LOBE-8678

* refactor: lift Copilot tool limit to provider settings + map ExceededToolLimit to 400

- Move maxToolCount/maxToolPayloadBytes from AIChatModelCard to AiProviderSettings; the 128-tool cap applies to every GitHub Copilot model, so a single provider-level field replaces the per-model duplication.
- Rewrite validateToolLimits to read limits from DEFAULT_MODEL_PROVIDER_LIST by providerId.
- Add ExceededToolLimit to getStatus in errorResponse.ts (alongside ExceededContextWindow) so the pre-flight error returns HTTP 400 instead of throwing RangeError from new Response(..., { status: 'ExceededToolLimit' }).

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

* test: add coverage for validateToolLimits / assertToolLimits

- ToolLimitExceededError: count overage message, payload-size message (KB rounding), combined overage, field assignment.
- validateToolLimits: empty tools, provider without declared limits, unregistered provider, count under cap, count exceeding the real GitHub Copilot 128 limit, payload-size enforcement via a synthetic provider pushed into DEFAULT_MODEL_PROVIDER_LIST.
- assertToolLimits: re-throws as a structured AgentRuntimeError chat payload with errorType ExceededToolLimit; no-op when limits are not exceeded.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 15:19:57 +08:00
Arvin Xu a41fd95eb5 feat(skills): drag skill chips + register agent-document skills (#15095)
*  feat(skills): drag skill chips from the working sidebar into the chat input

Pick a project skill from the right Skills panel and drop it onto the
chat input to insert a `/<skill-name>` action tag — the same end state
as picking it from the `/` slash menu.

- `SKILL_DRAG_MIME` lives in `@lobechat/const` so both the producer
  (sidebar) and the consumer (input drop handler) share one source of
  truth.
- `skillDragData.ts` owns the drag payload and a custom drag image: a
  themed "icon + name" chip centered above the cursor. The native drag
  image is suppressed by an invisible 1×1 ghost — the OS bakes its own
  drop shadow into it which no CSS can remove. Token values are resolved
  via `getComputedStyle` against the dragged row so the chip stays
  themed even though it mounts on `document.body`.
- `useSkillDrop` listens on the input container and only reacts to the
  `application/x-lobe-skill` MIME, so it never interferes with the
  file-upload drop zone (which keys off `Files`).
- `ProjectLevelSkills` and `SkillsGroup` wire drag-start with the
  `projectSkill` category, matching the existing slash-menu behaviour
  (markdown serializes to `/<skill-name>`).

Agent-document skills (the 智能体 Skills group) are not wired here —
they need to be registered as first-class skills in the runtime
registry first; that work is tracked separately.

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

* 💄 style(i18n): localize Skills label to 技能 across working sidebar and mention menu

- zh-CN: workingPanel.skills.* and resources.filter.skills now use 技能
  (covers the Space tab pill plus the agent/project skill section headers)
- Wire SkillStore tab and ChatInput mention categories through t() instead
  of hardcoded English labels; add mention.category.* keys for the five
  @-menu groups (Agents / Members / Topics / Skills / Tools)

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

*  feat(skills): register agent-document skill bundles in the skill registry

Agent-document skill bundles (the "智能体 Skills" panel group, stored as
isSkillBundle documents in agent_document) become first-class runtime
skills end-to-end, so the slash menu / drag chip / model activation all
share one source of truth.

Identifier convention: `agent-document:<filename>` (where `<filename>`
is the bundle's slug — `validateSkillName`-validated on the server). The
prefix prevents collisions with builtin / DB skill names; mirrors the
`project:<name>` convention used for filesystem project skills.

Server:
- `aiAgent/index.ts` SkillEngine assembly: query
  `agentDocumentsService.getAgentDocuments(resolvedAgentId)`, filter
  `isSkillBundle`, and merge into the skills array so the model sees
  them in `<available_skills>`.
- `toolExecution/serverRuntimes/skills.ts` factory: when an `agentId`
  is in the request context, load the bundles + their SKILL.md index
  children and shape them as `BuiltinSkill` entries, then concat with
  `filterBuiltinSkills(builtinSkills)` before constructing
  `SkillsExecutionRuntime`. The runtime resolves builtins by `name`
  with no DB lookup — so `activateSkill('agent-document:<filename>')`
  now returns the SKILL.md content for free, no `SkillRuntimeService`
  extension needed. `source: 'builtin'` on these entries is a
  type-system carrier shape, not a claim that they're real builtins.

Client:
- New tool-store slice `agentDocumentSkills` (per-agent scoped, cleared
  on agent switch). `useFetchAgentDocumentSkills(agentId)` is the SWR
  hook that keeps the registry hydrated; shares the SWR key with the
  working-sidebar panel so we never double-fetch.
- `useInstalledSkillsAndTools` now reads from the new slice and triggers
  the SWR hook with the active agent's id, so the `/` menu and any
  consumer that goes through that hook see agent-doc skills alongside
  builtin / lobehub / market / user skills.
- `AgentDocumentsGroup` wires `onSkillDragStart` on its SkillsList: the
  payload uses the runtime identifier (`agent-document:<filename>`),
  while the chip label keeps the human-readable title.

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

* ♻️ refactor(skills): rename agent-doc skill prefix to agent-skills + render <skill> tags

Three intertwined fixes around the agent-document skill registry that
the earlier commit (331eed1e9c) shipped half-baked:

1. **Prefix renamed `agent-document:` → `agent-skills:`** and extracted to
   `@lobechat/const` (`AGENT_SKILLS_IDENTIFIER_PREFIX`,
   `buildAgentSkillIdentifier`, `parseAgentSkillIdentifier`). The new
   prefix mirrors the unified VFS skill namespace path
   `./lobe/skills/agent/skills/<name>` flattened to one token, and
   single-sourcing it through const stops drift between the server
   resolver and the client drag wiring.

2. **`AgentDocumentsService.getAgentSkills(agentId)`** — one place to
   query bundles, filter `isSkillBundle`, resolve the `SKILL.md` index
   child, and build the runtime identifier. Both the SkillEngine
   assembly in `aiAgent/index.ts` and the `SkillsExecutionRuntime`
   factory in `serverRuntimes/skills.ts` call it instead of each
   re-implementing the prefix + bundle → index lookup (which was how
   the two sides drifted last round).

3. **`<skill>` / `<tool>` markdown plugins** (`plugins/Skill`,
   `plugins/Tool`) so the chat bubble renders these tags as the same
   chip the editor uses, instead of leaving the literal
   `<skill name="…" />` text in the message. Fixes a pre-existing bug
   that affected all registered skills (builtin / lobehub / DB / agent-
   document) — only the bare-text `projectSkill` flavour rendered
   correctly before because it serializes to `/<name>` instead.

Note: the client drag wiring in `AgentDocumentsGroup.tsx` and the
client tool-store slice action import the new const helpers, but
landing the *category* refactor (`'skill'` → `'agentSkill'`) and the
shared `@/features/SkillsList` extraction is intentionally kept out of
this commit so it can ship with its own ActionTag work.

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

* ♻️ refactor(skills): extract SkillsList feature + add agentSkill chip category

- New src/features/SkillsList/ bundle: SkillsList moved here from
  AgentDocumentsExplorer, joined by a shared SkillSection wrapper (optional
  collapsible sectionHeader prop unifies the Accordion / flat-header
  variants) and a useProjectSkills hook (SWR + open handlers).
- AgentDocumentsGroup / ProjectLevelSkills / SkillsGroup now consume that
  bundle and drop ~340 lines of duplicated SWR + section UI.
- ActionTag gains an 'agentSkill' UI category (types, mention card, style,
  en/zh editor copy) so agent-document skill chips render with their own
  tooltip / label while still serializing as <skill name="agent-skills:..."
  /> on the wire — the runtime keys off the identifier prefix, so no new
  XML tag is needed. The XML reader detects the prefix on parse to keep
  the chip's category across save/reload.
- AgentDocumentsGroup drag uses category='agentSkill', backed by the
  shared buildAgentSkillIdentifier helper.

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

*  feat(hetero-agent): classify Claude Code 529 overload as structured error

Adapter previously surfaced overload (`api_error_status: 529` /
`overloaded_error`) as a plain `{ error, message }` payload, so the
executor fell through to the unstructured branch and the UI rendered
the raw text instead of a typed `HeterogeneousAgentSessionError`. Add
a dedicated `overloaded` code + StatusGuide state with a Retry action
so the common transient failure has a recoverable, branded surface.

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

* 🐛 fix(skills): drop text/plain fallback + custom drag image — they broke every skill drag

`writeSkillDragData` also set `text/plain` to the chip label, and
`setSkillDragImage` swapped in a custom cursor-following preview. The
combination races the Lexical chat input's own drop handling: it reacts
to `text/plain` and the suppressed-native-image sequence intermittently
aborts the dragstart, leaving `useSkillDrop` to never fire. Net result
was that every skill drag (project + agent-document) silently failed.

Strip both back to the minimum that's known to work:

- `writeSkillDragData` writes only the custom `application/x-lobe-skill`
  MIME + `effectAllowed = 'copy'`. Drops on non-editor targets now do
  nothing instead of degrading to plain text — acceptable trade-off.
- Native browser drag image is back. The OS drop shadow on the ghost
  is ugly but not a regression worth losing the drag for.

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

* 🐛 fix(skills): drop agent-doc skill fetch from useInstalledSkillsAndTools

The earlier commit (331eed1e9c) wired the agent-document skill registry
into `useInstalledSkillsAndTools` by calling the SWR hook directly off
the tool-store selector:

    useToolStore((s) => s.useFetchAgentDocumentSkills)(activeAgentId);

That extra hook indirection — invoking a function selected out of
zustand on each render of the slash-menu consumer — was throwing /
breaking React's hook tracking at render time. The slash menu and every
drag-into-input flow rely on `useInstalledSkillsAndTools` resolving
cleanly, so the breakage cascaded into `/skills` not rendering and
every skill drag silently failing.

Revert to the pre-331eed1e9c shape: only the four already-working
sources (builtin / lobehub / market / user) feed the slash + mention
list. Agent-document skills are still in the tool store (server side
registers them in SkillEngine via `agent-skills:<filename>`) — they
just won't show up in the `/` autocomplete until we hydrate the slice
through a safer path (e.g. an effect in the agent route root, or
shared SWR from the panel).

Drag from the working sidebar continues to work because the wiring is
local to `AgentDocumentsGroup`, not to `useInstalledSkillsAndTools`.

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

* 💄 style(skills): restore custom drag image (white floating chip above cursor)

Brings back the cursor-following white rounded chip (icon + name) and
suppresses the native OS drag ghost. Earlier reverted along with the
`text/plain` fallback when we were narrowing down the drag breakage,
but the real culprit turned out to be the `useFetchAgentDocumentSkills`
hook indirection in `useInstalledSkillsAndTools` (fixed in 1ccdfc5821),
not the drag-image code itself.

`text/plain` stays removed — that one really does race with Lexical.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 15:13:18 +08:00
Arvin Xu a27ea18dfb 💄 style(builtin-tool): switch Task inspector copy by phase (#15104)
Inspector chips stay in chat history, so a settled TaskCreate row that still reads "Creating task" looks like the call is still running. Split lobe-claude-code task labels into .loading / .completed pairs and pick based on isArgumentsStreaming || isLoading. Documented the rule in the builtin-tool ui skill so new tools follow the same convention.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 15:12:21 +08:00
AmAzing- 875e2ffb87 🐛 fix(i18n): add provider description fallbacks (#15103) 2026-05-22 14:18:21 +08:00
LiJian 6953f188c1 feat(platform-agent): openclaw/hermes agent creation UI, device guard, and remote dispatch backend (#15065)
* ♻️ refactor(agent-invocation): add AgentInvocationIntent + unified non-hetero dispatcher (LOBE-8927/8928)

Introduce a shared invocation contract and unified dispatcher for the
non-hetero, non-group agent call paths (callAgent speak mode and @agent
direct mentions). Removes the implicit client-only fallback that existed
in both entry points.

Changes:
- agentDispatcher.ts: add AgentInvocationIntent interface as the unified
  intent type for callSubAgent / callAgent / @agent invocations
- nonHeteroSubAgentDispatcher.ts (new): dispatchNonHeteroSubAgent()
  resolves child runtime via selectRuntimeType and routes to
  executeClientAgent (client) or executeGatewayAgent (gateway);
  throws for hetero (out of scope per LOBE-8926)
- conversationLifecycle.ts #executeDirectMentionRoute: replace hardcoded
  executeClientAgent + TODO fallback with dispatchNonHeteroSubAgent call
- builtin-tool-agent-management executor.ts callAgent speak mode:
  replace hardcoded executeClientAgent + TODO fallback with
  dispatchNonHeteroSubAgent call

Fixes LOBE-8927
Fixes LOBE-8928

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

*  feat(platform-agent): openclaw/hermes agent creation UI, device guard, and remote dispatch backend

- Add CreatePlatformAgent 3-step creation modal (type select → config → bind device)
- Add RemoteAgentConfigCard to agent profile editor for openclaw/hermes config
- Add device guard banner in HeterogeneousChatInput for offline/unavailable devices
- Add useRemoteAgentDeviceGuard hook for real-time device status polling
- Fix backend dispatch: openclaw/hermes now use executeToolCall(runHeteroTask) instead of dispatchAgentRun (lh connect only handles tool_call_request)
- Add agentNotify router for lh notify → DB write + gateway stream event
- Add device.checkCapability endpoint for platform availability probe
- Add notify_update event type to gateway stream and event handler
- Add sendDoneSignal in heteroTask.ts for clean openclaw exit signaling
- Unify non-hetero sub-agent dispatch via dispatchNonHeteroSubAgent (LOBE-8927)
- Route openclaw/hermes to gateway runtime; keep claude-code/codex on hetero/client paths
- Add i18n keys for platform agent UI and device guard banners

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

* 🐛 fix(agentNotify): reuse execAgent placeholder message on first lh notify call

Instead of creating a second empty bubble, the first assistant notify
without a messageId now updates the placeholder assistantMessageId that
execAgent already seeded in runningOperation.assistantMessageId.

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

*  feat(agentNotify): cancel openclaw/hermes process on interruptTask

- Store deviceId + heteroType in topic.metadata.runningOperation at dispatch time
- interruptTask now dispatches cancelHeteroTask tool call to the bound device
  when topicId reveals a remote hetero operation, sending SIGINT to the process
- Pass topicId from gateway cancel callback to interruptTask
- Add topicId to InterruptTaskSchema and InterruptTaskParams

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

* ♻️ refactor(hetero-agent): consolidate remote/local type classification into heterogeneous-agents package

- Add RemoteHeterogeneousAgentConfig, REMOTE_HETEROGENEOUS_AGENT_CONFIGS, isRemoteHeterogeneousType, and derived type aliases (HeterogeneousAgentType, LocalHeterogeneousAgentType, RemoteHeterogeneousAgentType) to packages/heterogeneous-agents/src/config.ts
- Extend HETEROGENEOUS_TYPE_LABELS to cover remote platform types (openclaw, hermes) via REMOTE_HETEROGENEOUS_AGENT_CONFIGS
- Replace all inline `=== 'openclaw' || === 'hermes'` checks and local Sets/type aliases across aiAgent service, ProfileEditor, HeterogeneousChatInput, useRemoteAgentDeviceGuard, CreatePlatformAgent, RemoteAgentConfigCard, and deviceProxy with the shared utility
- Show OpenClaw/Hermes display name in assistant message model tag (Usage component) by setting provider=heteroType on placeholder message and using HETEROGENEOUS_TYPE_LABELS for rendering
- Fix ReferenceError: move remoteDeviceId declaration before updateMetadata call

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

* feat: add the platform agents get profiles

* 🐛 fix(platform-agent): routing, security, and i18n issues from review

- Route openclaw/hermes to gateway on desktop (P1): add isRemoteHeterogeneousType
  check in selectRuntimeType before desktop hetero branch — remote agents never
  use local desktop IPC, no special-casing needed
- Fix race in heteroTask: sendAutoNotify → sendDoneSignal now sequential via
  .finally() so error message is written before agent_runtime_end is published
- Security: validate messageId belongs to topicId in agentNotify before
  MessageModel.update to prevent cross-conversation data corruption
- Clear capability/device/profile state on platform change in creation modal (P2)
- Derive PLATFORM_DEFS from REMOTE_HETEROGENEOUS_AGENT_CONFIGS — new platforms
  automatically appear in the modal without code changes
- Use HETEROGENEOUS_TYPE_LABELS for platform names in HeterogeneousChatInput
  and RemoteAgentConfigCard (remove hardcoded PLATFORM_NAMES map)
- i18n: platform card descs, 'online'/'offline' tags, 'Select a device'
  placeholder, checkFailed error — all now use i18n keys

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

* ♻️ refactor(platform-agent): derive remote platform enum from config + fix test

- device.ts: replace hardcoded z.enum(['hermes','openclaw']) with a
  zod enum derived from REMOTE_HETEROGENEOUS_AGENT_CONFIGS so new
  platforms are automatically covered without touching this file
- heteroTask.ts / getAgentProfile.ts: use RemoteHeterogeneousAgentType
  instead of literal 'hermes' | 'openclaw' union for the same reason
- gateway.test.ts: update cancel-handler assertion to include topicId
  which was added to the interruptTask call in the previous commit

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

*  feat(platform-agent): gate creation entry behind labs flag + expand dispatcher tests

- Add enablePlatformAgent lab preference (default false) — the
  "Add Platform Agent" menu item is hidden until the user opts in
  via Settings → Advanced → Labs
- Wire toggle in settings/advanced with labs i18n key (en/zh)
- createPlatformAgentMenuItem returns null when flag is off
- agentDispatcher.test: add remote hetero cases (openclaw/hermes →
  gateway on both web and desktop) to cover the routing fix added earlier

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

* 🐛 fix(lint): merge duplicate import + sort interface props in nonHeteroSubAgentDispatcher

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

* 💄 feat(platform-agent): disable Hermes option in creation modal (coming soon)

Hermes is not yet ready for production. Mark it as coming-soon in the
platform selection step: grayed-out card, not clickable, "Coming Soon"
tag next to the name.

To enable Hermes when ready: remove 'hermes' from COMING_SOON_PLATFORMS
in CreatePlatformAgent/index.tsx.

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

*  fix(test): mock CreatePlatformAgentModal in ModalProvider.test

The modal always mounts (open=false) and calls lambdaQuery.useQuery
which requires a tRPC context not present in the test environment.
Mock it out the same way as ChatGroupWizard and EditingPopover.

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

*  fix(test): mock useUserStore + labPreferSelectors in useCreateMenuItems.test

Adding useUserStore to useCreateMenuItems triggered user store
initialization in tests, which pulled in @lobechat/const and failed
because the existing mock only exports isDesktop. Mock the store and
selectors directly instead.

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

* 🐛 fix(platform-agent): hide divider when platform agent entry is disabled

The divider before 'Add Platform Agent' was unconditional — it showed
even when the labs flag was off. Conditionally include both the divider
and the menu item together so no orphaned separator appears.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 14:04:08 +08:00
Arvin Xu 063c0b7a21 🐛 fix(command-menu): order topic/message search results by recency (#15094)
CommandK search surfaced stale topics/messages because results were ranked
purely by BM25 score across three sort layers that ignored recency:

- SearchRepo: topics/messages were limited to top-N by score, dropping newer
  items entirely. Now fetch a larger candidate pool (limit * 4) by score, then
  order topics by updatedAt DESC and messages by createdAt DESC before slicing.
- SearchRepo.search() / search router: both re-sorted the merged list by
  relevance, undoing the per-type recency order. Drop the relevance sort — the
  command palette groups results by type, so per-type order is what matters.
- cmdk client: with shouldFilter on, cmdk re-ranks items (incl. force-mounted)
  by fuzzy match against the query, overriding server order. Add a custom filter
  that returns a constant for "search-result" items so cmdk's stable sort keeps
  the server order, while built-in commands keep default fuzzy ranking.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 12:58:43 +08:00
Arvin Xu 16b932278e 🐛 fix(chat): persist topic status when run completes after agent switch (#15084)
`updateTopicStatus` looked up the topic via `getTopicById`, which only
searches the *currently active* agent's bucket. When an agent run
finishes after the user has switched to another agent, the topic isn't
in that bucket — the guard bailed early and the DB write was skipped
along with the in-memory dispatch, leaving the sidebar stuck on
"running" forever.

- Discover the owning bucket by scanning `topicDataMap` for the topicId
  (topicIds are globally unique), independent of `activeAgentId`.
- Run the DB write unconditionally so the next refetch picks up the
  persisted status even if no bucket is loaded in memory yet.
2026-05-22 12:57:59 +08:00
Arvin Xu 97111fc99d 🐛 fix(context-engine): guard placeholder log preview against undefined content (#15097)
A tool error result (e.g. budget-exceeded) can arrive with
`content: undefined`. The processor's logging step called
`JSON.stringify(undefined).slice(...)`, which throws because
`JSON.stringify(undefined)` returns `undefined`, not a string — crashing
the whole processor before any message was processed.

Coerce the preview to a string before slicing.

Fixes LOBE-9408
2026-05-22 12:46:52 +08:00
Arvin Xu 219f44c6e8 🐛 fix(agent-tasks): show 404 fallback when task does not exist (#14893)
* 🐛 fix(agent-tasks): show 404 fallback when task does not exist

Previously TaskDetailPage relied on the `isTaskDetailLoading` selector,
which returns true whenever the task is missing from the store map.
When the backend returns NOT_FOUND, the task never enters the map and
the page stays stuck on the loading spinner.

Switch to SWR's `isLoading` + `error` directly and render a NotFound
state (with a Back to all tasks action) when the fetch errored or the
task is still absent after loading completes.

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

* 🐛 fix(agent-tasks): preserve task detail on transient fetch errors

The not-found check included `!!error`, so any SWR revalidation failure
(focus/reconnect refresh, polling, temporary 5xx/network error) flipped a
cached, valid task to the 404 fallback and removed the editor until the
next successful revalidation.

Key the fallback solely off the absence of cached detail
(`!isLoading && !hasTaskDetail`), so a transient error on an
already-loaded task keeps the editor mounted.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:45:23 +08:00
LiJian 99ec113e75 💄 style(community): use landing URL for agent share link (#15099)
Change share URL from app.lobehub.com/community/agent/{id} to
lobehub.com/agent/{id} using the existing AGENTS_OFFICIAL_URL constant.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 12:20:39 +08:00
AmAzing- bf294e2df9 feat(onboarding): show agent welcome guidance (#15098) 2026-05-22 12:02:02 +08:00
YuTengjing 066c77fad7 🐛 fix: disable reasoning for Responses structured outputs (#15092)
* 🐛 fix: disable reasoning for Responses structured outputs

* 🐛 fix: preserve GPT-5 Pro Responses reasoning effort

* 🐛 fix: support GPT-5 Pro-family reasoning defaults
2026-05-22 11:25:39 +08:00
YuTengjing 8c40ff90ea ♻️ refactor: rename proLLM locale key to advancedLLM (#15093) 2026-05-22 11:11:31 +08:00
Innei 029d442992 feat(onboarding): simplify first screen and defer topic creation to first send (#15090) 2026-05-22 11:10:41 +08:00
YuTengjing 422ccc9f58 🐛 fix: bound redis command timeout (#15091) 2026-05-22 11:09:02 +08:00
Arvin Xu 83b8aa5a04 🐛 fix(agent-document): propagate sourceType and dedupe web crawls (#15088) 2026-05-22 08:40:26 +08:00
Arvin Xu e37cca70c5 chore(agent-tracing): resolve partial op id by _remote/ cache prefix (#15015)
*  feat(agent-tracing): resolve partial op id by _remote/ cache prefix

`agent-tracing inspect op_<timestamp>` used to fail with "Snapshot not found"
because the CLI only accepted the full `op_<ts>_agt_..._tpc_..._<suffix>` id.
Now when the input starts with `op_` but isn't a full id, scan the local
`_remote/` cache and resolve a unique prefix match automatically; on multiple
matches, list them and exit so the user can pick the full id.

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

* 🐛 fix(agent-tracing): preserve FileSnapshotStore fallback for op_ prefixes

The previous commit routed partial `op_<timestamp>` ids straight at the
`_remote/` cache, bypassing `FileSnapshotStore.get(...)`. That meant
in-progress local `_partial/` snapshots (which `FileSnapshotStore.get`
finds via substring match through `getPartial`) were no longer reachable
by prefix; users hit `Snapshot not found` even when the partial existed
on disk. Try the file store first, then fall back to the remote cache
prefix scan.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 02:18:26 +08:00
Innei c056760414 feat(tool): archive oversized tool results to VFS instead of truncating (#15074)
* 📝 docs: add tool result archive design

*  feat(tool): archive oversized tool results to VFS instead of truncating

When tool execution results exceed the configured max length, the full
content is now persisted to the agent's VFS under ./.tool-results/ and
the LLM receives a truncated preview with an archive path pointer.

Key changes:
- Add archiveToolResultIfNeeded() to persist oversized results via VFS
- Add skipResultTruncation flag to ToolExecutionContext so the runtime
  can receive full content for archival before truncation
- Add line-range (loc) support to VFS reads for inspecting archived files
- Extend AgentDocumentReadResult with line/char count and loc metadata
- Wire archival into both single-tool and batch-tool executor paths

*  feat(tool-archive): cover webapi client tool path and bypass agent-documents reads

Server-only AgentRuntime archive missed the main webapi chat loop where tool
execution happens in the browser. Route oversized tool results from the client
plugin executors through a new aiChat.archiveToolResult tRPC mutation that
reuses archiveToolResultIfNeeded, so calculator/MCP/klavis/lobehub-skill calls
all archive to the VFS instead of just being truncated.

Flatten the archive layout to ./.tool-results/<topicId>_<toolCallId>.md to dodge
a nested-folder edge case in the VFS resolver, surface the agent_documents.id
in the model-facing hint so the LLM can call lobe-agent-documents.readDocument
directly, and bypass archive entirely for lobe-agent-documents tool results so
reading the archive does not loop back into another archive write.

Also harden truncateToolResult against splitting a UTF-16 surrogate pair: when
the cutoff lands on a high surrogate, step back one code unit so JSON.stringify
no longer emits a lone \\uD83D escape that DeepSeek / Anthropic reject as
'unexpected end of hex escape'.

Includes a small ApprovalMode dropdown placement + trigger styling tweak.

* 🔨 chore: untrack docs/superpowers from git

The path is already excluded by .gitignore line 149; the design spec was only
in the index because an earlier commit forced it in. Remove it from tracking
while keeping the local copy so the ignore rule actually takes effect.

* 🧪 test(truncate-tool-result): exhaustive cutoff sweep over a ZWJ-composed emoji

A single surrogate pair was easy to get right; the real-world worry is ZWJ
sequences like 👨‍👩‍👧‍👦 where four surrogate pairs are stitched with ZWJs
into one grapheme. Sweep every cutoff position across that family emoji and
assert the result never leaves a lone high surrogate and always round-trips
through JSON.stringify / JSON.parse.

* 🐛 fix(thinking): drop stale loading when stream cancelled or ended

Thinking accordion and assistant content loading dot kept spinning after
the user aborted a stream or the run ended without closing the inline
`<think>` tag. Gate the markdown thinking plugins on
`isMessageGenerating(id)` and bail out of `ContentLoading` when no
running operation exists for the message.
2026-05-22 02:07:28 +08:00
YuTengjing aca724c430 🐛 fix: resolve browser model config import (#15089) 2026-05-22 01:46:51 +08:00
AmAzing- b45cb41d4b 🐛 fix(agent-builder): open panel after blank agent creation (#15085) 2026-05-22 01:06:38 +08:00
YuTengjing 736eb570af 🐛 fix: sanitize DeepSeek surrogate payloads (#15086)
* 🐛 fix: sanitize DeepSeek surrogate payloads

* Revert "🔨 chore: add DeepSeek payload diagnostics (#15062)"

This reverts commit d96912dae7.

* 🐛 fix: sanitize DeepSeek Anthropic tool inputs
2026-05-22 00:40:24 +08:00
YuTengjing af785466d1 🐛 fix: add signup email review spend locale (#15082) 2026-05-21 23:36:08 +08:00
Arvin Xu 869f10a44c 💄 style(skills-list): use colorTextSecondary by default with hover swap (#15078)
* 💄 style(skills-list): use colorTextSecondary by default with hover swap

Skill / folder / file name Text in the agent documents explorer rendered as
colorText because @lobehub/ui Text applies its own default color class that
beats the parent container's color. Set inline `color: 'inherit'` so the
existing parent secondary→text hover transition flows through.

* 💄 style(working-sidebar): replace antd Spin with NeuralNetworkLoading

The Space tab's resources loaders used antd's generic Spin dots. Swap to
NeuralNetworkLoading for consistency with the rest of the agent loading
states (content loading, context compression). Inline loader under the
Skills header uses size=24; the full-panel non-hetero loader uses size=32.
2026-05-21 23:25:24 +08:00
YuTengjing 7e78453ae3 🐛 fix: preserve current turn with zero history (#15080) 2026-05-21 23:19:21 +08:00
YuTengjing 874cf39ef3 🐛 fix: add signup email review trigger (#15079)
🐛 fix: add signup email review request trigger
2026-05-21 23:07:29 +08:00
Arvin Xu d3b6f74672 ♻️ refactor(agent-document): derive category server-side, drop frontend predicates (#15076)
* ♻️ refactor(agent-document): derive category + tab flags server-side

Add `category: 'skill' | 'document' | 'web'` plus `isFolder` /
`isSkillBundle` / `isSkillIndex` to `AgentDocumentWithRules` as server-
computed fields and inject them through `projectDocuments` so every
endpoint returning the agent-document shape gets them for free.

Drop the matching frontend categorization predicates (`isSkillBundleItem`,
`isSkillIndexItem`, `isManagedSkillItem`, `isFolderItem`) and the
duplicated `FOLDER_FILE_TYPE` / `SKILL_*` / `AGENT_SKILL_TEMPLATE_ID`
constants from `src/features/AgentDocumentsExplorer/types.ts`. The
remaining relationship helpers (`hasSkillIndexChild`,
`isOrphanSkillBundleItem`, `isProtectedManagedSkillItem`) now read the
server-derived flags directly. UI callers (`AgentDocumentsGroup`,
`DocumentExplorerTree`, `useDocumentTreeOps`, `canDrop`,
`pendingDocument`) switch to the new fields.

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

* ♻️ refactor(agent-document): consolidate skill taxonomy constants in db schemas

Move SKILL_BUNDLE_FILE_TYPE, SKILL_INDEX_FILE_TYPE, AGENT_SKILL_TEMPLATE_ID
(and the related SKILL_MANAGEMENT_SOURCE / SKILL_INDEX_FILENAME) into
packages/database/src/schemas/file.ts alongside DOCUMENT_FOLDER_TYPE — that
file is already the source of truth for the fileType column values, and
having the constants there lets deriveAgentDocumentFields import them
instead of re-declaring local copies.

src/server/services/skillManagement/constants.ts now re-exports from the
database package, so existing call sites (skillManagementService, the
agent-signal VFS providers, integration tests, etc.) keep their imports
unchanged.

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

* 🐛 fix(deepseek): satisfy thinking input type when disabling reasoning

`ChatStreamPayload['thinking']` now requires `budget_tokens` even when
`type: 'disabled'`. The generateObject test passed a bare
`{ type: 'disabled' }` input and broke `tsgo --noEmit` on CI.

Pass `budget_tokens: 0` in the input — the runtime still strips
`budget_tokens` from the disabled payload (see `index.ts` line 161 in
`buildDeepSeekAnthropicPayload`), so the assertion stays as
`{ type: 'disabled' }`.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 23:01:02 +08:00
Innei f8142de9a2 feat(chat-input): add installed skills to slash menu with mid-line trigger (#15061)
 feat: add installed skills to slash menu and support mid-line trigger

- Surface installed skills (builtin / lobehub / market / user agent) in the slash popup, reusing the action tag pipeline shared with @ mention
- Allow `/` to trigger mid-line when preceded by whitespace; in that position only skills are shown (commands stay line-start only)
- Suppress the menu inside paths/URLs (e.g. http://, a/b) by requiring line-start or whitespace before `/`
- Align ActionTag chip with surrounding text via vertical-align
2026-05-21 21:09:43 +08:00
Innei b22ac0f266 feat: drag folders into chat input as @localFile mentions on desktop (#15071)
When the agent's runtime mode is `local` (or it's a heterogeneous agent),
dragging a folder into the conversation now inserts a `<localFile path="..."
isDirectory />` mention at the editor cursor instead of recursively uploading
its contents. Mixed drops route folders to mentions and files to the existing
upload pipeline in drop order.

The drag overlay detects content kind on `dragenter` via `webkitGetAsEntry`
and swaps the title/desc/icon between "Upload Files", "Reference Folder", and
the mixed variant.

Also aligns the @ mention search and server-side local file materialization
gates with the same condition (`isLocalSystemEnabled || isHeterogeneous`)
since `lobe-local-system` plugin presence is already overridden in
toolEngineering — runtime mode is the only real gate.
2026-05-21 21:09:19 +08:00
YuTengjing b358b0b2d1 🐛 fix: handle deprecated runtime models (#15064) 2026-05-21 17:15:06 +08:00
Innei 9fb3038615 🐛 fix(onboarding): enforce response language in server runtime (#14793) 2026-05-21 16:39:26 +08:00
YuTengjing d96912dae7 🔨 chore: add DeepSeek payload diagnostics (#15062)
* 🔨 chore: add DeepSeek Anthropic payload diagnostics

* 🔨 chore: expand DeepSeek payload diagnostics
2026-05-21 16:32:48 +08:00
Innei 56cbf7a3f3 🐛 fix: prevent scrollbar from overlapping ScrollArea content (#15060)
🐛 fix: update @lobehub/ui to version 5.14.1 and add disableContentFit to ScrollArea components

Signed-off-by: Innei <tukon479@gmail.com>
2026-05-21 16:20:57 +08:00
YuTengjing 3680e5efe6 🐛 fix: guard system agent model config (#15058)
* 🐛 fix: guard system agent model config

* 🐛 fix: allow legacy system agent settings

*  test: fix disabled thinking payload type

* 🐛 fix: allow thinking without budget tokens
2026-05-21 16:16:50 +08:00
Arvin Xu e78cbaf945 💄 style(space-panel): split agent resources into Skills / Documents / Web tabs (#15057)
* ♻️ refactor(space-panel): split resources into Skills / Documents / Web tabs

Replace the All / Documents / Web filter on the agent Space panel with
three dedicated tabs (Skills / Documents / Web, default Skills) and give
the Skills tab a folder-style list with expand-to-children rows that
matches the heterogeneous agent's skills panel. Extract the row primitive
into a shared `SkillsList` component so both panels render the same UI.
Skill bundles and their `SKILL.md` index are filtered out of the
Documents tree; web items live on their own tab.

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

*  test(space-panel): mock router and skills empty state in WorkingSidebar test

`AgentDocumentsGroup` now calls `useNavigate`/`useMatch` at the top level
and defaults to the Skills tab, so the parent `AgentWorkingSidebar` test
needs a `react-router-dom` mock and the Skills empty-state i18n key.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 16:05:30 +08:00
Innei 3859b7ca51 🐛 fix(desktop): open settings via main window navigation on Windows/Linux (#15036)
The File → Preferences and Tray → Settings menu items on Windows and
Linux were calling `retrieveByIdentifier('settings').show()`, but no
browser window with the `settings` identifier exists in `appBrowsers`.
Clicking either entry threw `Browser settings not found and is not a
static browser` from `BrowserManager.retrieveByIdentifier`.

Align both platforms with the macOS implementation: show the main window
and broadcast a `navigate` event to `/settings`.
2026-05-21 15:57:10 +08:00
LiJian 9a4c8d5590 🐛 fix: hetero agent cloud credential alert flash and width misalignment (#15056)
🐛 fix: hetero agent alert flash and width misalignment

- Treat `isCredsLoading` as configured in `useHeteroAgentCloudConfig` so the
  "cloud credentials required" alert is hidden during the initial query, preventing
  the flash-then-disappear effect when credentials are already set up.
- Wrap the alert in `WideScreenContainer` in `HeterogeneousChatInput` so its
  width and centering match the chat input below it.

Co-authored-by: LobeHub Bot <bot@lobehub.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 15:40:58 +08:00
YuTengjing 1466d6eb51 feat: support thinking params for structured output (#15051)
*  feat: support thinking params for structured output

* 🐛 fix: scope generate object thinking params

* 💡 docs: clarify generate object thinking scope

* 🐛 fix: forward DeepSeek generateObject effort
2026-05-21 15:27:54 +08:00
YuTengjing ba358bf3fc 🐛 fix: support DeepSeek generateObject tool choice (#15054) 2026-05-21 12:03:59 +08:00
AmAzing- 516a2651f4 💬 chore(onboarding): refine agent setup copy (#15048) 2026-05-21 11:25:26 +08:00
YuTengjing 0911c2a94c ♻️ refactor: load models through model bank slot (#14877)
* ♻️ refactor: load models through model bank slot

* ♻️ refactor: remove static LobeHub model cards

* ♻️ refactor: share OpenAI image parameters

* 🐛 fix: load async LobeHub model config in server paths

* 🐛 fix: repair model bank CI follow-ups

* 🐛 fix: avoid repeated model bank fallback loads

* 🐛 fix: resolve business model config import in browser

* 🐛 fix: align Nano Banana 2 resolution default

* ♻️ refactor: move model loader slot under client

*  test: move model bank aiModels spec out of build entries

* 🐛 fix: use business model config for mixed provider parsing

* ♻️ refactor: consolidate model bank provider utilities

* 🐛 fix: preserve Nano Banana 2 raw resolution

* 🐛 fix: avoid generated locale sync for raw resolution

* 🌐 style: add Nano Banana 2 resolution locales

* 🌐 style: add online LobeHub model locales

* 🐛 fix: guard optional model provider loaders

* 🐛 fix: prevent sitemap build from hanging

* 🐛 fix: clear sitemap timeout after model load
2026-05-21 10:35:14 +08:00
YuTengjing fc088773bd 🐛 fix: configure anthropic client timeout (#15042) 2026-05-21 02:20:29 +08:00
Rdmclin2 1698b7e77d feat: support bot attachments across all platforms (#15029)
* feat: support bot attachments across all platforms

Squashed from feat/support-bot-attachments (15 commits):
- Wechat adapter attachment support (image/video/voice/file via iLink CDN)
- All-platform attachments: Discord, Telegram, Slack, Feishu/Lark, LINE, QQ
- Messager + CLI sendMessage/sendDirectMessage/replyToThread attachment params
- System Bot messenger installs as outbound channels + listOutboundChannels
- Onboarding messager integration + feedback commands
- AI-side attachment ingestion across platforms
- Updated builtin-tool-message systemRole / manifest / types

* chore: unify client and runtime adapter

* feat: support system bot messenger and cli

* chore: remove unnecessary listOutboundChannels

* chore: add test and prompts
2026-05-21 01:14:50 +07:00
AmAzing- b8c4df5a13 feat(onboarding): prefetch agent marketplace templates (#15041) 2026-05-21 01:55:37 +08:00
Innei 7b7690fbb6 ♻️ refactor(desktop): unify TabBar registration into a cross-platform route-meta layer (#14995)
* ♻️ refactor(desktop): unify TabBar registration into a cross-platform route-meta layer

Replace the desktop TabBar plugin registry with route-co-located metadata.

Previously four parallel registries (the RecentlyViewed plugin registry,
routeMetadata.ts, getRouteById icons, and the router config) had to be kept
in sync by hand; forgetting to register a page made its tab silently break.

Now every route declares its metadata once via `handle.meta`:
- New `routeMeta.ts` declaration types + a cross-platform `<RouteMetaBridge>`
  that resolves the active route's meta and drives `document.title`.
- Tab identity moves from semantic ids to normalized URLs (`TabItem`).
- Background-tab titles fall back through a guarded snapshot so cold-start
  store-data gaps never blank or clobber a tab.
- Deletes the 11 plugins, the registry, usePluginContext, routeMetadata.ts
  and cachedData.ts; `<PageTitle>` is removed from the (main) route tree.

*  feat(desktop): define route-meta title for task workspace routes

* ♻️ refactor(settings): create settingsRouteMeta for dynamic tab titles in settings

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

* ♻️ refactor(RouteMetaBridge): enhance dynamic route meta handling and state management

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

* 🐛 fix: scope route meta to tab url

* ♻️ refactor(PopupLayout): remove unused RouteMetaBridge component

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

* ♻️ refactor(route-meta): centralize web title updates

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-05-21 01:14:53 +08:00
YuTengjing c7976ce7f7 🐛 fix: return bad request for malformed auth JSON (#15038) 2026-05-21 00:56:57 +08:00
AmAzing- 45e07a9584 🐛 fix(onboarding): skip pro settings without Klavis (#15033) 2026-05-21 00:04:19 +08:00
Innei 55623c5661 🐛 fix(onboarding): restore mobile padding on Classic steps (#15032)
* 🐛 fix(onboarding): restore mobile padding on Classic steps

After the layout removed outer padding and inner border on mobile to
let the Agent conversation go full-bleed, Classic step content stuck
to the viewport edges. Add inline padding on the Classic Flexbox for
mobile only; Agent remains full-bleed.

* 💄 style(onboarding): inline chip-row refresh action to prevent title wrap
2026-05-20 21:45:40 +08:00
arya rizky 95c27bd748 fix: add LaTeX extensions (.tex, .sty, .cls, .bib, .bbl) to recognized text file types (#15008)
fix: add LaTeX extensions to recognized text file types

Add .tex, .sty, .cls, .bib, and .bbl to TEXT_READABLE_FILE_TYPES.
These are plain-text UTF-8/ASCII files used in LaTeX documents and should
not be treated as binary by lobe-local-system.

Closes #14917
2026-05-20 20:36:14 +08:00
Innei 67cd059340 🔨 chore: replace husky with native git hooks (#14941) 2026-05-20 20:30:49 +08:00
Innei c261c06098 feat(onboarding): adapt agent onboarding UI for mobile (#15019)
- Welcome.mobile: dedicated mobile greeting, push to bottom, static text (no typewriter)
- NameSuggestions: chips variant for mobile (horizontal scroll, emoji + name only)
- LobeMessage: add align/horizontal/disableTypewriter props, default flex-start
- CompletionPanel: explicit align=center, mobile-friendly sizes and block button
- ModeSwitch: mobile media query — avoid input area via safe-area-inset-bottom
- _layout: remove inner border/radius and outer padding on mobile
- Classic: gate ModeSwitch behind isDev (align with Agent page)
2026-05-20 19:37:05 +08:00
AmAzing- 2b2abca0ae feat(analytics): track create agent modal source (#15028) 2026-05-20 18:45:53 +08:00
YuTengjing 2eb860b59d 🐛 fix: discourage redundant visual tool calls (#15025)
🐛 fix: discourage redundant visual analysis tool calls
2026-05-20 17:19:42 +08:00
Innei 3b3632b419 🐛 fix(chat-input): prevent repeated draft restore (#15024) 2026-05-20 17:12:46 +08:00
YuTengjing b68760d0ca 💄 style: add Gemini 3.5 Flash to LobeHub provider (#15017)
- Add gemini-3.5-flash card to the LobeHub-hosted Google provider
- Fix missing structuredOutput ability on gemini-3.5-flash (google.ts, vertexai.ts)
- Fix missing image/video/audio input pricing units on gemini-3.5-flash,
  which caused multimodal input tokens to be billed at $0
2026-05-20 16:37:07 +08:00
Arvin Xu 71dd287001 ♻️ refactor(creds): remove getPlaintextCred tool to prevent plaintext credential exposure (#14998)
* refactor(creds): remove getPlaintextCred tool to prevent plaintext credential exposure

* refactor(creds): remove getPlaintextCred tool to prevent plaintext credential exposure

* refactor(creds): remove getPlaintextCred tool to prevent plaintext credential exposure

* refactor(creds): remove getPlaintextCred tool to prevent plaintext credential exposure

* refactor(builtin-tool-creds): remove getPlaintextCred from ExecutionRuntime and ICredsService

* refactor(builtin-tool-creds): remove getPlaintextCred from systemRole prompt and local_integration section

* fix(builtin-tool-creds): escape backticks in systemRole template literal
2026-05-20 16:31:37 +08:00
LiJian e87eb8c033 feat(cli): integrate OpenClaw/Hermes hetero-agent dispatch with persistent sessions and notify protocol (#15022)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:26:17 +08:00
sxjeru 63ced8167d 💄 style: add new Gemini 3.5 Flash model (#15001) 2026-05-20 14:38:03 +08:00
Innei 7cf5616638 🐛 fix(chat-input): persist unsent input drafts across tab switches (#14992)
* 🐛 fix(chat-input): persist unsent input drafts across tab switches

Switching desktop tabs remounts the conversation route, recreating the
ConversationStore and editor instance and discarding any unsent text.

Persist the editor JSON state per conversation context to localStorage:
save debounced on change (flushed on blur), restore on editor init,
and clear on a successful send. Covers both agent and group main chat,
which share the Conversation ChatInput.

* 🐛 fix(chat-input): flush draft save on unmount
2026-05-20 14:07:12 +08:00
LiJian 621b36e752 🐛 fix(hetero-finish): use heteroCurrentMsgId for lastAssistantContent (#15012)
runningOperation.assistantMessageId is the initial placeholder created at
run start. The persistence handler updates topic.metadata.heteroCurrentMsgId
on each step boundary to track the latest assistant message. Reading from
the initial placeholder produces only first-step content, causing IM to
receive a truncated reply (just the first sentence).

Fix: prefer heteroCurrentMsgId.msgId (when it matches the current operationId)
so BotCallbackService.handleCompletion receives the full final content.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:17:36 +08:00
LiJian c38e6db65c 🐛 fix(market-auth): add prompt=consent to OIDC authorization URL to fix missing refresh token (#15010)
🐛 fix(market-auth): add prompt=consent to OIDC authorization URL

Without prompt=consent the OIDC provider can skip the consent screen on
repeat logins, which causes oidc-provider to silently strip offline_access
from the granted scopes. No offline_access → no refresh_token → users are
forced to re-authenticate once the access token expires.

Co-authored-by: LobeHub Agent <agent@lobehub.dev>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:05:28 +08:00
CanisMinor 3740791573 📝 docs: add ph #1 badge (#15007)
* docs: add ph #1 badge

* docs: add ph #1 badge

* docs: add ph #1 badge
2026-05-20 12:00:22 +08:00
Arvin Xu 61f4bda987 🐛 fix(desktop): prevent App Nap from dropping gateway WebSocket during display sleep (#14994)
* fix(desktop): add powerSaveBlocker when gateway is connected

* fix(desktop): stop powerSaveBlocker on any non-connected status

* test(desktop): add powerSaveBlocker to electron mock in GatewayConnectionCtr tests
2026-05-20 10:49:39 +08:00
Arvin Xu 3bcf6a8d72 ♻️ refactor(agent-settings): consolidate Chat tab into Params popover, drop dead auto-topic feature (#14885)
* 🔥 chore(agent-config): drop dead enableAutoCreateTopic feature

Drop enableAutoCreateTopic + autoCreateTopicThreshold end-to-end. No
business code consumed these fields anymore — only types, defaults,
locale copy, UI form items, agent-builder LLM prompts, and test
fixtures kept the dead config alive.

Sweep:
- types & zod schema (LobeAgentChatConfig, AgentChatConfigSchema, openapi)
- DEFAULT_AGENT_CHAT_CONFIG constant
- locale keys in default + 18 translations
- agent-builder system prompts & tool manifests
- AgentChat form items (auto-topic switch + threshold slider)
- test fixtures & integration tests (replaced sample boolean key in
  parser tests with enableHistoryCount)
- docs/self-hosting env-var examples
- settings.test snapshot

dataImporter JSON fixtures keep the legacy keys on purpose — they
simulate historical user exports and the zod schema strips unknowns.

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

*  feat(chat-input): move inputTemplate + autoScroll into Params popover

Surface the User Input Preprocessing template (inputTemplate) and
Auto-scroll During AI Response toggle (enableAutoScrollOnStreaming) in
the chat-input Params popover, alongside compression / history /
max_tokens. Drop the matching form items from AgentChat — the popover
is now the single entry point for these two agent-level preferences.

ControlRow's action prop becomes optional so inputTemplate can render
as a label + TextArea without a Switch.

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

* 🔥 refactor(agent-settings): drop AgentChat tab in favor of Params popover

Remove the now-redundant Chat Preferences tab from agent settings:

- delete src/features/AgentSetting/AgentChat/
- drop ChatSettingsTabs.Chat enum and its three registrations
  (useCategory, AgentSettingsContent, profile Content)
- drop agentTab.chat locale key in default + 18 translations
- drop MessagesSquare / MessagesSquareIcon imports that became unused

History/compression/auto-scroll/inputTemplate already live in the
chat-input Params popover, so this tab carried no unique
functionality.

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

*  feat(chat-input): surface enableStreaming + reasoning_effort + disabledParams in Params popover

Bring the Model tab's controls into the chat-input Params popover so the
popover can become the single entry point for agent-level params.

- enableStreaming Switch at the top of Advanced (treats undefined as on,
  matching `chatConfig.enableStreaming !== false` in chat service)
- reasoning_effort row after max_tokens (Select tied to
  chatConfig.enableReasoningEffort / params.reasoning_effort, matching
  the agentConfigResolver gating)
- per-model disabledParams filter on the 4 sampling sliders (e.g. Claude
  Opus 4.7 hides temperature/top_p), via aiModelSelectors.modelDisabledParams
- max_tokens defaults to 4096 on toggle-on (parity with AgentModal),
  matching the AgentModal UX
- drop the !enableAgentMode gate on Advanced so agent-mode users still
  reach the model params once the Model tab is gone

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

* 🔥 refactor(agent-settings): drop AgentModal tab in favor of Params popover

Now that the chat-input Params popover surfaces enableStreaming,
reasoning_effort, the 4 sampling params (model-aware via
disabledParams), and max_tokens, the Model Settings tab carries no
unique behavior. Remove it:

- delete src/features/AgentSetting/AgentModal/ (index + ModelSelect)
- drop ChatSettingsTabs.Modal enum and its three registrations
  (useCategory, AgentSettingsContent, profile Content)
- drop agentTab.modal locale key in default + 18 translations
- drop BrainCog / BrainIcon imports that became unused
- simplify the profile Content inbox-default fallback to Opening
  (Content menu no longer carried Modal at all)

settingModel.* locale keys are kept — Controls still reads them.

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

* 🐛 fix(chat-input): keep !enableAgentMode gate on Advanced sampling params

Walk back the gate removal from the prior commit. Agent mode is meant
to manage temperature / top_p / penalties / reasoning_effort itself;
exposing user overrides there contradicts the design.

- Move enableStreaming out of Advanced into the common section so it
  stays visible in both modes (streaming is a UI behavior, not a
  sampling param).
- Re-wrap the SectionHeader + sampling sliders + max_tokens +
  reasoning_effort with `{!enableAgentMode && (...)}`, restoring the
  prior visibility rule.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:27:35 +08:00
YuTengjing 7144e9de28 🐛 fix: resolve desktop visual media urls (#14989) 2026-05-20 01:16:54 +08:00
Rdmclin2 0195f42daa 🐛 fix: onboarding im integration (#14988)
* feat: support onboarding messager

* chore: remove telegram CN screenshots

* feat: add feedback commands

* fix: bot feedback commands

* chore: optimize messenger intergration

* chore: update onboarding style

* feat: support wechat adapter attachments

* feat: support ai attachments

* chore: update i18n files

* fix: bot message image attachment
2026-05-19 22:51:38 +07:00
Innei 2a66071210 ♻️ refactor(onboarding): streamline discovery to a single profession question (#14987)
* ♻️ refactor(onboarding): streamline discovery to a single profession question

*  test(onboarding): update structured field fixtures
2026-05-19 22:46:46 +08:00
Neko f9b611bc69 🐛 fix(agent-signal,app): anchor agent signal receipts to messages (#14969) 2026-05-19 21:07:36 +08:00
YuTengjing 29623c4ab6 feat(profile): optimistic interests update + clickable auth logo (#14984) 2026-05-19 20:49:41 +08:00
YuTengjing d2d3888f43 🐛 fix(command-menu): promote inline type filters from setSearch (#14986) 2026-05-19 20:46:20 +08:00
Innei e7524c4f1a 🐛 fix(nav): align home sidebar layout (#14974)
* 🐛 fix(nav): align home sidebar layout

* 🐛 fix(nav): preserve sidebar bottom grouping
2026-05-19 20:19:57 +08:00
René Wang 632c1e6c49 📝 docs: add May 19 weekly changelog (#14973) 2026-05-19 19:18:57 +08:00
YuTengjing d3973a5cc0 feat: add chat cost estimate support (#14876) 2026-05-19 19:14:47 +08:00
Innei 6ab1fb2a77 feat(onboarding): add Market Agent Picker as a classic onboarding step (#14980)
*  feat(onboarding): add Market Agent Picker as a classic onboarding step

- Add AgentPickerStep as the final classic onboarding step (step 4)
- Agent onboarding skip now routes to the picker step instead of finishing
- Hide the footer skip link on the classic flow
- Relocate installMarketplaceAgents to src/services for shared use
- Map collected interests to marketplace category hints

* 💄 style(onboarding): widen agent picker step and polish card layout

- Widen the classic picker step container to 780px (other steps stay 600px)
- Left-align the LobeMessage logo to match the title
- Always reserve the agent card check slot to avoid text reflow on select
2026-05-19 18:56:58 +08:00
AmAzing- 6a7a20176a 🐛 fix(agent-builder): open builder panel after prompt creation (#14978) 2026-05-19 18:23:33 +08:00
YuTengjing a91385aabc 🐛 fix: nano banana 4K resolution dropped when aspect ratio is auto (#14977) 2026-05-19 17:30:05 +08:00
YuTengjing 1285f601df 🔨 chore: skip branded provider llm retries (#14975) 2026-05-19 16:58:20 +08:00
LiJian e5c9a1a054 🐛 fix(hetero-agent): fire IM bot-callback webhook from heteroFinish (#14968)
* 🐛 fix(hetero-agent): fire IM bot-callback completion webhook from heteroFinish

When an IM bot triggers a heterogeneous agent (Cloud Claude Code / Codex),
the execAgent hetero early-exit path discards all registered hooks, so the
`bot-completion` webhook registered by AgentBridgeService is never fired
and the IM user never receives a response.

Fix:
- Persist the `onComplete` webhook config into `topic.metadata.runningOperation.completionWebhook`
  when the hetero operation starts, alongside the existing `operationId` / `assistantMessageId`.
- In `heteroFinish`, read the stored webhook and deliver it via the existing
  `deliverWebhook` helper (export it from HookDispatcher), which honours
  QStash vs fetch delivery and resolves relative URLs with APP_URL.
- Add `completionWebhook` to the `runningOperation` Zod schema in the topic
  tRPC router and to the `ChatTopicMetadata` TypeScript interface.

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

* ♻️ refactor(hetero-finish): fix idempotency + clear runningOperation + import AgentHookWebhook

Three follow-up fixes from self-review of the completionWebhook change:

1. Idempotency — heteroFinish can be called more than once (signal path
   sends cancelled, normal exit sends the real result, transport retries).
   Now reads completionWebhook and clears runningOperation in the same
   block before delivery, so a second call finds runningOperation already
   null and skips the webhook.

2. Clear runningOperation — the normal LLM path clears this field in
   RuntimeExecutors after completion to prevent page-reload reconnects.
   The hetero path never did. Now cleared unconditionally in heteroFinish.

3. Payload order — align with HookDispatcher convention: spread
   hook.webhook.body last so it can override base fields if needed.
   (Was: `{ ...body, hookId, hookType }`. Now: `{ hookId, hookType, ...body }`)

4. Import AgentHookWebhook from hooks/types instead of inlining the type.

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

* 🐛 fix(hetero-finish): skip completionWebhook delivery on cancelled result

heteroFinish can be called twice: once with result=cancelled (from
termination signal) and once with result=success (from normal process exit).
The previous guard cleared runningOperation before delivering, so the first
call (cancelled) would fire the webhook with truncated content, and the
second call (success) would find runningOperation=null and skip delivery —
leaving the IM user with a partial response.

Fix: skip webhook delivery when result=cancelled. The subsequent success
or error call delivers the complete content. Transport-level retries of
the same result are accepted; BotCallbackService reads the latest DB
content on each invocation so duplicate deliveries are idempotent.

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

* 🐛 fix(hetero-finish): include lastAssistantContent and reason in completionWebhook payload

BotCallbackService.handleCompletion checks lastAssistantContent before
sending — without it the handler logs "no lastAssistantContent, skipping"
and returns, leaving the IM user with no reply despite the fix reaching
the delivery point.

Changes:
- Add messageModel field to HeterogeneousAgentService (reused by
  HeterogeneousPersistenceHandler so no extra DB connection)
- Read assistantMessageId from runningOperation before clearing it
- Fetch the final assistant message content via messageModel.findById
- Include lastAssistantContent, operationId, and reason (mapped from
  hetero result: success→done, error→error) in the webhook payload
- Include errorMessage/errorType on error result so handleCompletion
  can render the agent error card
- Spread completionWebhook.body last, matching HookDispatcher convention

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

* 🐛 fix(hetero-finish): don't clear runningOperation on cancelled result

When heteroFinish is called with result=cancelled (signal path) followed
by result=success (normal exit), the previous code cleared runningOperation
on the cancelled call. The subsequent success call then found runningOperation
already null, couldn't read completionWebhook or assistantMessageId, and
skipped delivery — leaving the IM user with no final reply.

Fix: early-return on result=cancelled without touching runningOperation,
so the subsequent success/error call still finds the stored webhook config.
runningOperation is only cleared on the delivering call (success/error).

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:36:35 +08:00
Arvin Xu 03c79bfb62 🐛 fix: surface stderr in errorOutput fallback and add UNKNOWN_EXEC_ERROR prefix (#14964)
* fix: surface stderr in errorOutput fallback and add UNKNOWN_EXEC_ERROR prefix

When a shell command fails with a non-zero exit code (e.g. git commit
with nothing to commit), the runner puts the error message in stderr
but does not set the error field. This caused errorOutput() to fall
through to the hardcoded 'Tool execution failed' string, losing the
actual error.

Changes:
- errorOutput() now checks state.stderr and state.error before the
  final fallback, so real error messages from stderr are surfaced
- Final fallback changed from 'Tool execution failed' to
  '[UNKNOWN_EXEC_ERROR] Tool execution failed' for easier grepping
- Same prefix applied to toResult() in the executor for consistency

* fix: pass stderr/stdout into errorOutput state for runCommand failures

runCommand() called errorOutput() with a state that only contained
{ error, isBackground, success }, missing result.result.stderr.
Since normalizeResult() stores the shell stderr under result.result.stderr
(not result.error), the state.stderr fallback in errorOutput() was
never reached for non-zero exit commands like 'git commit' with
nothing to commit.
2026-05-19 15:16:28 +08:00
Arvin Xu cf16737668 🐛 fix(local-file-shell): auto-enable hidden matching for dot-prefixed patterns (#14965)
🐛 fix(local-file-shell): auto-enable hidden matching for dot-prefixed glob/grep patterns

When callers passed patterns like `.github/workflows/*.yml` to `globLocalFiles`,
`searchLocalFiles`, or `grepContent`, the underlying engines (`fast-glob` with
`dot: false` and `rg` without `--hidden`) silently skipped dot-prefixed
directories and returned zero results — making it look like the file didn't
exist.

Detect when the pattern explicitly references a hidden segment (`.foo/...` or
`foo/.bar/...`, excluding `./` and `../` relative indicators) and auto-enable
hidden matching. A `hint` field on the result explains the auto-adjustment so
the agent doesn't treat an empty match as failure. The same fix is applied to
the desktop `contentSearch` rg/ag argument builder.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:15:06 +08:00
Innei d6dae46261 🐛 fix(document): reject unsupported file parser types (#14966) 2026-05-19 15:09:42 +08:00
YuTengjing 48ac76815d 🐛 fix: normalize Anthropic-compatible base URLs (#14960) 2026-05-19 14:45:14 +08:00
Arvin Xu d35ee849dd chore: streamline issue triage to core business labels (1-3 per issue) (#14962)
* refactor: streamline issue triage labels

---------

Co-authored-by: lobehubbot <i@lobehub.com>
2026-05-19 13:37:15 +08:00
YuTengjing 391b16e082 ️ perf: optimize chat bootstrap persistence (#14934) 2026-05-19 12:53:32 +08:00
AmAzing- 97ea30e48b 💬 fix(messenger): standardize platform preposition copy (#14959) 2026-05-19 12:40:11 +08:00
YuTengjing fd0d208152 💄 style(subscription): update budget recovery copy (#14875) 2026-05-19 11:44:27 +08:00
Arvin Xu 500a02bd88 🔒 chore: remove compromised actions-cool/issues-helper@v3 (#14956)
* fix: remove compromised actions-cool/issues-helper@v3

* fix: remove actions-cool/issues-helper

* fix: pin actions-cool/issues-helper to safe commit SHA in sync.yml
2026-05-19 11:42:01 +08:00
LobeHub Bot 8ddd8e2cff 🌐 chore: translate non-English comments to English in tests-utils and heterogeneous-agents (#14914) 2026-05-19 10:13:35 +08:00
Arvin Xu 62187d55c5 🐛 fix(portal): make markdown preview scrollable in LocalFile portal (#14919) 2026-05-19 10:11:51 +08:00
AmAzing- c68eb07a91 🐛 fix(sidebar): restore home nav for task workspace (#14945) 2026-05-19 01:46:53 +08:00
Innei 2dc812ac97 ♻️ refactor(onboarding): group chat input feature switches (#14943)
* ♻️ refactor(onboarding): group chat input feature switches

*  test(onboarding): satisfy chat input prop ordering lint
2026-05-19 01:27:42 +08:00
AmAzing- c21076eec4 🐛 fix(tasks): preserve agent context in task routes (#14926) 2026-05-18 22:54:25 +08:00
Innei b3a31ec2ee 💄 refactor(ToolTag): always use filled variant regardless of dark mode (#14937) 2026-05-18 22:04:39 +08:00
Innei c9505f7ea2 feat(follow-up): allow scene-specific model config for follow-up action extraction (#14797)
*  feat(follow-up): allow scene-specific model config for follow-up action extraction

Add optional modelConfig to FollowUpExtractInput so callers (e.g. the
onboarding agent) can specify which model/provider to use for chip
generation instead of always falling back to the generic topic system
agent.

Priority chain: caller-provided config > env overrides > default system
agent config.

*  Use scene model config for follow-up actions
2026-05-18 21:36:38 +08:00
Innei c6d3633337 🐛 fix(desktop): prevent frequent logout from token refresh retry (#14928)
* 🐛 fix(desktop): prevent frequent logout from token refresh retry

The OIDC server rotates refresh tokens and revokes the whole grant when a
consumed refresh token is reused. The desktop refresh wrapper retried the
token request up to 4 times reusing the same stored refresh token, so any
failure after the server had already consumed it (lost response, timeout,
parse error) guaranteed an invalid_grant on the next attempt and logged the
user out.

- RemoteServerConfigCtr: drop the in-line retry — refresh is now a single
  attempt; transient failures recover on the next refresh cycle
- AuthCtr: refresh proactively only when the access token is near expiry
  instead of on every launch/activation, cutting refresh-token rotations
  from dozens a day to roughly one a week
- remove the now-unused async-retry dependency

* 🐛 fix(desktop): use a small buffer for proactive token refresh checks

isTokenExpiringSoon() defaults to a 24h buffer. An OIDC server issuing
access tokens with a lifetime <= 24h would be treated as "expiring soon"
right after login, refreshing on every launch/activation and recreating
the refresh-token rotation churn this branch removes.

Pass an explicit 10-minute buffer at all three call sites (auto-refresh
timer, startup init, app activation) so the behaviour no longer depends
on the server's access-token lifetime.
2026-05-18 20:17:19 +08:00
Innei ae4145ba12 🐛 fix(desktop): restore route after update restart (#14922)
* 🐛 fix(desktop): restore route after update restart

When the desktop app installs an update and restarts via quitAndInstall, the main window always reloaded path '/', dropping whatever route the user was on. Capture the active route in installNow() and restore it on the next launch (consume-once).

* 🐛 fix(desktop): consume update restore route once
2026-05-18 19:50:12 +08:00
LiJian 8a2d05d64e 🐛 fix(market): map getUserByUsername 404 to NOT_FOUND instead of 500 (#14929)
🐛 fix(market): map 404 from market API to NOT_FOUND instead of 500

When a user hasn't set up a market username yet, getUserByUsername returns
404 — an expected first-login scenario. The backend was wrapping this as
INTERNAL_SERVER_ERROR (500), causing SWR to retry 3× per component and
flooding server logs with false-alarm 500s.

- server: catch MarketAPIError status 404 and re-throw as TRPCError NOT_FOUND
- client: add shouldRetryOnError to useMarketUserProfile so SWR does not
  retry on NOT_FOUND, eliminating log noise from UserAvatar / MarketAuthProvider

Co-authored-by: LobeHub Bot <bot@lobehub.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 17:48:46 +08:00
LiJian d359a83ade 🐛 fix: wire server-side exec_task/exec_tasks for callAgent async mode (#14913)
* 🐛 fix: wire server-side exec_task/exec_tasks for callAgent async mode

When a parent agent runs as a server-side QStash task and calls
`lobe-agent-management.callAgent(agentId, { runAsTask: true })`, the
sub-agent was silently never spawned.

Root cause (three missing links):
1. `RuntimeExecutors.ts` `call_tool` did not set `stop: true` in the
   `tool_result` payload when the tool returned an `execTask`/`execTasks`
   state, so `GeneralChatAgent` fell through to the normal LLM-call path
   instead of emitting an `exec_task` instruction.
2. No `exec_task` / `exec_tasks` executor existed in `RuntimeExecutors.ts`,
   so even if the instruction had been emitted the runtime would have thrown
   `No executor found for instruction type: exec_task`.
3. `AiAgentService` did not inject an `execSubAgentTask` callback into
   `AgentRuntimeService`, so the executors had no way to spawn the child
   operation.

Fix:
- Detect `execTask` / `execTasks` state type in `call_tool` and forward
  `stop: true` so `GeneralChatAgent` routes correctly.
- Add server-side `exec_task` and `exec_tasks` executors that create a
  task message and fire `execSubAgentTask` via an injected callback, then
  return a `task_result` / `tasks_batch_result` context so the parent agent
  can do a final LLM summary call.
- Extend `AgentRuntimeServiceOptions` with `execSubAgentTask` callback and
  propagate it through the executor context.
- Wire `this.execSubAgentTask` into `AgentRuntimeService` from
  `AiAgentService` constructor.

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

* ♻️ refactor: simplify execSubAgentTask injection + sync canary renames

- Remove bespoke ExecSubAgentTaskCallbackParams interface; reuse
  ExecSubAgentTaskParams from @lobechat/types directly (structurally
  identical, avoids duplication)
- Use this.execSubAgentTask.bind(this) instead of lambda wrapper in
  AiAgentService constructor
- Sync instruction/state type renames from canary:
    exec_task → exec_sub_agent
    exec_tasks → exec_sub_agents
    execTask state → execSubAgent
    execTasks state → execSubAgents
    task_result phase → sub_agent_result
    tasks_batch_result phase → sub_agents_batch_result
    AgentInstructionExecTask → AgentInstructionExecSubAgent
    AgentInstructionExecTasks → AgentInstructionExecSubAgents

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

*  test: add unit tests for server-side exec_sub_agent executor

Three cases covering the callAgent async fix:
1. call_tool sets stop:true when tool returns execSubAgent state
2. exec_sub_agent creates task message + calls execSubAgentTask callback
3. exec_sub_agent gracefully skips dispatch when callback not injected

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

* 🐛 fix(exec-sub-agent): report actual dispatch outcome instead of callback existence

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

* 🐛 fix(test): add as const to toolCalling.type to satisfy ToolManifestType

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 17:45:37 +08:00
CanisMinor 519e755aff 📝 docs: LobeHub Your Chief Agent Operator (#14924)
* style: update readme

* style: update readme

* style: update readme
2026-05-18 15:09:47 +08:00
Arvin Xu 652005ed21 🐛 fix(agent-signal): isolate memory-agent messages into a child thread (#14921) 2026-05-18 14:47:16 +08:00
Tsuki 27f97b2e52 🐛 fix(agent-tasks): prevent schedule pill from wrapping in Kanban card (#14923)
The schedule pill (TaskTriggerTag in tag mode) had a fixed 24px height
but no single-line constraint on its inner Text, so long descriptions
like "每周 日/一/二/六 09:00 运行" wrapped to two lines and broke the
row layout in the Kanban card. Force single-line + ellipsis truncation
and let the existing tooltip surface the full string + timezone.

Also hoist inline style objects to module scope so React.memo on
Block/Flexbox/Text isn't defeated as the Kanban re-renders many cards.

Fixes LOBE-9149

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 14:30:12 +08:00
Rdmclin2 6f42386345 🐛 fix: sidebar new agent (#14920)
fix: sidebar new agent
2026-05-18 11:54:10 +07:00
lobehubbot 1792752231 Merge remote-tracking branch 'origin/main' into canary 2026-05-18 04:42:33 +00:00
Arvin Xu 46818e9571 🚀 release: v2.2.0 (#14915)
# 🚀 LobeHub Release (20260518)

**Release Date:** May 18, 2026  
**Since v2.1.58:** 208 merged PRs · 209 commits · 16 contributors

> v2.2.0 introduces the **Chief Agent Operator** — an agent that runs
itself end-to-end. It self-iterates against its own output, assembles
sub-agent teams on demand through the heterogeneous runtime, and drives
a unified task system that knows when to pause for a human. Self-review,
AssistantGroup, and tasks/scheduling all converge into one operator
surface.

---

##  Highlights

### 🎩 Chief Agent Operator

- **Self-iteration exits Lab** — Agent Signal's self-review pipeline
ships proposal actions straight into briefs and auto-executes the
approved follow-ups, with prompts hardened against eval. The operator
now critiques and re-runs its own work without a human in the loop.
(#14769, #14583, #14647, #14882)
- **Auto-formed agent teams** — Heterogeneous AssistantGroup gains
Monitor-style signal callbacks, read-only SubAgent threads with
breadcrumb headers, and a thread switcher. The operator dispatches
sub-agents and you can step into any branch to see what the team is
doing. (#14859, #14658, #14845, #14715)
- **Task system as the operator's runway** — Claude Code surfaces task
tools, AskUserQuestion freeform notes, and a dedicated `waitingForHuman`
topic status; `lobe-task` exposes `setTaskSchedule`; the scheduler is
hardened (maxExecutions cap, sub-10min heartbeat block, race-free
SchedulerForm). Long-running operator runs no longer go silent and stop
themselves when human input is needed. (#14870, #14639, #14713, #14865,
#14853)

### 🚀 Cloud & runtime

- **Cloud Claude Code V3** — Repo picker, GitHub token flow, and
sandbox-aware context bring cloud-hosted Claude Code to feature parity
with local; cloud sandbox completion now triggers the task lifecycle
end-to-end. (#14568, #14822, #14681)
- **Heterogeneous agent multi-replica safety** — Subagent threads,
ingest refresh, and parallel-tool counts now survive replica swaps
without losing parent_id or rolling back tool state. (#14897, #14631,
#14806, #14838)
- **Built-in tool lifecycle hooks** — `onBeforeCall` / `onAfterCall`
land on the built-in tool runtime; sub-agent dispatch moves to
`lobe-agent`; self-iteration aligns with the shared inspector pattern.
(#14719, #14715, #14827)
- **Knowledge base RAG unified** — Client and server share one
`KnowledgeBaseSearchService`; KB files preserved on `NoSuchKey` instead
of silently lost. (#14673, #14501)

### 💬 Workspace experience

- **Home daily brief + recommendations** — The home screen opens with a
linkable welcome, paired input hint, and a recommendations module
sourced from the operator's hetero action library. (#14589, #14645,
#14770)
- **Chat mode + redesigned action bar** — The chat input gains a
Chat/Agent mode toggle and a re-pitched action bar with icon-and-color
action tag chips. (#14774, #14903, #14846)
- **Documents tree, optimistic** — Document tree creates, deletes, and
inline renames now apply optimistically; the agent-documents index hides
web crawls and switches to a table layout. (#14714, #14292)
- **Branded MCP inspectors** — Linear MCP tool calls render with the
same branded inspector as the built-in Linear skill; CC MCP and built-in
skills now share inspector code. (#14864, #14884)
- **Bot identity gating** — Device tools are gated by sender identity,
the activator bypass is closed, and Slack mpim plus Discord DM
regressions are fixed. (#14634, #14664, #14733)

---

## 🏗️ Core Agent & Signal Pipeline

### Self-iteration & Agent Signal

- Self-iteration graduates out of Lab, with service, tool, name, and
concept structure unified across `agent-signal`, `prompts`, `database`,
and `builtin-tool-self-iteration`. (#14699, #14769)
- Self-review now proposes actions to briefs and auto-executes the
approved set, with eval-verified prompt hardening. (#14583, #14657,
#14647)
- Self-iteration built-in tool aligns with the shared runtime +
inspector patterns. (#14827)
- Agent Signal prompts adapt their response language and avoid blocking
agent execution. (#14890, #14775, #14882)
- Receipt descriptions now carry an Agent Signal marker, and self-review
hinted skill documents route correctly. (#14764, #14895)

### Heterogeneous agent runtime

- Subagent threads render read-only with a breadcrumb header and thread
switcher; SUBAGENT badge dropped, indentation tightened. (#14658,
#14845, #14783)
- Multi-replica safety: ingest refresh restores tools/model from DB to
fix parent_id breaks; new-step assistants sync across replicas;
subagent-tagged events no longer leak into the main gateway handler.
(#14897, #14631, #14838)
- Fetch-triggering events are deferred to keep parallel tool counts from
rolling back. (#14806)
- AskUserQuestion is wired for Claude Code, with auto-decline disabled
and a freeform note input on the cloud side; `waitingForHuman` is a
first-class topic status. (#14639, #14629, #14870)
- AssistantGroup gains Monitor-style signal callbacks; project skills
surface in the working sidebar and markdown preview. (#14859, #14896)
- Cloud Claude Code V3 — repo picker, GitHub token, sandbox context;
credentials alert and disabled input when not configured. (#14568,
#14822)
- Cloud sandbox completion now triggers the task lifecycle end-to-end.
(#14681)

### Agent runtime & context engine

- Built-in tool runtime gets `onBeforeCall` / `onAfterCall` lifecycle
hooks. (#14719)
- `CompletionLifecycle`, `HumanInterventionHandler`, and
`stepPresentation` are extracted from the runtime monolith. (#14441)
- Per-tool timeout is honored end-to-end for client tool dispatch.
(#14817)
- Compression budget accounts for `tool_calls`, reasoning content, and
tool defs; `call_llm` forwards tools into the budget. (#14813, #14837)
- Pre-flight context check now fails fast for OpenAI-compatible
providers. (#14824)
- Malformed `tool_call` names are recovered instead of finishing the
step silently. (#14577)
- Sub-agent dispatch moves from `lobe-gtd` to `lobe-agent`. (#14715)
- Hidden built-in tools now appear in the system prompt @-mention list.
(#14823)

### Agent tracing & operations

- New `agent_operations` table and runtime persistence for every
hetero-agent operation. (#14416, #14736)
- `signOperationJwt` issues 4-hour signed operation tokens. (#14586)
- S3 trace snapshots are zstd-compressed; DB `trace_s3_key` aligns with
the `.json.zst` suffix; legacy `.json` fallback preserved on fetch.
(#14807, #14860, #14826)

---

## 📱 Platform & Integrations

### Bot / Channels

- Device tools are gated by sender identity. (#14634)
- Activator bypass closed and device-access checks converged. (#14664)
- Slack mpim supported; Discord DM regression fixed; Slack connect +
slash commands repaired. (#14733, #14591)
- Bot channels, bot watch, bot callback service, and system bot
reliability fixes. (#14847, #14796, #14570, #14784, #14649)
- Online Messager scaffolding. (#14755)

### Onboarding

- Home daily brief with linkable welcome and paired input hint. (#14589)
- Recommendations module sourced from the hetero agent action library.
(#14645)
- Chat onboarding passes request triggers via metadata and preserves the
resume request. (#14770, #14798)
- Discovery turn progress gated by phase, with a reminder on stalled
discovery. (#14842, #14833)
- FullNameStep back button rejoins the shared prefix; ModeSwitch hidden
in production. (#14898, #14760)
- Agent marketplace folds into the web onboarding tool. (#14578, #14672)
- Onboarding interests stored as keys instead of free text; early-exit
skips marketplace and drops CJK prompts. (#14624, #14598)

### Model providers

- Gemini 3.1 Flash-Lite cards; Gemini schema sanitizer drops
non-compliant `enum` / `required`; zero `cachedContentTokenCount`
handled in usage conversion. (#14604, #14740, #14567)
- DeepSeek-V4 model cards and pricing restored to official rates.
(#14110, #14911)
- ernie-5.1 and spark-x2-flash support; Grok 4.3 `reasoning_effort`
support. (#14643, #14731, #14642)
- SiliconCloud catalog synced with API; duplicates removed; reasoning
params adjusted. (#14464)
- Minimax derives `max_tokens` from context window to avoid
`ExceededContextWindow`. (#14814)
- aihubmix uses the full models endpoint for a complete list; stale
empty-apiKey test dropped. (#14511, #14669)
- Stream parse errors are enriched with provider + model context.
(#14636)
- Visual content parts are consumed in the server runtime; video image
references move to a JSON object. (#14637, #14900)
- Google function call magic `thoughtSignature` now attaches to every
part, not just the last turn. (#14904)
- Service model assignments settings added; model extend-param options
removed. (#14712, #14607)

### Built-in tools & knowledge base

- `lobe-task` exposes `setTaskSchedule`; task scheduler hardened
(maxExecutions cap, sub-10min heartbeat blocked, SchedulerForm race fix,
rapid automation-mode toggle stabilized). (#14713, #14865, #14853,
#14801)
- KnowledgeBaseSearchService shares RAG runtime across client and
server. (#14673)
- KB files preserved on `NoSuchKey` and orphan documents/tasks cleaned.
(#14501)
- Document tree gets optimistic create/delete + inline rename. (#14714)
- agent-documents index hides web crawls and switches to a table layout.
(#14292)
- `lobe-clarify` and SKILL.md frontmatter parsing/edit validation are
unified. (#14566)
- AnalyzeVisualMedia inspector + Portal HTML preview refactor; HTML
preview restored for AssistantGroup messages. (#14777, #14811)
- Branded inspector shared between CC MCP and built-in Linear skill.
(#14884, #14864)

---

## 🖥️ CLI & User Experience

### Chat & Conversation

- Chat mode toggle and redesigned chat input action bar. (#14774)
- Action tag chips switch to icon + colored label; ActionDropdown closes
on sibling-open and focus-out; submenu uses native header/footer slots.
(#14903, #14802, #14901)
- Action bar padding equalized around the send button; skeleton shows in
action bar while config loads. (#14846, #14656)
- `useCmdEnterToSend` is respected in thread & task inputs; send button
enables after pasting into thread/comment input. (#14850, #14816)
- TopicChatDrawer state preserved during close animation. (#14803)
- Only the last assistant block animates during markdown streaming.
(#14906)
- Right working panel no longer auto-collapses on chat mount; home agent
config fetched so knowledge toggles reflect in UI. (#14883, #14834)

### Tasks

- Task scheduler, hotkey, comment, and TodoList polish. (#14707)
- Add Subtask button & card baseline aligned; activity card stop run;
task agent manager polish. (#14848, #14559, #14569)
- Task template skeleton CLS reduced; task page placeholder copy
refreshed. (#14788, #14704)
- Task agent model snapshotted into `task.config` at create time.
(#14670)
- User-feedback card, task card polish, and Run-now context menu in
markdown. (#14727)
- Inline skill auth in recommended task templates. (#14676)

### Navigation & Layout

- Tab bar gains a Chrome-style divider between inactive tabs. (#14892)
- SideBarDrawer & header layout polish; nav ActionIcon sizing unified;
TodoList encapsulation improved. (#14762, #14692)
- Desktop header icons, sidebar density, and task menus polished.
(#14724)
- Standardized header action icon sizes. (#14717)
- Chat topic title length increased; copy session ID added to topic
dropdown menu. (#14659, #14595)
- Heterogeneous agent topic rows regain indentation. (#14783)

### Other polish

- Usage token details shortened; tool execution time formatted as `Xmin
Ys`. (#14849, #14641)
- Tool arguments display gets word-wrap toggle; long tool-call params
wrap instead of truncate. (#14706, #14640)
- Editor stops showing per-line placeholder once content is present.
(#14852)
- Visible divider between queued messages; intervention confirmation bar
polished. (#14593, #14587)
- Credit top-up copy refreshed; auth captcha retry copy refreshed; brief
recommendations layout polished. (#14821, #14561, #14871)

---

## 🔧 Tooling & Developer Experience

- Dev-only feature flag override panel. (#14565)
- `__DEV__` define replaces `process.env.NODE_ENV` in the SPA. (#14696)
- Agent-settings drops Meta/Documents tabs and restores `inputTemplate`.
(#14874)
- `local-system` forwards all `grepContent` params and moves the
executor to `/client`. (#14888)
- `lobe-task` and `setTaskSchedule` exposed. (#14713)
- Memory user-memory benchmark agent config and source-id extraction
schemas. (#14779, #14778)
- CLI man page drops stale cron entry; `clearMessages` hotkey removed.
(#14709, #14906)
- Skill docs simplified; cloud heteroContext gains sandbox TTL +
public-repo fork push guide. (#14785, #14761)

---

## 🔒 Security & Reliability

- **Security:** Sensitive comments and examples sanitized from the
production JS bundle. (#14557)
- **Security:** Inactive OIDC access rejected. (#14674)
- **Security:** CASC `new Function()` template replaced with safe string
builders. (#14751)
- **Security:** Sign-in captcha flow removed in favor of safer flow.
(#14573)
- **Security:** Desktop local file previews restricted to safe roots.
(#14789)
- **Security:** Image binary capped at 3.75 MB so base64 payload stays
under the Anthropic 5 MB limit. (#14711)
- **Reliability:** Neon/Node pools get error listeners to prevent Lambda
crashes. (#14606)
- **Reliability:** `paradedb.match(...)` replaces hardcoded normalizer
in memory search. (#14590)
- **Reliability:** `PlaceholderVariablesProcessor` errors carry
diagnostic context. (#14741)
- **Reliability:** File storage upload checks are serialized; multiple
account link bug fixed. (#14829, #14562)
- **Reliability:** `ScrollShadow` replaced with `ScrollArea` to fix a
React infinite render loop (error code 185). (#14689)
- **Reliability:** Embedding token cap enforced — long memory queries
are limited and truncated before search. (#14757)
- **Reliability:** Embed binary blob guard + oversized output cap in
`local-system.readFile`. (#14602)
- **Reliability:** Windows npm CLI shims resolved before spawning
agents. (#14772, #14720)
- **Reliability:** Vite pinned to 8.0.12 to avoid the rolldown 1.0.1
preload regression; desktop runtime externals split from native deps.
(#14804, #14776)
- **Reliability:** Old lobehub cron job removed; WeChat URL rules
dropped from web crawler. (#14630, #14633)

---

## 👥 Contributors

Huge thanks to **16 contributors** who shipped **208 merged PRs** this
cycle.

@hezhijie0327 · @sxjeru · @hardy-one · @Bianzinan · @brone1323 · @YuSaZh
· @Wxh16144 · @arvinxx · @Innei · @tjx666 · @Neko · @LiJian · @Rdmclin2
· @sudongyuer · @AmAzing129 · @rivertwilight

Plus @lobehubbot for maintenance translations.

---

**Full Changelog**:
https://github.com/lobehub/lobe-chat/compare/v2.1.58...v2.2.0
2026-05-18 12:41:47 +08:00
AmAzing- d6b5e81a57 🐛 fix(agent-signal): persist memory receipt routing metadata (#14912) 2026-05-18 11:41:33 +08:00
YuTengjing e5666882d4 💄 style(pricing): restore DeepSeek models to official pricing (#14911) 2026-05-18 11:05:47 +08:00
Arvin Xu 469a8e6661 🐛 fix(conversation): animate only the last markdown block + drop clearMessages hotkey (#14906)
* 🐛 fix(conversation): animate only the last assistant block markdown streaming

Switch `withMarkdownStreamingState` from disabling the first block to
disabling every block except the last one. The previous logic let middle
blocks keep `animated=true` during generation, so any remount mid-stream
replayed the typewriter from scratch.

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

* 🔥 chore(hotkey): remove clearCurrentMessages shortcut

Drop the Alt+Shift+Backspace binding from the chat scope. The eraser
button in ActionBar still works; only the keyboard shortcut, registry
entry, hotkey i18n and docs row are gone.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 10:59:13 +08:00
Arvin Xu 7798e4b0b5 💄 style(chat-input): switch action tag chips to icon + colored label (#14903)
* 💄 style(chat-input): switch action tag chips to icon + colored label

Replace the filled Tag chip with an inline icon + colored label so skill
and command references read like prose instead of UI badges.

- Use SkillsIcon for skill / projectSkill (both green via colorSuccess)
- Use TerminalIcon for command (cssVar.purple token, theme-aware)
- Use WrenchIcon for tool (cssVar.colorInfo)
- Preserve selection outline on .selected for the editor

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

* ♻️ refactor(chat-input): rename ActionTagView to ActionMention

The component no longer renders a Tag chip — it renders an inline icon
with colored label representing a mentioned/inserted action reference.
"Mention" matches how these are inserted in the editor (via slash menu or
@-mention) and reads better in the user-message renderer.

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

* 💄 style(chat-input): drop borders on @mention and @topic chips

@-mention (from `@lobehub/editor`) and @-topic refer chips both had
outlined borders; switch them to a borderless filled look so they sit
quietly inline with surrounding text — matching the new ActionMention.

- `ReferTopicView`: `variant="outlined"` → `variant="filled"`
- Add `mentionFilledClassName` (`.editor_mention { border: none }`) and
  apply it on both the editor (`InputEditor` className) and the rendered
  user message (`RichTextMessage` LexicalRenderer className) so input
  and read-back look the same.

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

*  feat(agent-sidebar): allow message channel for Claude Code hetero agents

Codex and other hetero providers still hide the channel entry; Claude Code agents can now use it.

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

* 🐛 fix(chat-input): satisfy strict types for icon map and mention className

CI failures from the previous commits:

- `ActionMention` typed CATEGORY_ICON as `ComponentType<any>` which is a
  superset of `LucideIcon | FC<any> | ReactNode` accepted by `<Icon>` —
  narrow to `FC<any>` so SkillsIcon and lucide icons type-check.
- `mentionFilledClassName` was a `SerializedStyles` from `css\`\``; wrap
  in `cx()` so it serializes to a `string`, which `LexicalRenderer`'s
  `className` prop requires.
- Update `Nav.test.tsx` mock to expose the new
  `currentAgentHeterogeneousProviderType` selector that landed in 89d7515.

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

* 🐛 fix(hetero-agent): keep reasoning state live during gateway streaming

The gateway event handler only accumulated reasoning text into `message.reasoning`
without ever creating a `type: 'reasoning'` operation, so `isMessageInReasoning`
was always `false`. The Thinking UI then rendered the "已深度思考" completed title
and stayed collapsed for the entire stream. Mirror `StreamingHandler`'s lifecycle:
start a reasoning sub-op on the first thinking chunk and end it on text /
tools_calling / stream_end / stream_start (next step) / agent_runtime_end / error.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 03:03:48 +08:00
Arvin Xu 654035e7b0 🐛 fix(google): add magic thoughtSignature to all functionCall parts, not just last turn (#14904)
Previously the magic signature was only applied when the last message was a
tool message and only to functionCall parts after the last user message. This
missed cross-provider scenarios (e.g. OpenAI GPT-5 → Gemini switch) where
historical tool_calls lack thoughtSignature, causing Gemini API warnings:

  Function call is missing a thought_signature in functionCall parts.

Now we unconditionally iterate all model-role contents and add the magic
signature to any functionCall part that doesn't have one, ensuring Gemini's
thought signature validator is always satisfied regardless of conversation
history origin.

See LOBE-8662
2026-05-18 02:38:02 +08:00
Innei eb39f193c9 ♻️ refactor(chat-input): adopt native submenu header/footer slots for skill menu (#14901)
* ♻️ refactor(chat-input): adopt native submenu header/footer slots for skill menu

The skill menu in the Plus dropdown pinned its search bar and stats footer as faux menu items held by position:sticky CSS hacks (data-fixed-menu-footer / data-skill-menu-search / data-skill-stats). @lobehub/ui 5.14.0 adds native header/footer slots to submenu popups, so move the search bar and stats row onto those slots and drop the hacks.

* ♻️ refactor(knowledge-controls): integrate footer into useControls and update PlusAction to utilize new structure

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

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-05-18 00:55:49 +08:00
YuTengjing 7e514ac3e3 🐛 fix: use JSON object for video image reference (#14900) 2026-05-18 00:55:29 +08:00
Zhijie He f3f2bda880 💄 style: add ernie-5.1 support (#14643) 2026-05-18 00:44:49 +08:00
Arvin Xu 6434ee9a5d 🐛 fix(agent): stop auto-collapsing right working panel on chat mount (#14883)
* 🐛 fix(agent): stop auto-collapsing right working panel on chat mount

ChatConversation had a mount effect that forcibly toggled showRightPanel
off whenever status init completed, so switching to a new topic (which
remounts the route subtree) would close the user's Workspace panel.
Drop the effect and default showRightPanel to false instead — the
persisted user preference is now the single source of truth.

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

* 🐛 fix(agent): keep right-panel toggles usable before status hydration

INITIAL_STATUS.showRightPanel now defaults to false, which means
WorkingPanelToggle / ToggleRightPanelButton / ParamsPanelToggle render
their "open" button during the pre-hydration window. But
updateSystemStatus bails early while isStatusInit is false, so the very
first click was silently dropped and the panel stayed closed even after
hydration when storage was empty.

Defer rendering these toggles until isStatusInit flips true so a click
can never land in the no-op window. Also fix the
action.test.ts > toggleRightPanel > should toggle chat sidebar case,
which was passing only because the old default was true; it now hydrates
the store before asserting.

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

* 🐛 fix(agent): stop overwriting working-sidebar tab when reopening panel

WorkingPanelToggle unconditionally set storedTab='review' on every
click, so any Space/Files preference the user had clicked previously
got clobbered the next time they re-opened the right panel — most
visibly on hetero CC sessions where the intended default is Space.

The toggle now just toggles the panel open; the sidebar's own
resolveActiveTab handles defaulting (hetero → Space, otherwise → last
explicit click, then Review/Files based on local-system availability).

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 00:44:14 +08:00
Arvin Xu b52ff52949 🐛 fix(hetero-agent): restore tools/model from DB at ingest refresh to fix multi-replica parent_id breaks (#14897)
* 🐛 fix(hetero-agent): restore tools/model from DB at ingest refresh to fix multi-replica parent_id breaks

In prod a topic with 11 step boundaries produced 4 assistants whose
parentId pointed at the previous assistant instead of the previous tool
message — same in-memory state.toolState gets reset at the end of every
handleStepStart, so if the next step's tools_calling lands on a different
replica, this replica stays empty and the following step boundary falls
back to currentAssistantMessageId. Two of the four also had
model=null/provider=null for the same reason: handleTurnMetadata only
cached lastModel/lastProvider in memory.

Adopt DB as authoritative at the ingest() refresh: replace
state.toolState wholesale when DB has more tools or more result_msg_ids
than memory, and restore state.lastModel/lastProvider from the refreshed
assistant row. Also extend handleTurnMetadata to persist model/provider
to DB (previously only metadata.usage was written), so the refresh path
has something to recover from.

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

* 🐛 fix(hetero-agent): never mark unresolved restored tools as persisted

Three sites that hydrate `state.toolState` from DB-side `assistant.tools[]`
were unconditionally pushing every id into `persistedIds`:

- `ingest()` refresh (newly added in the prior commit on this branch)
- `loadOrCreateState` (cold replica boot)
- `syncAssistantPointerForAdvancedStep`

`persistToolBatch` writes `tools[]` in Phase 1 BEFORE creating the
`role:'tool'` row in Phase 2 and backfilling `result_msg_id`. A replica
that hydrates between those two phases sees an unresolved id; marking it
as persisted then causes a follow-up retry of the same tools_calling
event to fall out of `freshForCreate`, skip Phase 2, and rewrite the
unresolved `tools[]` unchanged — leaving the tool permanently without a
tool message / result_msg_id.

Restore only ids whose `result_msg_id` is already set. Unresolved ids
stay re-createable so the BatchIngester's outer retry can complete the
write.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 23:48:26 +08:00
Arvin Xu 4766bb3eb3 feat(hetero): surface project skills in working sidebar + markdown preview (#14896)
*  feat(hetero-cc): surface project skills in working sidebar + markdown preview

When the active agent is a heterogeneous Claude Code session, the Space tab
now lists skills discovered under `<cwd>/.agents/skills/` (with a fallback
to `<cwd>/.claude/skills/`). Each row shows the skill's frontmatter name,
file count, and a chevron to expand a peek at the bundle contents; clicking
the name opens `SKILL.md` in the LocalFile portal, and clicking a child
file opens that file directly.

The LocalFile portal also gets a Preview / Raw toggle for `.md` / `.mdx`
files — frontmatter is now parsed and the YAML block stripped from the
rendered markdown body (no more `name: x description: y` reading as a wall
of body text). The portal tab strip distinguishes SKILL.md tabs by showing
the skill name with the Skills icon instead of the generic filename, and
falls back to a file icon for all other open files. Markdown content gets
its own scroll container so the Preview pane scrolls correctly.

The space-tab AgentDocuments group is hidden for hetero CC sessions so the
panel focuses on skills.

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

*  feat(hetero-cc): default to Space tab for hetero sessions

Hetero CC right-panel now defaults to the Space tab (where the Skills
module lives) when there's no prior stored tab choice. Non-hetero sessions
keep the existing review/files/resources fallback order.

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

* 💄 style(hetero-cc): surface cumulative progress on Task inspector rows

TaskCreate / TaskUpdate-with-status inspector rows now lead with the
same ProgressRing (from pluginState.todos) and a `completed/total`
chip, so a mixed create/update column reads as one continuous progress
gauge instead of bare-text per-row signals. The verb in the label
still carries the per-row status.

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

*  feat(hetero-cc): project skills in slash menu + skills panel polish

Surfaces `.agents/skills/` SKILL.md entries as a new `projectSkill`
ActionTag category in the chat input's `/` menu so users can invoke
project skills the same way CC does internally. The chip serializes to
literal `/<skill-name>` on send, leaving CC's own skill resolution
untouched (no system prompt injection).

Side-panel polish bundled in: the Space-tab Skills list expands as a
real directory tree, the LocalFile portal renders SKILL.md frontmatter
as a metadata card (reusing parseSkillMarkdownMetadata), and skill rows
use the secondary→colorText hover pattern. Also passes `data.root` (the
exact root listProjectSkills approves) to openLocalFile so previews
never hit the workspace-root mismatch path.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 23:43:27 +08:00
Innei 7ab111fcc5 🐛 fix(onboarding): restore FullNameStep back button to the shared prefix (#14898)
FullNameStep is the classic branch's first step; its back button called
goToPreviousStep, which no-ops at step 1 — a dead link ever since the
telemetry/language steps were extracted into the shared prefix.

Route it back to ResponseLanguageStep, and let CommonOnboardingPage
re-enter the shared prefix when an explicit `?step` is present (a bare
`/onboarding` still resumes the branch).
2026-05-17 23:31:11 +08:00
Neko 6281ca4228 🐛 fix(agent-signal): route hinted skill documents (#14895) 2026-05-17 22:59:00 +08:00
Arvin Xu 73fa3b1689 feat: agent-documents index — hide web crawls + new table format (#14292)
*  feat: agent-documents index — hide web crawls + new table format

The default `<agent_documents_index>` was injecting every progressive
document — including hundreds of web-crawled snapshots (~73% of all
agent docs in production). The result was a low-signal list dominated
by duplicate page titles, plus zero metadata for the LLM to rank by.

This revamp:

- Hides `source_type=web` documents from the default index. Header
  surfaces the count and points the LLM at `listDocuments(sourceType=
  'web')` to enumerate them when needed.
- Renders the index as a fixed-width table with TITLE / ID / SIZE /
  UPDATED columns. Rows are sorted by recency (most-recent first).
  Empty docs render as `empty` to discourage retry reads.
- Adds `sourceType` and `updatedAt` to the `AgentContextDocument`
  contract; client mapping populates both from the DB row.
- Adds `sourceType: 'all' | 'file' | 'web'` parameter to the
  listDocuments tool/TRPC; service-layer filter applies before
  shaping the LLM response.
- Renames `target` → `scope` on listDocuments + createDocument
  (manifest, types, runtime, system role, TRPC, client service,
  call sites, tests). `target="currentTopic"` becomes
  `scope="currentTopic"` everywhere.

Coverage: inline snapshot tests in
`packages/context-engine/src/providers/__tests__/AgentDocumentInjector.test.ts`
pin the rendered output for the three load cases (mixed user docs,
web-hidden header, empty doc).

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

* 🐛 fix(test): update listDocuments mock assertion for sourceType default

The agent-documents listDocuments runtime now forwards sourceType
(defaulting to 'all'), so the spy receives two positional args.

* 📝 docs(builtin-tool-local-system): bump documented runCommand max timeout to 800000ms

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:08:08 +08:00
Neko 04e9f7fcea ♻️ refactor(agent-signal): adapt response language for prompts (#14890) 2026-05-17 21:20:59 +08:00
Arvin Xu 1cc92db5e2 💄 style(tab-bar): add Chrome-style divider between inactive tabs (#14892) 2026-05-17 21:10:31 +08:00
Arvin Xu 2d088ca6e2 🐛 fix(local-system): forward all grepContent params + move executor to /client (#14888)
* 🐛 fix(local-system): forward all grepContent params + move executor to /client

The local-system executor was reducing the agent's full grepContent params
({pattern, glob, output_mode, -i/-n/-A/-B/-C, multiline, head_limit, type,
scope, ...}) down to {directory, pattern} before handing them to the runtime.
`directory` isn't recognized by the IPC layer (which expects path/scope), so
cwd silently fell back to process.cwd() (= apps/desktop/ in dev), and with
glob/-i/output_mode all stripped grep matched anything containing the pattern
across the whole tree — explaining LOBE-8666's dist/main/index.js +
tsconfig.tsbuildinfo leaks.

Also audited the rest of the executor layer:
- listFiles: forward `limit` (was silently dropped → manifest default of 100
  always won).
- getCommandOutput: forward `filter` (was silently dropped → no regex filter
  ever applied to streamed output).
- runCommand: mirror `run_in_background` → `background` so
  ComputerRuntime.RunCommandState.isBackground reflects reality (the IPC
  handler reads run_in_background directly, so the command itself ran in
  background — only the state field was wrong).

Structure: moved src/executor/ → src/client/executor/ to match the other
builtin-tool packages (task / lobe-agent / knowledge-base) and consolidate
renderer-only code under /client. Dropped the `./executor` package subpath;
consumers now import from `…/client`.

Defensive: also added a resolveSearchPath helper in apps/desktop's
contentSearch module that reads params.scope as a fallback for params.path,
so any non-executor caller (direct IPC, future Gateway path) that passes
`scope` still gets routed correctly instead of falling through to
process.cwd().

Regression coverage:
- grepContent full forwarding (LOBE-8666 case + all optional flags)
- listFiles.limit forwarding
- getCommandOutput.filter forwarding
- runCommand.run_in_background → background mirror
- resolveSearchPath fallback semantics (3 cases in base.test.ts)

Verified end-to-end via Electron CDP — tool.invokeBuiltinTool with the
LOBE-8666 params returns 9 clean .ts matches (no dist/, no .tsbuildinfo);
listFiles {limit:3} returns 3 files (totalCount 10); runCommand
{run_in_background:true} reports state.isBackground=true.

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

* 🐛 fix(desktop): readFile fails with `protocol.registerSchemesAsPrivileged should be called before app is ready`

Two-part fix for a regression where reading any text/JSON/source file via the
local-system `readFile` tool surfaced an Electron protocol error in the response
content. The error fired *after* `stat()` succeeded (so missing-file ENOENT was
unaffected), making it look like the file couldn't be parsed.

## Root cause

Stack trace (instrumented `read.ts` to capture it):

```
Error: protocol.registerSchemesAsPrivileged should be called before app is ready
    at new App (apps/desktop/dist/main/index.js:105339:21)
    at Module.<anonymous> (apps/desktop/dist/main/index.js:105615:11)
    at Module._compile (...)
```

`Module._compile` on `dist/main/index.js` means the main bundle is being freshly
evaluated as a CJS module — re-running its top-level `var app = new App(); …;
app.bootstrap();` after the real Electron-launched App was already ready.

Triggering chain: agent calls `readFile` → main runs `loadFile(path)` from
`@lobechat/file-loaders` → `getFileLoader('txt')` → `await import('./text')`.
The lazy text-loader chunk back-references the main bundle for the shared util
`detectUtf16NoBom`:

```js
// dist/main/text-Cbmlmtca.js
const require_index = require("./index.js");      // ← re-evaluates main
…
const variant = require_index.detectUtf16NoBom(buffer);
```

Electron's main entry is not in Node's CJS module cache (it's bootstrapped
separately), so this `require("./index.js")` triggers a fresh compile of the
main bundle — re-running `new App()` and `protocol.registerSchemesAsPrivileged`
*after* `app.whenReady()`, which is illegal per Electron's API contract.

Introduced by #14602 (`fix(local-system): guard readFile against binary blobs
and oversized output`): adding `isBinaryContent.ts` made `detectUtf16NoBom`
shared between the main bundle (via `sniffBinaryFile`) and the lazy text chunk,
so rolldown placed it in main and rewrote the text chunk's call as a
`require_index.detectUtf16NoBom`.

Identical class of bug previously fixed for the `debug` package in #11827.

## Fix

1. **`packages/file-loaders/src/loaders/index.ts`** — TextLoader was lazy-imported
   for no real benefit. It's a 10KB module whose only deps are `node:fs/promises`
   and a tiny utf-16 detect util — nothing like the multi-MB parsers (pdfjs-dist,
   xlsx, mammoth) that the lazy pattern was designed for. Make it a static
   import; `getFileLoader('txt')` returns it synchronously. Result: the text
   chunk disappears entirely, removing this back-reference at the source.

2. **`apps/desktop/electron.vite.config.ts`** — defensive `manualChunks` rules
   so any future shared symbol doesn't recreate the same trap:
   - `vendor-file-loaders-utils` for the three small text/binary detection
     utils (`detectUtf16` / `isBinaryContent` / `isTextReadableFile`).
     Explicitly enumerated to avoid catching `parser-utils.ts`, which pulls
     in xmldom/yauzl/concat-stream (≈900KB) and belongs in the docx/pptx
     chunks instead.
   - `vendor-jszip` for JSZip — same root cause for `.docx` reads: the docx
     chunk had `require_index.require_lib()` (JSZip) back-referencing main.
     Both ends now share the vendor chunk; no main re-eval.

Follows the project precedent set by #11827 for `debug`.

## Verification (live Electron via CDP)

Bundle inventory before/after:

| Chunk | Before | After |
| --- | --- | --- |
| `text-*.js` | 9.7KB (back-refs main) | (gone, inlined into main) |
| `vendor-file-loaders-utils-*.js` | n/a | 18KB |
| `vendor-jszip-*.js` | n/a | 899KB |
| `docx-*.js` back-refs | `require_index.require_lib` | none |

End-to-end via `tool.invokeBuiltinTool('lobe-local-system', 'readFile', …)`:

| File | Before | After |
| --- | --- | --- |
| `.md` / `.json` / `.ts` | `Error accessing or processing file: protocol.registerSchemesAsPrivileged should be called before app is ready` | real file content |

`grep -o 'require_index\\.[a-zA-Z_]*' dist/main/*-*.js | sort -u` → empty.

All 61 file-loaders tests pass; all 64 builtin-tool-local-system tests pass.
2026-05-17 20:26:15 +08:00
Arvin Xu 43b0b5e854 🐛 fix(agent-runtime): honor per-tool timeout end-to-end for client tool dispatch (#14817)
* 🐛 fix(agent-runtime): honor per-tool timeout end-to-end for client tool dispatch (LOBE-8436)

Server BLPOP was hardcoded to 60s and ignored the LLM-supplied `timeout` in
`tool_call.arguments`, so long-running shell commands consistently failed
with a server-side timeout while the desktop runner was still happily
executing. Renderer also never raced its own deadline, leaving it free to
hang past the server budget.

Plumb a per-tool timeout through the full chain:

  - New `resolveToolTimeoutMs` (server) — priority: `args.timeout` >
    `manifest.api[apiName].defaultTimeoutMs` > 120s global default,
    clamped to [1s, 800s] (cloud function ceiling).
  - `dispatchClientTool` accepts `timeoutMs` in ctx; constants moved into
    `resolveToolTimeout.ts`. Default 60→120s, max 270→800s.
  - `RuntimeExecutors` calls the resolver at both client-dispatch sites
    (single + batch) using the LLM-parsed args and the effective manifest.
  - `LobeChatPluginApi` (types + context-engine) gains
    `defaultTimeoutMs?: number` so tool authors declare per-API budgets.
  - `LocalSystemManifest` sets per-API defaults: runCommand 120s,
    read/write/edit/list 30s, grep/glob/search/move 60s, killCommand 10s.
  - `local-file-shell/runner.ts` internal kill cap raised 600→800s to
    match the server ceiling.
  - Renderer `clientToolExecution.ts` rewritten to (1) race executor
    against `executionTimeoutMs - 500ms`, abort the operation's
    AbortController, and send `client_executor_timeout` on overrun;
    (2) read `gatewayConnections[operationId]` live on every send so
    reconnects between dispatch and result are picked up; (3) wrap in
    try/finally with an exactly-once `sent` guard so every `tool_execute`
    yields exactly one `tool_result` even on logic gaps.

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

* 🐛 fix(test): drop unused @ts-expect-error and tighten timeout assertion

CI lint failed on tsgo: an `@ts-expect-error` directive in
`resolveToolTimeout.test.ts` was unused (the field's `unknown` value
type happily accepts a string at compile time), and the
`sendToolResult.mock.calls[0][0]` access in `clientToolExecution.test.ts`
tripped TS2493/TS2532 because vitest typed `calls` as an empty tuple.

Cast the test-only string value through `unknown` for the resolver
defense check; merge the budget assertion into the `toHaveBeenCalledWith`
matcher via `expect.stringContaining('2000ms')` so we never index into
`mock.calls` by hand.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:23:15 +08:00
Arvin Xu 0e46085176 💄 style: share branded inspector between CC MCP and built-in Linear skill (#14884)
*  feat(linear): share branded inspector between CC MCP and built-in Linear skill

The Linear-branded inspector (logomark + action chip + parentId badge) was
only registered against `mcp__claude_ai_Linear__*` tool names emitted by the
CC adapter. LobeHub's own built-in Linear skill calls land with
`identifier='linear'` and bare apiNames (`get_issue`, `save_issue`, …), so
they fell through to the generic Title + JSON inspector despite being the
exact same Linear surface.

Moves the inspector + label utilities out of `builtin-tool-claude-code` into
`packages/builtin-tools/src/linear/` (alongside `github/`) and registers
them twice in the central inspector map: once under `LinearIdentifier =
'linear'` for the built-in skill path, once merged into the CC entry for
the MCP-prefixed wire names. Same component, same look in both cases.

`formatLinearShortLabel` now matches bare apiNames against the known tool
list too, so the collapsed workflow summary reads `Linear · Get issue`
for built-in calls as well — previously only CC got the humanized label.

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

* ♻️ refactor(linear): leave CC's LinearMcp inspector inside CC, only ship the built-in skill side

Walks back the cross-package edits from the previous commit. The CC adapter
keeps its own `LinearMcp.tsx` + `linearMcpLabels.ts` exactly as #14864 left
them — `formatLinearMcpShortLabel` is still exported from
`@lobechat/builtin-tool-claude-code/client/labels` and `toolDisplayNames.ts`
still imports it from there. CC's inspector index continues to spread
`LinearMcpInspectors` into its own map.

The new shared module under `packages/builtin-tools/src/linear/` now only
covers the built-in LobeHub Linear skill path: `LinearIdentifier='linear'`
+ bare apiNames (`get_issue`, `save_issue`, …). The inspector component is
duplicated from CC on purpose — `builtin-tools` already depends on
`builtin-tool-claude-code`, so we can't import the other way without a
circular dep, and the user wants the CC code to stay put.

Drops the `LinearMcpInspectors` re-export and the CC-entry merge in
`inspectors.ts` that the previous commit had introduced.

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

* ♻️ refactor(linear): hoist shared LinearInspector + label utilities into shared-tool-ui

The Linear-branded inspector and its tool-name parsing helpers were
duplicated between `builtin-tool-claude-code/src/client/Inspector/LinearMcp`
(MCP-prefixed wire names) and `builtin-tools/src/linear/` (built-in skill
bare names). The dep graph (`builtin-tools` → `builtin-tool-claude-code` →
`shared-tool-ui`) means CC can't import from `builtin-tools`, so the
previous round kept two copies.

Moves the component + labels into `packages/shared-tool-ui/src/Inspector/
Linear/` — both CC and `builtin-tools` already depend on `shared-tool-ui`,
so they can each pull the same `LinearInspector` and register it under
whichever key shape their code path uses:

- CC's `LinearMcp.tsx` is now a 10-line wrapper that maps the shared
  inspector across every MCP-prefixed name.
- CC's `linearMcpLabels.ts` re-exports the parsing primitives + keeps the
  CC-only `formatLinearMcpShortLabel` (the prefix check stays here so the
  workflow-summary label only fires for MCP-prefixed wire names).
- `builtin-tools/src/linear/` drops its own Inspector / labels files; the
  index just registers the shared component under bare apiNames.

Exposes a labels-only subpath `@lobechat/shared-tool-ui/inspectors/
linear-labels` so the workflow-summary path can pull parsing helpers
without dragging the React inspector (and its `keyframes`-using style
modules) into `Group.test.tsx`'s mocked antd-style context.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:59:27 +08:00
Neko e50e6859e7 ️ perf(agent-signal,prompts): better prompts and explicit rules (#14882) 2026-05-17 17:58:06 +08:00
LobeHub Bot 70097ad315 🌐 chore: translate non-English comments to English in agent-tasks (#14880)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 17:06:56 +08:00
Arvin Xu 929d23a94e feat(cc): task tools + AskUserQuestion freeform note + waitingForHuman topic status (#14870)
*  feat(cc): support TaskCreate / TaskUpdate / TaskList tools (CC 2.1.143+)

Add adapter accumulator, inspectors and Todos panel for CC's imperative
task trio that replaces TodoWrite. TaskUpdate's status flip is surfaced
as a per-call chip ("Completed: Read hosts") and the Todos panel header
mirrors that label, with subject resolved from pluginState by CC-assigned
task id.

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

*  feat(cc): escape-toggle AskUserQuestion + waitingForHuman topic status

AskUserQuestion intervention — mode-exclusive escape hatch:
- Mirror `lobe-user-interaction`'s "Or type directly" toggle: form picks
  and the freeform reply are mutually exclusive, not stacked. Default
  view shows the multi-choice options; clicking "Or type directly"
  swaps the body to a single TextArea, and "Back to options" returns.
- Submit sends either per-question picks OR `{ __freeform__: <text> }`
  (never both). Bridge formatter (`AskUserMcpServer.formatAnswerForCC`)
  forwards the text verbatim to CC when `__freeform__` is the payload,
  bypassing the `User answers:\n- <q>: <a>` framing — keeps the model
  prompt clean when the user opts out of the structured form.
- Draft persistence resumes the user back into escape mode when
  `__freeform__` is non-empty; an empty draft starts in form mode.
  Timeout fallback respects escape mode: non-empty text submits as-is
  rather than being discarded for option-1-of-each defaults.
- Render swaps to a single "user reply" card with the typed text when
  `__freeform__` is present; otherwise renders the Q&A pairs as before.

Topic status `waitingForHuman`:
- Add new enum value to `ChatTopic` status — TS-only widening (the
  drizzle `text({enum})` is not a `pgEnum`, no migration needed) —
  wired through types + zod router schema.
- Sidebar topic row renders a warning-colored Hand icon when an
  intervention is pending so the waiting state reads from the topic list.
- `heterogeneousAgentExecutor` flips status to `waitingForHuman` when
  an AskUser intervention is raised and back to `running` once the
  bridge resolves; `conversationControl.submitHeteroIntervention` also
  flips back to `running` after the user submits / skips / cancels. The
  natural `runtime_end → writeTopicStatus('active')` takes over.

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

* 💄 style(explorer-tree): drop doubled outline on selected file rows

Add `--trees-selected-focused-border-color-override: transparent` to
both ExplorerTree consumers (working-sidebar Files + AgentDocuments).
`@pierre/trees` draws an outline via `::before` on focused+selected
rows that visually fights with the filled `--trees-selected-bg`
highlight — the existing `--trees-border-color-override: transparent`
only controls structural borders, not this focus outline. Keyboard
focus ring on unselected rows stays intact (a11y).

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:06:18 +08:00
Arvin Xu ad75e25443 ♻️ refactor(agent-settings): drop Meta/Documents tabs, restore inputTemplate (#14874)
* ♻️ refactor(agent-settings): drop Meta and Documents tabs

Remove the 助理信息 (Meta) and 文档 (Documents) tabs from the agent
profile/settings UI. Default chat-settings tab falls back to Opening for
non-inbox agents.

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

*  feat(agent-chat): restore inputTemplate field in Chat Preferences

Add back the User Input Preprocessing (inputTemplate) form field that was
removed in 2.0. The pipeline (InputTemplateProcessor, i18n, types) was kept
intact when the UI was dropped — only the form entry is added back.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:15:17 +08:00
YuTengjing 93492382ca 💄 style: shorten usage token details (#14849) 2026-05-16 23:21:54 +08:00
Arvin Xu 4ea80c2915 🐛 fix(gemini): sanitize enum/required from non-compliant types in tool schema (#14740)
* fix(gemini): strip enum from non-STRING types in tool schema

* fix(gemini): handle nullable types and definitions recursion in schema sanitizer

Addresses review feedback on #14740 for LOBE-8661:

1. Preserve nullable string enums (type: ['string', 'null'])
   - Replace strict type equality checks with isStringType/isObjectType
     helpers that handle both single-string and array types.
   - Apply to both sanitizeGeminiSchema and
     convertOpenAISchemaToGoogleSchema.

2. Recurse into definitions/$defs schema maps
   - When a tool schema stores non-compliant enum/required inside
     definitions/$defs and references it with $ref, the walker now
     visits these schema maps as well.

Test coverage: 6 new cases for nullable type preservation and
definitions/$defs recursion.

* 🐛 fix(test): wrap sanitizeGeminiSchema inputs in valid JSON Schema

The 3 cases were passing bare property maps directly to the sanitizer,
which only recurses through `properties`/`items`/combinators/`$defs` —
so the inner `enum`/`required` were never visited and assertions failed.

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

---------

Co-authored-by: Arvin Xu <arvinxx@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:55:02 +08:00
YuTengjing f94f941fe8 💄 style(home): polish brief recommendations layout (#14871) 2026-05-16 20:20:32 +08:00
Arvin Xu fbc42b725e feat(hetero-agent): support Monitor-style signal callbacks in AssistantGroup (#14859)
*  feat(hetero-agent): emit externalSignal on Monitor-callback steps + reader-side SignalCallbacksNode

LOBE-8998 Phase 1 — data-layer work. Adapter detects repeated tool_results
on the same tool_use.id (Monitor stdout pushes etc.) and tags the next
stream_start(newStep) with an externalSignal peer field. Executor stamps
metadata.signal on the new assistant message. conversation-flow
MessageCollector / ContextTreeBuilder collect signal-tagged toolless
assistants into a SignalCallbacksNode appended inside AssistantGroup
children. UI rendering deferred to a follow-up commit.

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

* 🐛 fix(hetero-agent): keep parentId chain alive across toolless middle steps

LOBE-8993: when a CC step produced only text (e.g. Monitor stdout drove
Claude to reply without invoking a tool), the next step's parentId fell
back to the previous assistant. MessageCollector only walks the
assistant → tool → assistant zigzag, so each Monitor stdout line split
into its own bubble.

Carry the most recent tool result_msg_id across step boundaries via a
`lastToolMsgIdEver` tracker so toolless middle steps still chain back to
the originating tool result.

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

*  feat(chat-ui): render SignalCallbacks block inside AssistantGroup for Monitor-style callbacks

Adds the UI layer of LOBE-8998. FlatListBuilder snapshots signal-callback
groups onto the virtual AssistantGroup message via UISignalCallbacksBlock
(new typed field on UIChatMessage) and marks each callback message
processed so it does NOT render as a separate top-level bubble.
AssistantGroup reads the field and renders a collapsible
<SignalCallbacks> component under the main Group content, one block per
source tool.

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

* 🐛 fix(hetero-agent): detect Monitor callbacks via system task lifecycle instead of repeat tool_result

The previous detection model (count repeat tool_result per tool_use.id) was
based on a wrong assumption — Monitor's stdout pushes are NOT delivered as
additional tool_result events for the same tool_use.id. Verified against a
real `claude -p` trace: Monitor emits ONE tool_result (the initial "Monitor
started" ack), then each subsequent stdout line triggers a `system init` +
new `message_start` cycle within the same CLI process. The actual lifecycle
signal is `system task_started` (long-running tool registers) followed by
`system task_notification` (terminal).

New detection: a `message_start` that opens a new turn WITHOUT a preceding
`user` event, while at least one task is active, is a signal callback.
`task_started` records `{task_id → tool_use_id}`; `task_notification` drops it.
Verified against the recorded CC trace: 5/5 reactive turns get tagged with
correct sequence and source tool, the natural confirmation turn and the
post-task summary turn are correctly excluded.

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

*  feat(hetero-agent): keep CC post-task summary in same group + dedicated Monitor inspector (LOBE-8998)

The post-task summary turn (fired after `system task_notification` ends
a long-running tool) was spawning its own AssistantGroup because the
collector only followed the first non-signal toolless sibling under a
tool_result — it never saw the summary that came after the
SignalCallbacks. Adapter now stamps `signal.type = 'task-completion'`
on the summary turn so the collector keeps it inside the same group,
rendered AFTER the SignalCallbacks accordion (initial reply → callbacks
→ summary, in creation order).

Also adds a dedicated `MonitorInspector` (lucide `Monitor` icon, chip
shows description / command, trailing timeout label) so the Monitor
tool call line stops falling back to the generic `claude-code > Monitor`
display, and tightens the Flexbox spacing around SignalCallbacks +
taskCompletions inside the AssistantGroup so the three sections read
as one connected reply rather than disconnected blocks.

Adapter: arm `pendingTaskCompletion` on `task_notification` (last-task-
wins), consume it on the next natural `message_start`, clear on `result`
so it never leaks across LLM runs.

Tests: adapter (74) + executor (56) + conversation-flow (126) all green.
Verified end-to-end in Electron with a 5-tick Monitor run — single
AssistantGroup with the natural narrative inside.

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

* 🐛 fix(conversation-flow): skip signal callbacks when locating the group tail

`findLastNodeInAssistantGroup` blindly took `toolNode.children[0]` when
walking past a tool, so for the common `[signal callback, next tool-using
assistant]` order the tail landed on the callback (a leaf) and
`findNextAfterTools` returned null — truncating the AssistantGroup and
omitting follow-up messages after the real last assistant. Mirror the
signal-skip already used in `collectAssistantGroupMessages` (LOBE-8998).

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:40:57 +08:00
Arvin Xu f94e4f46a4 🐛 fix(task-schedule): enforce maxExecutions cap and block sub-10min heartbeat (#14865)
* 🐛 fix(task-schedule): enforce maxExecutions cap and block sub-10min heartbeat

The "运行次数限制" input on a scheduled task was accepted by the UI and
persisted to `tasks.config.schedule.maxExecutions`, but no execution path
ever read it — scheduleDispatch/scheduleTick/runTask had no counter and
no cap check, so a "stop after N runs" schedule would loop forever.

Separately, the server-side `heartbeatInterval` zod schema was `min(0)`,
and the `setTaskSchedule` tool manifest only said "recommend ≥600s". An
LLM could pass any positive number and trigger sub-minute heartbeats.

Enforcement (no schema migration):

- `TaskService.updateStatus` stamps `context.scheduler.scheduleStartedAt`
  (ISO) when a task transitions into `scheduled` from a non-`running`
  status. The cron loop's natural `running → scheduled` flips happen via
  `taskModel.updateStatus` (taskLifecycle), bypassing the service layer,
  so they don't reset the counter. User-initiated (re)starts do.
- `TaskTopicModel.countByTaskSince(taskId, since)` counts task_topics
  rows created since a timestamp.
- `runScheduleTick` reads `config.schedule.maxExecutions`; if the count
  since `scheduleStartedAt` has reached the cap, it marks the task
  `completed` (so the next dispatch sweep filters it out) and returns a
  new `max-executions-reached` skip reason.

Heartbeat lower bound:

- `updateSchema.heartbeatInterval` on the lambda router now refines to
  `v === 0 || v >= 600`, matching `MIN_MINUTES = 10` in the UI.
- `setTaskSchedule` tool manifest description updated to "Minimum 600s
  … the server rejects positive values below 600" so the LLM sees the
  hard limit before the zod refine bounces the call.

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

* ♻️ refactor(task-topic-model): rename countByTaskSince → countByTask, use drizzle count()

- Make `since` an optional `options` argument so the helper covers total
  counts too, not only the since-window the scheduler needed.
- Swap `sql<number>\`count(*)::int\`` for drizzle's native `count()`
  aggregator.

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

*  test(task-schedule): cover countByTask, scheduleStartedAt stamping, and tick max-exec

- `TaskTopicModel.countByTask`: total-mode, since-window mode, task scope,
  user scope (real DB).
- `TaskService.updateStatus`: stamps `context.scheduler.scheduleStartedAt`
  on user-initiated starts/restarts of a schedule task; does NOT stamp on
  the cron loop's natural `running → scheduled` cycle, on heartbeat-mode
  tasks, or when the new status isn't `scheduled`.
- `runScheduleTick`: cap not configured / under cap → runs; cap reached
  → marks `completed` and skips with `max-executions-reached`; missing
  `scheduleStartedAt` → falls through (backwards-compat for tasks created
  before this PR).

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

* 🐛 fix(task-schedule): complete capped schedules at the final allowed run

The pre-tick cap check in `runScheduleTick` only sees `runCount` *before*
starting the next tick. For low-frequency schedules (e.g. daily,
`maxExecutions=1`), this meant the task would consume its final allowed
run, get parked back at `scheduled` by `TaskLifecycleService.onTopicComplete`,
and then sit in `scheduled` for a full cron period before the next pre-tick
check noticed the cap was already consumed — contradicting the "stop after
N runs" promise.

Move the canonical stop to post-completion:

- New `TaskLifecycleService.scheduleCapReached(task)` helper counts
  `task_topics` rows since `context.scheduler.scheduleStartedAt` and
  compares against `config.schedule.maxExecutions`. Short-circuits when
  the task isn't in schedule mode, no cap is configured, or no
  `scheduleStartedAt` is stamped (pre-PR tasks).
- The default post-tick transition in `onTopicComplete` now routes a
  cap-reached schedule task to `completed` instead of `scheduled`, so
  the UI/API reflect the cap immediately.

The pre-tick check in `runScheduleTick` is kept as defense-in-depth:
covers crashed ticks that never reached `onTopicComplete`, users
editing `maxExecutions` downward past current count, and stale
`scheduled` rows from older code paths. Comment updated to reflect that.

Tests:
- `onTopicComplete`: schedule task under cap → still `scheduled`; at
  cap → `completed`; with no `scheduleStartedAt` (pre-PR) → still
  `scheduled` (helper short-circuits before querying).

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:14:29 +08:00
Arvin Xu 6478c6012f feat(cc): render Linear MCP tool calls with branded inspector (#14864)
*  feat(cc): render Linear MCP tool calls with branded inspector

CC emits Linear MCP tools as `mcp__claude_ai_Linear__<verb>_<noun>` —
the default inspector and the collapsed summary surface those raw names,
which read as `Mcp__claude_ai_ Linear__get_issue` after title-casing.

Adds a generic Linear MCP inspector that:
- Shows the monochrome Linear logomark + "Linear" product prefix
- Renders the action as a single pill split into action / value halves
  (e.g. `Get issue | id: LOBE-8743`)
- Detects `parentId` and surfaces it with a CornerLeftUp icon, either in
  the chip's value half (when parent is the primary arg) or as a secondary
  badge after the chip (mirrors the parent visual used by AgentTask UI)
- Hard-caps chip text at 60 chars so long comment bodies / search queries
  don't push the row off-screen

Also humanizes the collapsed-workflow summary via a `formatLinearMcpShortLabel`
helper exported from `@lobechat/builtin-tool-claude-code/client`, so the
bundle row reads "Linear · Get issue" instead of the raw tool name.

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

*  feat(cc): render WebSearch and WebFetch tool calls with custom inspector

CC's web tools were falling through to the generic tool UI because
`ClaudeCodeApiName` and the render/inspector registries hadn't been
extended. Adds dedicated inspector (query/url chip) and result card
(text for search, markdown for fetched pages) for both.

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

* 🐛 fix(cc): isolate Linear MCP label helper to avoid antd-style mock break

`Group.test.tsx` mocks `antd-style` with only `createStaticStyles`. The
previous wiring imported `formatLinearMcpShortLabel` through the
`@lobechat/builtin-tool-claude-code/client` barrel, which transitively
loads `LinearMcp.tsx` → `@lobechat/shared-tool-ui/styles` → `keyframes`,
crashing the mock.

Splits the pure label utilities (LINEAR_MCP_PREFIX, parseToolName,
staticLabelFor, formatLinearMcpShortLabel, LINEAR_MCP_TOOL_NAMES) into
`linearMcpLabels.ts` with no React/antd-style imports, exposes it as
`@lobechat/builtin-tool-claude-code/client/labels`, and switches the
consumer in `toolDisplayNames.ts` to that subpath. The inspector
component keeps importing the same helpers locally.

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

* 💄 ui(hetero): land manual workflow expand at full level

Heterogeneous agent workflows often run 40+ tool calls. When the user
collapsed the workflow and clicked the header to re-expand, it landed
at the height-capped `semi` state and hid most of the chain. Now we
infer a "fully expanded experience" from `defaultWorkflowExpandLevel`
— any phase opting into `full` routes the manual expand straight to
`full` instead of the legacy `semi` cap.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:41:22 +08:00
Arvin Xu ff259bdc51 🐛 fix(agent-tracing): align DB trace_s3_key with .json.zst suffix (#14860)
🐛 fix(agent-tracing): align DB trace_s3_key with `.json.zst` suffix

PR #14807 switched the S3 object key written by `S3SnapshotStore.save()`
to `.json.zst` but the DB-persistence path in `CompletionLifecycle.ts`
still hardcoded `.json`. Result: every row inserted into
`agent_operations.trace_s3_key` points at a key that does not exist —
the actual object is the `.json.zst` sibling. Any consumer that GETs by
the DB-recorded key (dc tracing UI, agent-tracing inspect via record
lookup) hits 404.

Verified in prod: 87012/87159 populated rows still end in `.json`, 0
end in `.json.zst`, including rows inserted hours after the PR #14807
deploy.

Fix factors out a single `buildFinalSnapshotKey(agentId, topicId, opId)`
helper exported from `@/server/modules/AgentTracing` so both the S3
writer and the DB writer construct the key from the same source, making
this class of drift impossible going forward.

Existing rows need a one-off backfill (run from dc):
  UPDATE agent_operations SET trace_s3_key = trace_s3_key || '.zst'
  WHERE trace_s3_key LIKE '%.json';

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:56:58 +08:00
AmAzing- 7b61b9526f feat: align self-iteration builtin tool with shared runtime and inspector patterns (#14827) 2026-05-16 13:52:08 +08:00
Arvin Xu 8c4fbf4a81 🐛 fix(home): fetch agent config so knowledge toggles reflect in UI (#14834)
* 🐛 fix(home): fetch agent config so knowledge toggles reflect in UI

Home layout didn't subscribe to the agent config SWR key, so
`toggleFile` / `toggleKnowledgeBase` succeeded server-side but the
follow-up `mutate([FETCH_AGENT_CONFIG_KEY, agentId])` had no listener
and `agentMap` was never refreshed — leaving the Library submenu
checkboxes visually frozen on the home page.

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

* ♻️ refactor(home): move agent config fetch into InputArea with loading state

Move `useInitAgentConfig(agentId)` from the home layout into InputArea
so it tracks the resolved home agent id (inbox or AgentSelect override)
and refetches when the selection changes. Disable the send button while
the agent config isn't yet in `agentMap`, matching the loading shape of
the Memory/Search/History actions.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 10:58:03 +08:00
Arvin Xu d91132c155 💄 style(thread): indent subagent rows and drop SUBAGENT badge (#14845)
Restyle subagent thread items in the Topic sidebar:
- Replace `└` TreeDownRightIcon with `↳` CornerDownRight from lucide-react
- Remove right-aligned SUBAGENT Tag badge; the indent + arrow now carry the
  nesting affordance on their own
- Apply `paddingInlineStart: 32` on the NavItem's inner Block so subagent
  rows shift right by ~one icon slot while the row background/highlight
  stays full-width
- Sync agent and group sidebar copies; drop the now-unused
  `chat:thread.subagentBadge` i18n key

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 10:55:45 +08:00
Tsuki b8a03bdc08 🐛 fix(task-schedule): stop SchedulerForm race + drop stale-refresh CLS (#14853)
* 🐛 fix(task-schedule): stop SchedulerForm race + drop stale-refresh CLS

Rapid edits in the schedule form (weekday toggles, frequency/time picks,
timezone changes) fired concurrent PUTs through `updateSchedule` and then
a SWR mutate refresh. The refresh was async and could land after the
user's next click, overwriting their latest input with whatever the
server happened to hold — the same race as setAutomationMode in LOBE-8893.

- Migrate `updateSchedule` to the shared `OptimisticEngine` introduced by
  LOBE-8893. Same `taskDetailMap.<id>` path, so schedule edits serialize
  against each other AND against mode toggles.
- Mirror every server-bound field (config.schedule.maxExecutions JSONB +
  flat schedulePattern/scheduleTimezone columns) into the optimistic
  patch and drop the post-PUT refresh.
- PUT failure now rolls back via inverse patches.
- Remove `#withCoalescedRefresh` + `#pendingWrites` — both unused after
  setAutomationMode and updateSchedule moved to the engine.

Fixes LOBE-8901

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

* 💄 style(task-trigger-tag): ellipsis the inline primary so long patterns don't wrap to two lines

A weekly schedule with many selected days (e.g. "每周 日/四/六 09:00 运行")
overflowed the 200px properties widget width and wrapped to two lines, so
adding/removing weekdays shifted the rows above and below. Truncate with
ellipsis instead — the full text + timezone is still visible on hover via
the existing tooltip.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 02:07:26 +08:00
Tsuki 8385a7c447 🐛 fix(editor): stop showing per-line placeholder once the editor has content (#14852)
LOBE-8924: TaskInstruction (and every other EditorCanvas consumer that doesn't
pass `lineEmptyPlaceholder` itself) was forwarding the same string into both
`placeholder` and `lineEmptyPlaceholder`. The latter renders the hint on every
empty block, so as soon as the user typed something and moved to a new line,
"Add task instruction…" reappeared inline next to the cursor. Drop the
`lineEmptyPlaceholder` pass-through so the hint only shows when the whole
editor is empty; callers that genuinely want per-line hints
(`SkillEditForm`, `agent/profile/EditorCanvas`, `CreatePlan`) already pass it
directly to `<Editor>`.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 02:07:12 +08:00
Tsuki c814c566d4 🐛 fix(chat): respect useCmdEnterToSend preference in thread & task inputs (#14850)
Thread feedback and task comment inputs hardcoded Cmd/Ctrl+Enter to send,
ignoring the user's "Use Cmd+Enter to send" preference and diverging from
the main chat input. Extract a shared useEnterToSend hook and apply it to
all chat-like inputs so behavior stays consistent.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 02:06:57 +08:00
Tsuki 5e03311d21 💄 style(agent-tasks): align Add Subtask button & card baseline (#14848)
💄 style(agent-tasks): align Add Subtask button with card content

Fixes LOBE-8904

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 02:06:38 +08:00
Tsuki 03f99bfeeb 💄 style(chat-input): equalize action bar padding around send button (#14846)
* 💄 style(chat-input): equalize action bar padding around send button

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

* 💄 style(task-feedback): equalize commentInputCard padding around send button

The asymmetry the issue called out lives on the TopicChatDrawer
FeedbackInput card, not the main DesktopChatInput action bar. Revert
the earlier DesktopChatInput tweak and align top/bottom/right padding
on commentInputCard instead.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 01:27:40 +08:00
Tsuki 224079b420 🐛 fix(agent-tasks): enable send button after pasting into thread/comment input (#14816)
The Editor's `onTextChange` ignores the first content-change event after listener
registration (uses a `previousContent` baseline). Because the parent re-creates
the callback ref on every render, the listener re-registers and that gate fires
on every paste — leaving `hasContent` false and the send button disabled until
the user types something.

Switch to `onChange` (which fires unconditionally), and use `editor.isEmpty` so
each fire stays O(1) despite the higher invocation rate.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 01:27:06 +08:00
Tsuki 081a0886aa 🐛 fix: preserve TopicChatDrawer state during close animation (#14803)
Wrap title, extra and body of TopicChatDrawer in `Freeze` so the drawer
keeps its last rendered content while it animates closed, instead of
flashing to the empty/"untitled" view as `topicId` and `agentId` clear.

Fixes LOBE-8900

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 01:26:47 +08:00
Tsuki d9eba30519 🐛 fix(task-schedule): stop UI flip-flop on rapid automation-mode toggles (#14801)
Rapid Segmented clicks (schedule ↔ heartbeat) used to leave the popover trigger
row flickering and the task properties widget vertically shifting.

- TaskTriggerTag inline mode now always renders a single row; timezone moves
  to the hover tooltip so the row height is stable regardless of mode.
- setAutomationMode goes through OptimisticEngine: per-task path conflicts
  serialize concurrent toggles so PUTs land in click order, and a failure
  triggers an inverse-patch rollback instead of a manual save/restore.
- Mirror every server-bound field into the optimistic patch and drop the
  post-PUT SWR refresh — the async refresh could land after the user's next
  click and overwrite their latest state.

Fixes LOBE-8893

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 01:26:28 +08:00
Rdmclin2 a47d29b0bb 🐛 fix: bot channels (#14847)
* feat: support app home welcome messger

* feat: support welcome message in bot channels

* fix: /start commands ephemeral

* chore: fix User Block trigger style

* chore: add bot channel docs

* feat: support thread participants count

* feat: bot channel support participants count
2026-05-15 22:32:40 +07:00
Innei 3864a1eaab 🐛 fix(onboarding): gate discovery progress by phase (#14842) 2026-05-15 22:23:21 +08:00
Arvin Xu 8ca3f9a372 🐛 fix(agent-runtime): forward tools into compression budget on call_llm (#14837)
* 🐛 fix(agent-runtime): forward tools into compression budget on call_llm

Tool definition tokens were already counted by `countContextTokens`, but
`GeneralChatAgent` never passed `tools` into `compressionOptions`, so a
large tool manifest (16-22K tokens observed on openrouter `:free`
variants) could push the request past the model's context window
without ever tripping the compression threshold.

Forward `state.tools` (init/user_input) and `payload.tools` (toLLMCall)
into `shouldCompress`. Fixes LOBE-8973 Bug B.

* 🐛 fix(agent-runtime): skip tool budget on force-finish continuations

When state.forceFinish is set, RuntimeExecutors.callLlm strips every tool
via buildStepToolDelta (deactivatedToolIds: ['*']) before the model call.
The compression check must mirror that stripping — otherwise the operation's
tool schemas push the budget over threshold and the runner returns
compress_context, spending an extra summarization pass on tokens that won't
be sent.

Threads state.forceFinish through the compression budget at both the
init/user_input and the toLLMCall paths.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 20:50:58 +08:00
LiJian a2d91b205e feat(cc): show cloud credentials alert and disable input when not configured (#14822)
When a heterogeneous agent (Claude Code) is opened in the browser (cloud/web
mode) and the CLAUDE_CODE_CRED_KEY env is not yet configured, the chat input
is now disabled and a warning banner is shown with a direct link to the agent
profile page so the user can set up their token.

- Add useHeteroAgentCloudConfig hook (business slot) that checks isDesktop,
  heterogeneousProvider, and env.CLAUDE_CODE_CRED_KEY
- Guard handleSendButton in ChatInput store to respect sendButtonProps.disabled
  (blocks Enter-key send when button is externally disabled)
- Render Alert banner + pass disabled:true to sendButtonProps in
  HeterogeneousChatInput when credentials are missing
- Add i18n keys: heteroAgent.cloudNotConfigured.{title,desc,action}

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 20:45:10 +08:00
Innei a35c55c57b 🐛 fix(onboarding): remind discovery turn progress (#14833) 2026-05-15 20:28:33 +08:00
Arvin Xu 625cf80b84 🐛 fix(model-runtime): fail-fast pre-flight context check for OpenAI-compatible providers (#14824)
* 🐛 fix(model-runtime): fail-fast pre-flight context check for OpenAI-compatible providers

LOBE-8291 added `resolveSafeMaxTokens` + `MaxTokensExceededError` but only
wired them into MiniMax. NVIDIA and DeepSeek hosts continued to round-trip
doomed requests to upstream just to get a 400 back ("requested 0 output
tokens and your prompt contains at least N+1 input tokens"). LOBE-8974
captures the variants still hitting users — including 5 consecutive
failures from a single user retrying across deepseek-v4-{flash,pro}.

This change:

- Promotes the pre-flight check to `openaiCompatibleFactory` via a new
  `chatCompletion.contextPreFlight` option. When set, the factory runs
  `assertContextWithinWindow` against the provider's model list before
  invoking `handlePayload`, and surfaces a structured
  `ExceededContextWindow` error so the UI can offer fork / switch-model
  affordances instead of a raw provider 400.
- Renames `MaxTokensExceededError` to `ContextExceededPreFlightError` and
  reshapes its payload to match the LOBE-8974 spec: `{ type, promptTokens,
  ctx, model, shortBy, suggestions }`. The factory intercepts the error
  centrally so providers no longer need their own `handleError` for this.
- Wires NVIDIA and DeepSeek (OpenAI path) to opt in. MiniMax keeps using
  `resolveSafeMaxTokens` for `max_tokens` capping; its bespoke
  `handleError` is removed since the factory handles it now.

Out of scope (tracked in LOBE-8974): compression-failure metrics for the
4b "input genuinely overflows 1M" cases, repeated-ECW UX guidance to fork
the topic, and DeepSeek's Anthropic-compatible path (which lives behind a
separate factory).

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

* 🐛 fix(model-runtime): pre-flight should reject only on real context overflow

The previous `assertContextWithinWindow` reused `resolveSafeMaxTokens`'s
strict thresholds — subtracting a 1024-token buffer and then requiring
another 1024 tokens of completion headroom. That made sense for MiniMax
(which caps `max_tokens` itself and needs room left for output) but
wrong for NVIDIA / DeepSeek where the harness does not pick `max_tokens`
and the upstream chooses its own default. A 198.5k-token prompt against
a 200k-token window would be rejected pre-flight with a negative
`shortBy` even though the upstream would happily serve it.

Pre-flight-only providers now reject only when the estimated prompt
strictly exceeds the model context window. `AssertContextWithinWindowOptions`
exposes a `safetyMarginTokens` knob for callers that want to absorb
estimator drift, defaulting to 0. The error class makes `minOutputTokens`
optional and only includes it in the structured payload when the
max_tokens-capping path populated it.

Adds regression tests for the near-limit case at both the helper level
and through the factory wiring.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:54:27 +08:00
Arvin Xu d02df7b897 🐛 fix(hetero-agent): drop ALL subagent-tagged events from main gateway handler (#14838)
The forwarding guard only filtered `stream_chunk` events. `tool_start` and
`tool_end` for subagent inner tools still reached the main handler, where
`tool_end` fired a `fetchAndReplaceMessages(main)` on every subagent inner
tool result — wasted work AND a state-drift window that surfaced as the
"orphan tool call" banner on the spawn's bubble even after DB had settled.

`tool_start(subagent)` was also leaking `dispatchOnBeforeCall` invocations
against the main context for what is actually a subagent inner tool, firing
renderer onBeforeCall hooks in the wrong scope.

Broadens the guard to drop ALL events with `event.data.subagent`. Safe
because:
- `tool_result(subagent)` is already handled inline at executor:1407 with
  an early `return`.
- `stream_chunk(subagent)` is routed through `persistSubagent*Chunk` into
  the per-spawn thread scope; the subagent's own in-thread renderer state
  is streamed via the thread-scoped dispatcher introduced in #14024.
- `tool_start` / `tool_end` are pure renderer-notification hooks; the
  subagent has no business firing them on the main bucket.

Regression test asserts:
- No forwarded event with `event.data.subagent` reaches the handler.
- Main's own `tool_start` / `tool_end` (no subagent flag) still reach
  the handler so the main bubble's animation + onAfterCall hooks fire.

Closes LOBE-8991.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:47:59 +08:00
Arvin Xu 19b11f05be 💄 i18n(chat): rename Agent mode label in zh-CN (#14835)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:48:36 +08:00
YuTengjing 59d2915bf9 🐛 fix: serialize file storage upload checks (#14829) 2026-05-15 17:28:56 +08:00
YuSaZh 17506e30ee 🐛 fix(desktop): resolve Windows npm CLI shims before spawning agents (#14772)
* 🐛 fix(desktop): resolve Windows CLI shims before spawning agents

* 🐛 fix(desktop): support Windows node-backed CLI shims

* 🐛 fix(desktop): resolve npm cmd node shims on Windows

* 🐛 fix(desktop): avoid async spawn wrapper for CLI agents
2026-05-15 17:24:43 +08:00
LiJian 1a48642a2d 🐛 fix(agent-profile): include hidden builtin tools in system prompt @-mention list (#14823)
* 🐛 fix(agent-profile): include hidden builtin tools in system prompt @-mention list

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

* 🐛 fix(agent-profile): use discoverableMetaList for system prompt @-mention

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 17:05:43 +08:00
Arvin Xu 205b9de5c6 🐛 fix(agent-tracing): restore legacy .json fallback when fetching remote snapshots (#14826)
🐛 fix(agent-tracing): restore legacy .json fallback in RemoteSnapshotStore.fetch

After #14807, `buildRemoteUrl` always targets `.json.zst` and
`RemoteSnapshotStore.fetch` throws on any non-OK response. Because the
S3 rollout only compresses new uploads — pre-rollout final snapshots
remain at the legacy `.json` key — every pre-rollout operation ID would
404 through the CLI/viewer.

Mirror the fallback that `S3SnapshotStore.loadPartial` already uses:
try `.json.zst` first, fall back to the sibling `.json` on non-OK, and
sniff the zstd frame magic (0x28b52ffd) on the body so decoding is
content-driven rather than suffix-driven.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:51:41 +08:00
YuTengjing 20a631a637 💄 style(subscription): update credit top-up copy (#14821) 2026-05-15 16:34:47 +08:00
Arvin Xu ba6980ffe9 🐛 fix(minimax): derive max_tokens from context window to avoid ExceededContextWindow (#14814)
* 🐛 fix(minimax): derive max_tokens from context window to avoid ExceededContextWindow

MiniMax API enforces `input_tokens + max_tokens <= context_window`. The
provider was passing the model's full `maxOutput` as `max_tokens`, which
overflowed the context window as soon as a few large tool definitions or
system prompts were attached and made the very first user message fail
with "context window exceeds limit".

Add `resolveSafeMaxTokens` utility that estimates input tokens from the
payload (messages + tools), caps `max_tokens` at
`min(maxOutput, contextWindow - estimatedInput - buffer)`, and throws a
typed `MaxTokensExceededError` when no headroom remains. The MiniMax
provider now wires this into `handlePayload` and surfaces the error as
`ExceededContextWindow` via a `handleError` callback so it short-circuits
before the doomed upstream call.

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

* 🐛 fix(minimax): estimate max_tokens against sanitized messages

handlePayload strips signed reasoning (and reasoning-without-content)
from assistant messages before sending to MiniMax, but the previous
resolveSafeMaxTokens call was still measuring the original payload.
For chats with long historical reasoning traces this overcounted the
input — capping max_tokens unnecessarily, or even raising
MaxTokensExceededError when the request would actually fit.

Pass the same processedMessages we send so the estimate matches the
wire payload.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:47:30 +08:00
Innei 55b4842f00 🐛 fix(chat-input): allow submenu to close on sibling-open and focus-out in ActionDropdown (#14802) 2026-05-15 13:47:26 +08:00
Arvin Xu 6e6970f1b2 🐛 fix(context-engine): account for tool_calls + reasoning + tool defs in compression budget (#14813)
🐛 fix(context-engine): account for tool_calls + reasoning + tool defs in compression budget

The pre-compression token check (`shouldCompress`) only counted `msg.content`,
which under-counted typical agent conversations by ~58% — tool_calls (~33%
of payload), reasoning traces (~17%), and top-level tool definitions (~2%)
were all silently ignored. As a result, conversations that the provider
tokenizer measured at ~656K passed the harness's 524K threshold without
firing compression, and were rejected upstream as ExceededContextWindow.

Verified empirically against 2 op snapshots in the same topic that hit
the failure mode (LOBE-8964): harness counted 267K, deepseek measured
649K — a 380K (58.8%) gap. ~92% of that gap is fixable by accounting
for the missing fields; the remaining ~8% is `tokenx` vs provider
tokenizer drift, compensated by a 1.25× multiplier on the trigger path.

Changes:

- New `@lobechat/context-engine/tokenAccounting` module exporting
  `countContextTokens({messages, tools, options})`. Returns structured
  per-source + per-message + per-tool breakdown — usable both by the
  compression trigger and by UI panels showing "context by type".
- `shouldCompress` in agent-runtime delegates to `countContextTokens`,
  applies the 1.25× drift multiplier on `adjustedTotal` for the trigger
  decision, exposes raw count via `currentTokenCount`. Signature now
  takes `UIChatMessage[]` directly.
- Removed deprecated `calculateMessageTokens` / `estimateTokens` /
  `TokenCountMessage` from agent-runtime — the new module supersedes
  them. `createAgentExecutors.ts` updated to call `countContextTokens`
  directly for post-compression telemetry.
- Added `raw-md` plugin to agent-runtime vitest config (needed once
  context-engine is imported transitively, since the import graph pulls
  in `@lobechat/agent-templates` `.md` files).

What's intentionally NOT counted (DB-only fields not sent to provider):
`plugin`, `pluginState`, `chunksList`, `extra`, `fileList`, etc.
Counting these would over-estimate and trigger compression too early.

Tests:

- 19 new unit tests for `countContextTokens` covering content / tool_calls
  / reasoning / tool_call_id / tool definitions / fast-path / aggregation
  / DB-only field exclusion.
- `tokenCounter.test.ts` updated for new drift semantics + UIChatMessage
  signature; one boundary case now triggers compression (intentional —
  the drift multiplier kicks in at the threshold).

Refs: LOBE-8964 (ECW edge boundary), LOBE-8972 (ECW umbrella),
LOBE-8973 (openrouter `:free` ctx), LOBE-8976 (compression diagnostics).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:22:19 +08:00
Arvin Xu da7e18281d feat(builtin-tool): add onBeforeCall / onAfterCall lifecycle hooks (#14719)
*  feat(builtin-tool): add onBeforeCall / onAfterCall lifecycle hooks

Tools that mutate state surfaced in the renderer (e.g. lobe-task) need a
way to invalidate UI caches after their own writes — but when the tool
runs server-side via a registered server runtime, the renderer never sees
the mutation and SWR caches go stale (e.g. delete-all-tasks succeeds on
the server but the kanban keeps showing the deleted rows).

Adds optional `onBeforeCall` / `onAfterCall` to `IBuiltinToolExecutor`,
both taking a single `ToolHookContext` object so the surface stays
non-breaking as we add fields. The gateway event handler dispatches them
on `tool_start` / `tool_end` regardless of whether the tool actually ran
client- or server-side.

`TaskExecutor` implements `onAfterCall` to refresh the task list / detail
SWR caches for write APIs. Also fills the missing `setTaskSchedule`
implementation in the server runtime so cloud-mode users can actually
configure schedules through the agent.

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

* 💄 style(tasks): widen empty-tasks hero to 960px

Aligns with the default `CONVERSATION_MIN_WIDTH` used elsewhere; the
720px cap was leaving the recommended-template grid feeling cramped on
wider monitors.

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

* 🐛 fix(builtin-tool-task): refresh parent task detail after subtask mutation

Deleting a subtask through the agent left the parent's detail view
showing the stale child until a manual page reload — `onAfterCall` was
only invalidating the mutated task's own detail key, never the parent
whose `subtasks[]` array embeds it.

Adopt the same multi-target pattern that `updateTask` already uses in
the detail slice: walk `taskDetailMap` via `findSubtaskParentId` to
locate the embedding parent, and also refresh `activeTaskId`
defensively (covers e.g. `createTask` whose new identifier isn't yet in
the local map but whose parent the user is viewing).

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

* 🐛 fix(builtin-tool): unwrap nested tool_end payload before dispatching hook

Real gateway `tool_end` events ship `data.payload` as the
`{ parentMessageId, toolCalling }` wrapper (see both publish sites in
`src/server/modules/AgentRuntime/RuntimeExecutors.ts`), but
`dispatchOnAfterCall` was passing that wrapper straight into
`readToolPayload`, which expects `identifier` / `apiName` at the top
level. Result: identity always undefined for server-runtime tool
completions, `onAfterCall` never fires, and the task cache invalidation
from the previous commit was effectively dead code.

Add `unwrapToolPayload` that prefers `payload.toolCalling` when present
and falls back to the flat shape, plus three regression tests covering
the wrapper, flat, and malformed cases.

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

* ♻️ refactor(builtin-tool-task): colocate executor under client subpath

Aligns with the knowledge-base / lobe-agent precedent: drop the standalone
`./executor` subpath and re-export `taskExecutor` from `./client`.

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

* 🐛 fix(builtin-tool): lazy-load executor registry to break import cycle

`gatewayEventHandler.ts` statically imported `getExecutor`, which transitively
pulled in tool client barrels (e.g. `@lobechat/builtin-tool-lobe-agent/client`
→ `PlanCard.tsx` → `@/store/chat`). Loading `gateway.ts` in isolation (as
the gateway.test.ts suite does) thus reached the chat-store module while
`gateway.ts` was still mid-evaluation, and the eager `useChatStore()` call
hit `new GatewayActionImpl(...)` before the class binding was initialized.

Dynamic-importing `getExecutor` inside the two async dispatch functions
breaks the cycle at module load; runtime behavior is unchanged.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 12:50:00 +08:00
Arvin Xu 7083ab4ef5 🐛 fix(conversation): restore HTML preview for AssistantGroup messages (#14811)
PR #14703 wired @lobehub/ui's `enableHtmlPreview` into the Assistant
useMarkdown but missed the AssistantGroup path, so any full HTML
document the LLM emits in a grouped step rendered as a plain code
block instead of an iframe preview.

Extract the shared markdown wiring (components, plugins, animated,
HtmlPreviewDrawer) into useChatMarkdown so both paths use the same
configuration and the next markdown feature won't drift between them.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 12:29:21 +08:00
Arvin Xu 3dae46911b ️ perf(agent-tracing): zstd-compress S3 snapshots (#14807)
* ️ perf(agent-tracing): zstd-compress S3 snapshots

Compress operation snapshots with zstd (level 3) before uploading to S3
and write them under a `.json.zst` key. Measured on 76839 production
snapshots: 217 GB → 25.8 GB (8.4× average ratio, p99 47×). New uploads
only; old `.json` objects are left as-is.

The `.zst` suffix is the format indicator; Content-Encoding is
intentionally omitted so the object is served as opaque bytes and
readers decompress explicitly (avoids surprise behavior from HTTP
clients that negotiate zstd).

Uses Node's built-in zstd (node:zlib, available since Node 22.15) so
no new runtime dependency is added.

Reader updates:
- RemoteSnapshotStore.fetch decompresses the downloaded payload;
  local cache stays as plain `.json` for easy inspection.
- buildRemoteUrl now points at `.json.zst`.
- S3SnapshotStore.loadPartial falls back to the legacy `.json` key so
  in-flight QStash operations spanning the deploy keep working; the
  fallback dies off naturally once partials finalize.
- removePartial deletes both keys for clean transition.

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

* 🔒 chore(agent-tracing): gate zstd compression on NODE_ENV=production

Local dev (including ENABLE_AGENT_S3_TRACING=1 for S3 testing) keeps
writing plain `.json` so devs can inspect bucket payloads directly.
Only production deployments (NODE_ENV=production) compress + use the
`.json.zst` suffix.

Readers no longer assume the URL suffix matches the body format —
they sniff the zstd frame magic (0x28b52ffd) and decode accordingly.
This way prod-written `.json.zst` and dev-written `.json` round-trip
through the same code path regardless of which environment reads.

S3SnapshotStore.loadPartial tries the active suffix first then the
sibling format; removePartial cleans up both. RemoteSnapshotStore.fetch
falls back from `.json.zst` to plain `.json` on 404 so dev-uploaded
snapshots stay inspectable from another machine via the CLI.

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

* Revert "🔒 chore(agent-tracing): gate zstd compression on NODE_ENV=production"

This reverts commit 70d0b3d857.

*  test(agent-tracing): cover S3SnapshotStore zstd round-trip + legacy fallback

9 vitest cases mocking FileS3:
- save() → key ends in .json.zst, body starts with zstd magic, decompresses to original snapshot
- save() → falls back to "unknown" for missing agentId / topicId
- savePartial() → writes to _partial/ with zstd body
- loadPartial() → decodes .json.zst happy path
- loadPartial() → falls back to legacy .json on miss
- loadPartial() → returns null when neither key exists
- removePartial() → deletes both .json.zst and .json
- removePartial() → swallows individual delete failures (allSettled)
- get/getLatest/list/listPartials → return null/[] (OTEL owns querying)

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:40:30 +08:00
Arvin Xu 36d0994ec2 🐛 fix(context-engine): attach diagnostic context to PlaceholderVariablesProcessor errors (#14741)
* fix: attach diagnostic context to ProcessorError/PipelineError

* fix: include cause summary in PipelineError message

* fix: pass structured cause to ProcessorError

* fix: enhance PlaceholderVariablesProcessor with diagnostic context

* 🐛 fix: preserve placeholderVariablesProcessed count for no-op messages

processMessagePlaceholdersWithDiagnostics always returns a spread {...message},
so the identity check `processed !== message` was always true and the count
incremented even when content was unchanged (e.g. messages with no placeholders
or only unresolved `{{missing}}` tokens). Restore the JSON-equality comparison
used by the pre-PR `processMessagePlaceholders` path.

Add regression coverage for the no-op cases and for new error paths:
- only-unresolved string content, only-unresolved array text parts, mixed batch
- per-message isolation when a generator throws
- defensive validation when variableGenerators is undefined / null

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:26:19 +08:00
Arvin Xu 516c04797d 🐛 fix(hetero-agent): defer fetch-triggering events to avoid parallel tool count rollback (#14806)
🐛 fix(hetero-agent): defer fetch-triggering events through persistQueue to avoid parallel tools[] rollback

When CC fires a large parallel tool batch, the gateway handler's
fetchAndReplaceMessages (triggered synchronously by tool_end) reads a
partial assistant.tools[] while persistToolBatch Phase 1/3 writes are
still queued, and replaceMessages clobbers the in-memory cumulative
tools[] — causing the "7 → 6 次技能调用" rollback users see in the
AssistantGroup count.

Defers tool_end / step_complete:execution_complete / stream_chunk with
toolMessageIds through persistQueue so the handler observes
DB state only after pending writes commit. Text / reasoning / regular
tools_calling forwards stay synchronous to preserve streaming UX.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:53:41 +08:00
LobeHub Bot f3cf7f4aed 🤖 style: update i18n (#14449) 2026-05-15 09:34:48 +08:00
Arvin Xu df8111aca0 🐛 fix(build): pin vite to 8.0.12 to avoid rolldown 1.0.1 preload regression (#14804)
Vite 8.0.13 bumps rolldown to 1.0.1, which ships a new
chunk-optimization dedupe pass (rolldown #9305) with an unsound
sibling-dynamic-entry handling — see rolldown #9350 (open). This
causes preload-deps entries (m.f in __vite__mapDeps) to be dropped,
leaving null slots; at runtime any dynamic import that hits the
shrunken table fires import(null) and throws "Failed to resolve
module specifier 'null'", taking down every tRPC call that flows
through src/libs/trpc/client/lambda.ts headers (await import('@/services/_auth')).

Because the repo runs with lockfile=false + resolution-mode=highest,
^8.0.9 silently floats to 8.0.13 on every fresh Vercel build. Pin
exactly to 8.0.12 (which uses rolldown 1.0.0) until rolldown 1.0.2 /
Vite 8.0.14 lands a fix.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 02:20:50 +08:00
Rdmclin2 566b261a12 feat: support bot watch (#14796)
* feat: add whatsAPP and iMessage comming soon

* chore: update i18n

* feat: support watch keyword instruction

* feat: add cli and messager api for bot channels

* fix: test cases

* feat: add system prompt for messenger tool

* feat: add messenger mdx
2026-05-15 00:36:40 +07:00
Innei e00c299d1c 🐛 fix(onboarding): resolve agent route loading stall and branch redirect (#14795)
* 🐛 fix(onboarding): refresh branch config before redirect

* 🐛 fix(onboarding): refresh agent route flag before branch guard

* 🐛 fix(onboarding): simplify agent branch guard

* 🐛 fix(onboarding): eliminate agent route loading stall

- Make AgentModel.getBuiltinAgent idempotent under concurrent callers.
  The web-onboarding builtin agent was inserted by both the bootstrap
  query and the standalone useInitBuiltinAgent SWR in parallel; the
  insert loser hit agents_slug_user_id_unique and SWR sat in its ~5s
  error-retry window before the row could be read.
- Prefetch /onboarding/agent and /onboarding/classic chunks while the
  shared-prefix steps are visible, so the branch redirect no longer
  pays a cold chunk load.

* 🐛 fix(onboarding): skip prefetch under test and complete fixture

- Add `__TEST__` Vite define so renderer code can branch on Vitest runs
  (set true in vitest.config.mts, false in sharedRendererDefine).
- Guard the shared-prefix chunk prefetch with `if (__TEST__) return`.
  Otherwise the fire-and-forget `import('@/routes/onboarding/agent')`
  resolves after the test asserts and tries to load builtin-agents,
  which the test's partial `vi.mock('@lobechat/const')` doesn't supply
  (`DEFAULT_MODEL` missing), surfacing as 25 unhandled rejections.
- Fix `extract.runtime.test.ts` fixture to include the new required
  `agentBenchmarkLoCoMo` field on `MemoryExtractionPrivateConfig`,
  added in 20267fc77c.
2026-05-15 01:19:37 +08:00
Arvin Xu e0d20e86fc feat: support chat mode and redesign chat input action bar (#14774)
* Refine chat parameter controls and working sidebar

* 💄 style: refine chat parameter controls

* 💄 style: refine chat input action affordances

* 💄 style: refine chat input control menus

* 💄 style: refine chat input skills menu

* 🐛 fix: replace skills policy dropdown with popover

* fix: base-ui dropdown

* fix: base-ui dropdown

* 💄 style: fix popover conflict and refine skills menu layout

- Extract PopoverLabel component with controlled open state to prevent
  conflict when skill policy menu opens
- Dispatch custom close event so detail popovers close before policy popover opens
- Add divider between pinned and auto skill groups
- Refine sticky search/footer padding via CSS attribute selectors
- Remove stray console.log from ActionDropdown

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

* 💄 style: refine skills policy menu and chat input UI

- Skills policy menu: change active icon color to blue, add divider +
  uninstall action for Klavis/MCP/agent-skill items, suppress detail
  popover when the "..." policy menu is open
- Minor refinements across ChatInput, Conversation Error/ContentLoading,
  and HeterogeneousAgent StatusGuide components

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

*  feat: add custom MCP tag and configure action to skills menu

- Show orange "Custom" tag next to custom MCP plugin entries
- Add Configure action above Uninstall in the policy popover that
  opens the PluginDevModal drawer for editing the custom plugin

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

*  feat: default agent mode to true and gate chat mode at the tools engine

- Move `enableAgentMode` from `LobeAgentConfig` to `LobeAgentChatConfig` so it
  persists via the existing `chat_config` jsonb column and is readable on the
  server (the top-level field was silently dropped by drizzle).
- Default to agent mode for all agents — selectors treat `undefined` as `true`;
  only an explicit `false` collapses to chat mode.
- Introduce `chatModeAllowedToolIds = [knowledge-base, memory, web-browsing]`.
  Both `createServerAgentToolsEngine` and the frontend `createAgentToolsEngine`
  now switch on this whitelist in chat mode: skip user plugins, skip
  `alwaysOnToolIds`, narrow `defaultToolIds`, and turn off
  `allowExplicitActivation` so the activator can't smuggle other tools in.
- `useToggleAgentMode` is the single mode-switch entry; `plugins[]` is left
  alone — chat mode is enforced at runtime, not by mutating saved config.

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

*  feat: extend topic status with running/paused/failed

Widen `ChatTopicStatus` enum (DB schema, types, TRPC validation) to cover the
in-flight lifecycle that gateway and heterogeneous executor runs report. Add a
`updateTopicStatus` store action and have both runtime paths write `running`
on start and `active` on completion (or `failed` on terminal error). Sidebar
topic items render a spinner while `status === 'running'`.

Note: drizzle migration for the widened enum needs to be generated separately.

* 💄 style: polish skills menu — official tag, tooltip on settings button

Add a LobeHub "official" badge to builtin tools and agent skills surfaced in
the Skills menu. Wrap the menu's settings button in a Tooltip. Scope the
group-header padding reset to the skill-activation group only so the
Knowledge submenu keeps its native section padding.

*  feat: mark topic as paused while awaiting human tool approval

Extend the heterogeneous-agent topic status machine (c0170d032f) with a
paused state. The gateway event handler writes topic.status = 'paused' on
step_start { phase: 'human_approval' } — one hook covers both Gateway and
desktop heterogeneous paths since they share the same handler.

Resume back to 'running' is free: approve / reject_continue both spawn a
fresh op via the executor entries, which already persist 'running'.

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

*  feat: gate skills and agent-document injectors at the context engine in chat mode

Thread `enableAgentMode` into `MessagesEngine`. When it is explicitly `false`,
the engine forces `enabled: false` on:
- SkillContextProvider — drops the <available_skills> block
- All AgentDocument injectors (BeforeSystem / SystemAppend / SystemReplace /
  Context / Message) — drops every agent-document position

The frontend (`src/services/chat/mecha/contextEngineering.ts`) and server
(`src/server/modules/AgentRuntime/RuntimeExecutors.ts` →
`serverMessagesEngine`) read `chatConfig.enableAgentMode` from agent config
and pass it through; no caller needs to know which injectors to skip.

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

*  feat: also gate agent-management context in chat mode

`agentManagementContext` (the `<current_agent>` + `<available_agents>` block)
was leaking into chat-mode prompts whenever the agent was in auto-skill mode,
because its caller-side guard (`isInAutoSkillMode || isAgentManagementEnabled`)
is orthogonal to `enableAgentMode`. Fold the gate into the same `isAgentMode`
switch already covering skills + agent documents in `MessagesEngine` so the
injector goes off in chat mode regardless of how the caller populates the
context.

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

* 🐛 fix: drop orphan rebase marker in OperationTraceRecorder

Leftover `<<<<<<< HEAD` from an earlier rebase that was only half cleaned —
the HEAD-side content is the one we want; just delete the marker line so the
file type-checks again.

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

* 💄 style: cursor-style action bar on home input

Rework the home ChatInput footer to read like Cursor's composer while keeping
the model picker on the right:

- Replace the `agentMode` icon-only button with a pill trigger (icon + label
  + chevron) carrying a persistent fill, dropping a `bottomLeft` mode
  popover. Reuses the `RuntimeConfig/ModeSelector` design in place so any
  other action bar consumer picks it up automatically.
- Introduce a `modelLabel` action that shows the resolved model display name
  + chevron, opening `ModelSwitchPanel`. The original `model` icon stays
  untouched for callers that prefer the compact form.
- Wire the home input to use ['agentMode','plus'] on the left and
  ['modelLabel'] on the right; bump `SendArea` gap to 12 and add
  `paddingLeft={6}` to the action bar so the pill aligns with the input
  placeholder.
- Localize `chatMode.chat` to "对话" in zh-CN (default English stays "Chat").

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

* 💄 style: surface params panel toggle and hide it for heterogeneous agents

- Drop the developer-mode gate on the conversation header params toggle so it
  ships by default; popup routes remain excluded.
- Hide both the header toggle and the right sidebar `Params` tab for
  heterogeneous agents (Claude Code / Codex etc.), since their model params
  panel doesn't apply. The active-tab resolver also falls back away from
  `params` when it isn't available.
- Strengthen the Tools popover divider to `colorFill` so the header /
  footer separators stay visible against the elevated dark-mode surface.

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

* 🚑 fix: address type errors surfaced on the new-input branch

- Move the `border` from the removed `overlayInnerStyle` onto `styles.content`
  so the AgentMode / ModeSelector popovers compile against the base-ui
  `PopoverProps` shape.
- Pass `paddingLeft: 6` through `style` on `ChatInputActions` since the
  underlying Flexbox only accepts `padding` / `paddingBlock` / `paddingInline`.
- Tighten skill / market menu items: drop the unsupported `closeOnClick`
  from the group item, fallback the uninstall display name to
  `identifier`, swap the antd-style `type: 'warning'` confirm option for
  `okButtonProps.danger`, and assert the conditionally-spread market
  items as `ItemType` so the inferred union no longer contains
  `undefined`.
- Annotate `resolveMark` in `LevelSlider` so the fallback branch returns
  a `ReactNode` label, fixing the `MarkObj` mismatch on `LevelOption`.

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

---------

Co-authored-by: Innei <tukon479@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 00:07:47 +08:00
YuTengjing b5871d327a 🐛 fix: preserve resume request trigger (#14798) 2026-05-14 23:43:09 +08:00
YuTengjing 875c9b49eb 🐛 fix: reduce task template skeleton CLS (#14788)
* 🐛 fix: reduce task template skeleton CLS

* 🐛 fix: align recommendation skeleton count

* 🐛 fix: derive recommendation skeleton count

*  test: cover recommendation count without rendering

*  test: move recommendation count coverage to const

* ♻️ refactor: simplify task template recommendation count

* ♻️ refactor: remove task template recommendation aliases

* 🐛 fix: use task template count constant in router

* ♻️ refactor: remove task template count max
2026-05-14 23:23:21 +08:00
Innei 1914ae6d43 🐛 fix(desktop): restrict local file previews (#14789)
* 🐛 fix(desktop): restrict local file previews

* 🐛 fix(desktop): close TOCTOU in localfile protocol handler

* 🐛 fix(desktop): guard approveWorkspaceRoots against undefined input

App.test.ts StoreManager mock returned undefined for unknown keys,
causing TypeError when approveWorkspaceRoots tried to call .map().
Added default parameter and updated mock to return defaultValue.

*  test: stabilize ci dependency resolution
2026-05-14 22:08:57 +08:00
YuTengjing ffd66d5465 📝 docs: simplify and refresh skill docs (#14785) 2026-05-14 15:53:05 +08:00
Arvin Xu d00770a956 💄 style: AnalyzeVisualMedia inspector, Portal HTML preview refactor & CE trace dedup (#14777)
*  feat: add AnalyzeVisualMedia inspector, Portal HTML preview refactor, and CE trace dedup

- Add AnalyzeVisualMedia inspector and state types to builtin-tool-lobe-agent
- Refactor Portal HTML renderer to use @lobehub/ui built-in HtmlPreview
- Add portal artifact type selector and portal selectors to distinguish HTML/other artifacts
- Dedup context_engine_result events in OperationTraceRecorder; add resolveCeEvent in viewer
- Update .agents/skills/builtin-tool/references/ui.md with Tool Render design principles
- Bump @lobehub/ui to 5.12.0 for HtmlPreview support

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

* 🧪 test(trace-recorder): add deduplicateCeEvent tests for context_engine_result dedup

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

* 🐛 fix(agent-tracing): wire resolveCeEvent into all CE reader paths

All render functions and CLI inspect paths now call resolveCeEvent(step, allSteps)
instead of reading step.events?.find(...) directly, so deduplicated steps
correctly reconstruct their context_engine_result input/output by walking back
through previous steps.

Affected: renderSystemRole, renderEnvContext, renderPayloadTools, renderPayload,
renderMemory, renderMessageDetail, renderStepDetail, and all --system-role /
--env / --payload-tools / --payload / --memory CLI branches (both text and --json).

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

* ♻️ refactor(conversation): pass onRegenerate through ErrorMessageExtra and fix error guard order

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

* ♻️ refactor(agent-tracing): lift context_engine_result out of events into typed contextEngine field

Replace ad-hoc CE event dedup (mutating input/output inside events[]) with a
dedicated `contextEngine` field on StepSnapshot that uses the same delta pattern
as messagesBaseline/messagesDelta. CE data is structural state, not a streaming
event — keeping it in events[] was a semantic mismatch.

- Add `StepSnapshot.contextEngine?: { input?, output? }` with full delta semantics
- OperationTraceRecorder: extract CE from events before building snapshotEvents,
  store in contextEngine, deduplicate via deduplicateCeSnapshot (no more mutations)
- viewer: add resolveCeSnapshot (reads contextEngine first, falls back to legacy
  events format for old snapshots); deprecate resolveCeEvent alias
- inspect CLI: update all call sites to resolveCeSnapshot
- tests: rewrite deduplicateCeEvent suite → contextEngine dedup suite

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

* 💄 style(loading): use colorTextTertiary for elapsed time display

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 15:25:54 +08:00
Neko 20267fc77c 🔨 chore(memory-user-memory): add benchmark agent config (#14779) 2026-05-14 14:45:30 +08:00
Neko 4630785870 🔨 chore(memory-user-memory): support source ids in extraction schemas (#14778) 2026-05-14 14:45:09 +08:00
Rdmclin2 5b7611615e 🐛 fix: system bot error (#14784)
* chore: add start link short cut

* chore: update qq zh files

* fix: add messenger block message alert

* chore: update i18n files

* fix: messenger router bridge

* fix: dm thread create problem

* chore: remove lab prefer for messenger

* chore: update i18n files

* fix: e2e test
2026-05-14 13:26:10 +07:00
Arvin Xu ec547a3b57 🐛 fix(topic): restore indent for heterogeneous agent topic rows (#14783)
Remove the dead `return null` branch that skipped icon rendering entirely
for heterogeneous agents (Claude Code, Codex, …).  The early return caused
`NavItem` to omit the 28 px icon `<Center>` container, shifting the title
text leftward and breaking visual alignment with regular topic rows.

The existing `visibility: hidden` style on the HashIcon already preserves
the layout box while hiding the glyph — the null return just prevented it
from ever running.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 12:58:09 +08:00
Innei 36c4be46f0 🐛 fix(desktop): split runtime externals from native deps (#14776) 2026-05-14 01:57:46 +08:00
Neko 7b136a210f 🐛 fix(agent-signal): avoid blocking agent execution (#14775) 2026-05-14 01:53:11 +08:00
Innei 9075d5dfd3 refactor: merge agent marketplace into web onboarding
*  feat(desktop): open-in-app + agent files tab + localfile protocol

Bundle three related desktop features:
- Open-in-app: IPC contract, main-process detector/launcher/icon-extractor,
  renderer service, OpenInAppButton + hook, agent header / portal /
  files-tab integration, user preference (defaultOpenInApp).
- Agent files tab: working sidebar files tab with file tracking, store
  wiring, i18n, reveal-in-tree action in Review/FileItem.
- LocalFile protocol: serve binary images via localfile:// for inline
  preview in the review panel.

* 🐛 fix: add explicit type annotation for ref parameter in Files test

Fix TS7031: Binding element 'ref' implicitly has an 'any' type.
This error was caught by tsgo type-check in CI.

* 🐛 fix: address codex review feedback (P1 reveal retry + P2 WebStorm Windows detection)

* 🐛 fix(open-in-app): avoid process.platform reference in renderer

The Electron renderer sandbox does not expose `process`, so reading
`process.platform` in the useOpenInApp hook crashes with a ReferenceError
on app launch. Use the `window.lobeEnv.platform` value already exposed
via preload contextBridge instead.

* 🐛 fix(conversation): keep assistant runtime errors outside workflow collapse

When an assistant block carries a runtime error, render the error in the
answer segment instead of letting it fold into the workflow collapse with
the surrounding tool calls.

*  feat(portal): add file viewer tab strip and local file protocol improvements

- Add tabbed interface for local file portal viewer
- Extend LocalFileProtocolManager with audio MIME type support
- Add portal actions for file navigation and tab management
- Improve OpenInAppButton and conversation header integration
- Update working sidebar resources section
- Add comprehensive portal action tests

*  feat(agent-sidebar): redesign Review panel and refine Files explorer

- Review: drop antd Collapse, replace with a linear disclosure list
  (hairline dividers, no rounded cards, chevron-left, role=button rows).
  Add motion height/opacity expand animation. Compact row spacing.
  Move hover-revealed copy/reveal/revert into an absolute Flexbox with
  a gradient mask so they overlay the right edge without taking layout.
- Files: extract useGitWorkingTreeFiles hook + tests; surface git
  status entries in the working tree explorer.
- ExplorerTree: share folder icon style; minor type tweak.
- Locales: new chat strings for the above.

* 🐛 fix(test): add missing chatConfigByIdSelectors mock to WorkingSidebar test
2026-05-14 01:45:43 +08:00
YuTengjing 1c429f8d28 feat(chat): add Onboarding request trigger and pass via metadata (#14770)
*  feat(chat): add Onboarding request trigger and pass via metadata

- Add RequestTrigger.Onboarding for onboarding chat requests
- Replace requestTrigger option with metadata.trigger across chat service / executors
- Tag onboarding agent send-message with metadata.trigger = Onboarding
- Persist trigger on message metadata for billing & logs

* 🔨 chore(chat): share request context header constants

* 🐛 fix(chat): preserve trigger on tool resumes

* 🔧 chore(builtin-agents): expose package entry types

*  test(types): preserve request trigger metadata

* 🐛 fix(chat): scope resumed trigger metadata to message chain
2026-05-14 00:32:26 +08:00
Neko ac250b9897 ♻️ refactor(agent-signal,server,app,database,locales): self iteration exits lab (#14769) 2026-05-14 00:04:57 +08:00
Neko e8b7fe14e1 🐛 fix(server,memory-user-memory): embedding token exceeded, should limit and cut off searched memory query (#14757) 2026-05-13 22:32:28 +08:00
Innei 79cf5febed 🐛 fix(kb): preserve files on NoSuchKey and clean orphan documents/tasks (#14501)
* 🐛 fix(kb): preserve files on NoSuchKey and clean orphan documents/tasks

NoSuchKey from object storage no longer cascades into wholesale deletion
of file rows (and their chunks/embeddings). Instead the async chunking
task is marked Error with a clear message so users can re-upload or
retry. Files whose url uses the `internal://` scheme (mirror rows for
inline custom/document) skip storage fetch entirely.

fileModel.delete and deleteMany now also remove (a) mirror documents
where sourceType='file' and fileId matches, and (b) the chunk/embedding
asyncTasks rows tied to the file. Without this, deletion left orphan
documents (still indexed by BM25, still occupying KB slots) and dangling
task rows.

Closes LOBE-8607

* 🐛 fix(kb): delete document storage objects
2026-05-13 22:22:19 +08:00
Innei 4b6b341951 💄 fix(nav-panel): polish SideBarDrawer & header layout details (#14762)
* 💄 fix(nav-panel): polish SideBarDrawer & header layout details

- Use SMALL icon size for close button and settings icon
- Remove unused imports and dead code in SideBarHeaderLayout
- Fix topic item padding in AllTopicsDrawer Content

* 🐛 fix(nav-panel): update ITEM_HEIGHT to match new row height without vertical padding

Address Codex review feedback on PR #14762.
The padding change from padding='4px 8px' to paddingInline={4} removed
the 4px top/bottom padding, reducing row height from ~44px to ~36px.
Update ITEM_HEIGHT estimate from 44 to 36 to keep virtualization
fill logic accurate.
2026-05-13 20:41:03 +08:00
AmAzing- 44892960e0 feat: add Agent Signal marker to receipt descriptions (#14764)
 feat: add agent signal marker to receipt descriptions
2026-05-13 19:19:52 +08:00
Innei dc86f38dc1 🐛 fix(onboarding): hide ModeSwitch in production environment (#14760)
The ModeSwitch component was rendering in production because the cloud
repo sets AGENT_ONBOARDING_ENABLED=true, bypassing the isDev guard
inside the component. Wrap the entire ModeSwitch with isDev so neither
the segmented control nor dev actions appear in prod.
2026-05-13 19:07:39 +08:00
LiJian 3e43683132 🔨 chore(heteroContext): clarify sandbox TTL and add public-repo fork push guide (#14761)
* 🔨 chore(heteroContext): clarify sandbox TTL and add public-repo fork push guide

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

* 🐛 fix(heteroContext): make fork remote setup idempotent

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:52:35 +08:00
LobeHub Bot 2cfe9f6180 🌐 chore: translate non-English comments to English in file-loaders (#14744)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 15:34:53 +08:00
Neko c9bb82d09d 🐛 fix(builtin-tool-memory): clarify memory retrieval sufficiency rules (#14753) 2026-05-13 15:19:43 +08:00
Rdmclin2 6933ddc4e5 🔨 chore: Online Messager (#14755)
* feat: add line integration Banner

* chore: remove messenger lab switch

* feat: add messenger banner

* feat: add messenger promo

* chore: update i18n files
2026-05-13 14:17:07 +07:00
Arvin Xu ef8aa72af5 🐛 fix(brief): add ignore action next to retry on error briefs (#14742)
*  feat(brief): add ignore action next to retry on error briefs

Lets users dismiss error briefs without re-running the task. The button
is hardcoded in the UI alongside the retry primary action; brief.actions
stays untouched.

*  feat(agent-runtime): wire trigger field across all execAgent call sites

- Add Cli / Openapi / Notify values to RequestTrigger enum
- Pass trigger:'cli' from CLI command, trigger:'openapi' from OpenAPI service
- Pass trigger:RequestTrigger.Eval from all 4 agentEvalRun call sites
- Pass trigger:RequestTrigger.Notify from agentNotify router
- Default trigger to RequestTrigger.Chat in execAgent/execAgents tRPC handler
- execGroupAgent passes trigger:RequestTrigger.Chat explicitly
- execSubAgentTask inherits trigger from parent operation (best-effort DB lookup)
- Expose trigger as optional input on ExecAgentSchema so callers can override
- Remove dead aiAgent.createOperation tRPC mutation and its frontend counterpart
- Delete test file that only covered the removed createOperation method

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

* 💄 style(loading): use shiny text animation for operation labels

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

* 🐛 fix(error): broaden heterogeneous agent error guard to match any error type

The previous guard required `error.type` to be `AgentRuntimeError` or absent,
which missed cases like `ServerAgentRuntimeError`. Extract the detection into a
proper type guard (`isHeterogeneousAgentStatusGuideError`) that checks only the
body shape (agentType + code), making it resilient to wrapper error types.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 15:12:24 +08:00
Neko 8618699888 🐛 fix(server/toolExecution): support server-owned memory embedding runtime (#14754) 2026-05-13 15:09:17 +08:00
Neko bfc4820a17 🐛 fix(server/userMemories): return locomo ingestion session results (#14752) 2026-05-13 15:09:10 +08:00
LiJian d8bfc58f22 🐛 fix(casc): replace new Function() template with safe string builders (#14751)
* 🐛 fix(casc): replace new Function()-based template with safe string builders and self-fetching ChangelogModal

- Remove es-toolkit/compat template (uses new Function()) from ShareModal, ShareMessageModal, and parserPlaceholder; replace with plain string building and String.replace
- ChangelogModal now self-fetches latest changelog id via lambdaClient instead of relying on async server component wrapper; setTimeout starts after data arrives
- Remove ChangelogService/gray-matter import from route component

* 🐛 fix(casc): add missing deps to changelog timer effect
2026-05-13 14:59:50 +08:00
Neko 690098dcb9 🐛 fix(agent-signal,server): both skill bundle and skill index should be considered as primary skill documents (#14748) 2026-05-13 13:11:59 +08:00
Neko a12079d338 🐛 fix(server): user id context missing in tool outcome for signal (#14749) 2026-05-13 13:11:49 +08:00
LiJian 8d1584eb78 🐛 fix(cc): preserve trailing suffix after partial deltas (#14745)
* 🐛 fix(cc): preserve trailing suffix after partial deltas

* 🐛 fix(cc): clear streamed delta buffers after reconciliation

* 🐛 fix(cc): clear streamed buffers per modality
2026-05-13 12:56:00 +08:00
LiJian c3bb289c44 🐛 fix(market-auth): add offline_access scope and guard expiresIn default (#14743)
Add `offline_access` to the OIDC authorization scope so the server
returns a refresh_token, fixing silent session expiry after ~24h.

Guard `tokenResponse.expiresIn` with `?? 3600` to prevent `NaN`
propagation into `expiresAt` when the server omits the field.

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 11:30:07 +08:00
lobehubbot b125565597 🔖 chore(release): release version v2.1.58 [skip ci] 2026-05-13 02:01:19 +00:00
lobehubbot c19f87fdb2 Merge remote-tracking branch 'origin/main' into canary 2026-05-13 01:59:32 +00:00
Arvin Xu 9d03349c46 🚀 release: 20260513 (#14739)
# 🚀 LobeHub Release (20260513)

**Hotfix Scope:** Ship the canary backlog (111 PRs) onto main as a
fast-tracked patch — operator-focused, no weekly-style write-up.

> Brings the accumulated canary work into main: agent/task improvements,
hetero-agent fixes, desktop & onboarding polish, and several reliability
caps.

##  What's Included

- **Agent & tasks** — Self-review proposal-to-action automation,
sub-agent dispatch consolidated to `lobe-agent`, AskUserQuestion wiring
for Claude Code, scheduler/hotkey/TodoList polish. (#14583, #14657,
#14715, #14639, #14732, #14707, #14713)
- **Home & onboarding** — Daily brief with linkable welcome + paired
input hint, inline skill auth in recommended task templates, cleanup of
captcha-on-signin and marketplace early-exit. (#14589, #14676, #14573,
#14598)
- **Bots & integrations** — Slack MPIM support, Discord DM fix,
slash-command + connect-error fixes, gateway client-tool plugin state.
(#14733, #14591, #14596)
- **Desktop & CLI** — Windows `.cmd` shim detection for `claude` /
`codex` CLIs, auth focus & pending-login reset fixes. (#14720, #14694,
#14695)
- **Reliability** — Cap web-crawler body size and image binary at safe
limits, attach error listeners to Neon/Node pools, reject inactive OIDC
access. (#14660, #14711, #14606, #14674)
- **Database** — `agent_operations` table + persist agent operations
from the runtime; switch user memory search to `paradedb.match(...)`.
(#14416, #14736, #14590)

## ⚙️ Upgrade

- **Self-hosted:** pull the latest image and restart. Drizzle migrations
(including the new `agent_operations` table) run automatically on boot.
2026-05-13 09:58:47 +08:00
Zhijie He 1a745382b5 💄 style: add spark-x2-flash support (#14731)
* style: add spark-x2-flash support

* fix: fix deployname not send to api

fix: fix deployname not send to api

fix: fix deployname not send to api

fix: fix deployname not send to api

fix: fix deployname func

fix: fix deployname func
2026-05-13 03:08:55 +08:00
Arvin Xu a77234107e feat(agent-runtime): persist agent operations to agent_operations table (#14736)
*  feat(agent-runtime): persist agent operations to `agent_operations` table

Wire start-time INSERT and terminal UPDATE into the agent runtime so
operation history outlives the 2-hour Redis TTL. Adds
`AgentOperationModel` with `recordStart` / `recordCompletion` /
`findById` (scoped by userId so a leaked operationId can't flip another
user's row) and threads both calls through `CompletionLifecycle`, which
now owns both ends of the persistence lifecycle. Also plumbs
`parentOperationId` through `ExecAgentParams` → `OperationCreationParams`
so sub-agent invocations carry their parent lineage. Per-step aggregate
updates are intentionally out of scope.

Refs LOBE-8848

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

* 🐛 fix(agent-runtime): update CompletionLifecycle test constructor to 2 args

CompletionLifecycle now constructs MessageModel internally from
(db, userId), so the test builder passing a third messageModel arg
tripped tsgo --noEmit.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:14 +08:00
Rdmclin2 729265ab5d feat: support slack mpim and fix discord dm problem (#14733)
* feat: support mpim

* chore: add errorMsg

* fix: discord commands thinking error

* fix: discord typing error

* feat: add oauth process for discord
2026-05-13 02:57:14 +08:00
Arvin Xu 5174c13ef1 🐛 fix(hetero-agent): wire AskUserBridge response events to renderer (#14732)
Close the wire-protocol gap that left CC's AskUserQuestion form stuck on
"pending" after the bridge gave up. AskUserBridge now emits an
agent_intervention_response event on every terminal path (timeout,
user resolve, cancel, cancelAll), and heterogeneousAgentExecutor handles
it by stamping pluginIntervention.status = 'rejected' for timeout /
session_ended (user-driven paths are filtered out — already optimistic).

Layered defenses so a late Submit no longer throws "Operation not found":
- cleanupCompletedOperations: find→filter so every messageOperationMap
  entry pointing to the cleaned op is removed (assistant + tool message
  pairs previously stranded one entry as a dangling reference).
- internal_getConversationContext: log + fall back to global state when
  the op has been GC'd, instead of throwing.
- submitHeteroIntervention: detect a stale opId before passing it into
  the optimistic chain.

Scoped as a short-term backstop until LOBE-8746 retires the AskUser MCP
bridge entirely.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:14 +08:00
Arvin Xu dcc9f78091 ♻️ refactor(builtin-tool): move sub-agent dispatch from lobe-gtd to lobe-agent (#14715)
* ♻️ refactor(builtin-tool): move sub-agent dispatch from lobe-gtd to lobe-agent

Move the `execTask` / `execTasks` capability out of `packages/builtin-tool-gtd/`
and into `packages/builtin-tool-lobe-agent/`, renaming the public APIs to
`callSubAgent` / `callSubAgents`. The "subtask" naming inside GTD overlapped
with the new lobe-task tool's task model and conflated planning with
sub-agent dispatch.

- API names: `execTask` → `callSubAgent`, `execTasks` → `callSubAgents`
- TS types: `ExecTaskParams` → `CallSubAgentParams`, etc.; introduce
  `SubAgentTask` to replace `ExecTaskItem`
- Client UI (Inspector / Render / Streaming) ported under
  `packages/builtin-tool-lobe-agent/src/client/`
- Central registries (`packages/builtin-tools/src/{inspectors,renders,streamings}.ts`)
  updated to register lobe-agent
- GTD `meta.description` and system role no longer mention async tasks;
  they point to lobe-agent for sub-agent dispatch
- `isSubTask` filtering in `agentConfigResolver` now excludes `lobe-agent`
  (new owner of sub-agent dispatch) instead of `lobe-gtd`
- i18n: new `builtins.lobe-agent.apiName.callSubAgent*` and
  `workflow.toolDisplayName.callSubAgent*` keys in default/zh-CN/en-US

Kept the executor's emitted `state.type` values (`execTask` / `execTasks` /
`execClientTask` / `execClientTasks`) unchanged so the agent-runtime
instruction layer (`exec_task` / `exec_tasks` / `exec_client_task*`) and all
downstream tests / heterogeneous executors (`builtin-tool-agent-management`,
server `agentManagement` runtime) continue to work without modification.

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

* ♻️ refactor(chat): rename isSubTask flag to isSubAgent

After moving sub-agent dispatch from lobe-gtd to lobe-agent, the flag name
no longer matches what it controls. Rename `isSubTask` → `isSubAgent` across
the chat / agent runtime layer and update related comments and test labels.

- `agentConfigResolver` context field + filter helper
- `streamingExecutor.internal_createAgentState` + `executeClientAgent`
  signatures and call sites
- `createAgentExecutors` (exec_task / exec_client_task handlers) and
  `GroupOrchestrationExecutors` (batch_exec_async_tasks)
- `chatService.createAssistantMessageStream` `resolvedAgentConfig` docs
- Test descriptions and assertions in `agentConfigResolver.test.ts` and
  `streamingExecutor.test.ts`

No behavior change — the flag's filter target (`lobe-agent` identifier) is
unchanged.

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

* ♻️ refactor(agent-runtime): rename exec_task wire identifiers to exec_sub_agent

Bring the agent-runtime "wire" naming in line with the lobe-agent
callSubAgent / callSubAgents API rename. Three layers are renamed in lockstep
to keep the bridge between tool executors and the runtime consistent:

1. Tool-emitted state.type discriminators
   - 'execTask' → 'execSubAgent'
   - 'execTasks' → 'execSubAgents'
   - 'execClientTask' → 'execClientSubAgent'
   - 'execClientTasks' → 'execClientSubAgents'

2. AgentInstruction.type and matching TS interfaces
   - 'exec_task' / 'exec_tasks' / 'exec_client_task' / 'exec_client_tasks'
     → 'exec_sub_agent' / 'exec_sub_agents' / 'exec_client_sub_agent' /
       'exec_client_sub_agents'
   - AgentInstructionExecTask → AgentInstructionExecSubAgent (and the three
     siblings)
   - ExecTaskItem → SubAgentTask

3. AgentRuntimeContext.phase + matching payload types
   - 'task_result' → 'sub_agent_result'
   - 'tasks_batch_result' → 'sub_agents_batch_result'
   - TaskResultPayload → SubAgentResultPayload
   - TasksBatchResultPayload → SubAgentsBatchResultPayload

Also renames the operation-type discriminator 'execClientTask' /
'execClientTasks' to 'execClientSubAgent' / 'execClientSubAgents' and updates
its locale string in default / zh-CN / en-US.

Tests / fixtures / mocks updated in lockstep:
- packages/agent-runtime/src/agents/{GeneralChatAgent.ts,__tests__/...}
- packages/builtin-tool-{lobe-agent,agent-management}/src/...
- src/server/services/toolExecution/serverRuntimes/agentManagement.ts
- packages/agent-mock/src/cases/builtins/todo-write-stress.ts (helper renamed
  to callSubAgent)
- src/store/chat/agents/createAgentExecutors.ts + exec-task / exec-tasks tests
  + fixtures/mockInstructions.ts (createExecSubAgent[s]Instruction)
- src/store/chat/slices/aiChat/actions/streamingExecutor.ts (phase check)
- packages/conversation-flow/src/__tests__/fixtures/**/*.json (8 fixtures
  retargeted from lobe-gtd/execTask[s] to lobe-agent/callSubAgent[s] with the
  new state.type wire values)

No behavior change — the agent runtime, executors and tests all go through
the same code paths; only the strings on the wire change.

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

* ♻️ refactor(builtin-tool): absorb GTD tool (plan + todo) into lobe-agent

Delete `packages/builtin-tool-gtd/` and fold its full surface — plan, todo,
ExecutionRuntime, all client UI (Inspector / Render / Streaming /
Intervention / SortableTodoList) and the system role — into
`packages/builtin-tool-lobe-agent/`. Single `lobe-agent` identifier now
owns: plan + todo management, sub-agent dispatch, and visual media analysis.

Also restructures the lobe-agent package so the executor lives under
`./client/` alongside the UI it ships with, and drops the dedicated
`./executor` export — consumers go through `./client` for everything
client-side.

Package-level changes:
- DELETE `packages/builtin-tool-gtd/` entirely.
- `packages/builtin-tool-lobe-agent/`
  - Move `src/executor/` → `src/client/executor/`. Drop `./executor` from
    `package.json` exports; expose `lobeAgentExecutor` via `./client` only.
  - Rename `GTDExecutionRuntime` → `PlanExecutionRuntime` and place under
    `src/client/executor/PlanRuntime/`. Re-export from package root so the
    server runtime can consume it without pulling in client UI deps.
  - Extend `LobeAgentExecutor` with `createPlan` / `updatePlan` /
    `createTodos` / `updateTodos` / `clearTodos`, all delegated to the
    shared runtime.
  - Add Plan + Todo API entries to the manifest (with their original
    descriptions, humanIntervention, renderDisplayControl).
  - Move all GTD client UI verbatim:
    `Inspector/{ClearTodos,CreatePlan,CreateTodos,UpdatePlan,UpdateTodos}`,
    `Render/{CreatePlan,TodoList}`, `Streaming/CreatePlan`,
    `Intervention/{AddTodo,ClearTodos,CreatePlan}`,
    `components/SortableTodoList`. Register them in
    `LobeAgentInspectors / Renders / Streamings`, add new
    `LobeAgentInterventions`.
  - Merge GTD system role into lobe-agent's (`<plan_and_todos>` plus the
    existing `<sub_agents>` and `<run_in_client>` sections).
  - `package.json`: pick up `@lobechat/prompts` dep and `@lobehub/editor` +
    `antd` + `lucide-react` peer-deps inherited from GTD.

Central registries (`packages/builtin-tools/src/*`) and consumers:
- Remove every `GTDManifest / Inspectors / Renders / Streamings /
  Interventions` import + registration; existing `LobeAgent*` registrations
  now cover them.
- Replace `[GTDManifest.identifier]: GTDInterventions` with
  `[LobeAgentManifest.identifier]: LobeAgentInterventions`.
- Drop `@lobechat/builtin-tool-gtd` workspace dep from
  `packages/builtin-tools/package.json`, `packages/builtin-agents/package.json`
  and root `package.json`.
- Remove `gtdExecutor` from `src/store/tool/slices/builtin/executors/index.ts`;
  switch `lobeAgentExecutor` import to `/client`.
- Replace `serverRuntimes/gtd.ts` with a service factory
  `serverRuntimes/lobeAgentPlan.ts` (`createServerPlanRuntimeService`).
  `serverRuntimes/lobeAgent.ts` instantiates `PlanExecutionRuntime` with
  that service so the registry exposes one runtime per `lobe-agent`
  identifier covering both visual analysis and plan/todo.
- `services/chat/mecha/contextEngineering.ts`: gate plan/todo injection on
  `LobeAgentIdentifier` instead of `GTDIdentifier`.
- `agentConfigResolver.test.ts`: switch fixture plugin IDs to
  `LobeAgentIdentifier`.
- `packages/const/src/recommendedSkill.ts`: drop the standalone `lobe-gtd`
  recommendation — `lobe-agent` already covers it via `defaultToolIds`.

i18n migration (default + zh-CN + en-US; other locales regenerate on
`pnpm i18n`):
- `builtins.lobe-gtd.*` → `builtins.lobe-agent.*` in `plugin.ts/json`.
- `lobe-gtd.*` (tool namespace) → `lobe-agent.*` in `tool.ts/json`.
- Remove `tools.builtins.lobe-gtd.{description,readme,title}` from
  `setting.ts/json` (lobe-agent has its own meta now).
- Update all client component `t(...)` keys to the new namespace.

Mocks / fixtures / tests:
- `packages/agent-mock/src/cases/builtins/todo-write-stress.ts`: all
  `identifier: 'lobe-gtd'` → `'lobe-agent'`; helper comments updated.
- `packages/types/src/stepContext.ts`: comment refers to
  `builtin-tool-lobe-agent` (the only consumer of `StepContextTodoItem`).
- `packages/model-runtime/src/core/streams/google/google-ai.test.ts`:
  function-call names from `lobe-gtd____createPlan` etc. → `lobe-agent____*`.
- `src/store/chat/slices/message/selectors/dbMessage.test.ts`: same.
- `src/features/DevPanel/RenderGallery/fixtures/lobe-gtd.ts` deleted; its
  plan/todo fixtures are folded into `fixtures/lobe-agent.ts` alongside the
  existing `callSubAgent[s]` ones.
- Replace `console.log` → `console.info` in moved client components to
  satisfy lobe-agent's stricter ESLint rules (GTD package allowed
  `console.log`; lobe-agent inherits the repo-wide `no-console` rule).

No behavior change for end users: `lobe-agent` now owns all the APIs,
identifiers, and UI that previously lived in `lobe-gtd`, but as a single
consolidated package under a single tool identifier.

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

* ♻️ refactor(context-engine): drop residual GTD naming, rename to PlanInjector / TodoInjector

Follow-up to 9ca5c9d (which absorbed the GTD tool package into lobe-agent).
That commit moved the package surface but left the GTD vocabulary embedded
in context-engine providers, types, metadata fields, XML tags, and a pile
of comments. This change finishes the sweep so the only remaining GTD
references are user-facing docs and the legitimate Productivity & GTD Coach
methodology suggestion.

context-engine
- `GTDPlanInjector` → `PlanInjector`; types `GTDPlan`/`GTDPlanInjectorConfig`
  → `Plan`/`PlanInjectorConfig`; metadata `gtdPlanId`/`gtdPlanInjected` →
  `planId`/`planInjected`; XML tag `<gtd_plan>` → `<plan>`; debug channel
  `provider:GTDPlanInjector` → `provider:PlanInjector`.
- `GTDTodoInjector` → `TodoInjector`; types `GTDTodoItem`/`GTDTodoList`/
  `GTDTodoStatus`/`GTDTodoInjectorConfig` → `TodoItem`/`TodoList`/
  `TodoStatus`/`TodoInjectorConfig`; metadata `gtdTodo*` → `todo*`;
  XML tag `<gtd_todos>` → `<todos>`, wrapper `gtd_todo_context` →
  `todo_context`; debug channel renamed similarly.
- `MessagesEngineParams.gtd?: GTDConfig` → `planTodo?: PlanTodoConfig`;
  internal vars `isGTDPlanEnabled`/`isGTDTodoEnabled` →
  `isPlanEnabled`/`isTodoEnabled`. Re-exports updated in `providers/index.ts`
  and `engine/messages/{index,types}.ts`.

prompts
- `packages/prompts/src/prompts/gtd/` → `planTodo/` (only export was
  `formatTodoStateSummary`, which kept its name). Updated `prompts/index.ts`
  re-export.

src/services
- `contextEngineering.ts`: `GTDConfig` import → `PlanTodoConfig`;
  `isGTDEnabled`/`gtdConfig` → `isPlanTodoEnabled`/`planTodoConfig`; payload
  field `gtd` → `planTodo`; log message wording.

Tests
- `dbMessage.test.ts`: helper `createGTDToolMessage` →
  `createLobeAgentToolMessage`; `gtdMessage` → `lobeAgentMessage`; all `it`
  descriptions reworded to "lobe-agent" instead of "GTD".
- `agentConfigResolver.test.ts`: test descriptions reworded.

Comments / docs (no behavior change)
- agent-runtime (`instruction.ts`, `runtime.ts`, `generalAgent.ts`,
  `messageSelectors.ts`), `types/{stepContext,tool/builtin}.ts`,
  `builtin-agents/group-supervisor`, `builtin-tool-claude-code/types.ts`,
  `builtin-tool-lobe-agent/Render/TodoList`, `createAgentExecutors.ts:1426`,
  `AssistantGroup/{constants,Fallback.test}`, `agent-mock/todo-write-stress`,
  `.agents/skills/builtin-tool/references/architecture.md`.

Intentionally left alone
- `docs/usage/agent/gtd.{mdx,zh-CN.mdx}` and other docs — user-facing
  product brand "GTD Tools".
- `src/locales/default/suggestQuestions.ts` "Productivity & GTD Coach" —
  references the methodology, not the tool.
- `ToolSystemRoleProvider.test.ts` `'gtd-tool'` fixture — generic test
  identifier, unrelated.
- Translated locale files still carrying `lobe-gtd.*` keys — regenerated by
  `pnpm i18n` from the updated default namespace.

Verified: `bun run type-check` passes; touched test files
(dbMessage, agentConfigResolver) and full context-engine + prompts test
suites pass.

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

* 🐛 fix(builtin-tool-lobe-agent): reset TodoList auto-save status to idle

`performSave` (the debounced auto-save path) was leaving `saveStatus` stuck
on 'saved' forever — `saveNow` had the 1.5s setTimeout-to-idle but the
auto-save twin didn't, so the inline indicator never eased back to idle
after a settle. Add the same idle-reset to performSave so both paths
behave the same.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:14 +08:00
Arvin Xu 266d10206b 💄 style: use @lobehub/ui built-in HtmlPreview instead of custom component (#14703)
* 💄 style(home,i18n): use 已阅 for brief confirm/confirmDone in zh-CN

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

* 🐛 fix(home): use 确认完成 for brief.action.confirmDone in zh-CN

confirmDone signals the terminal transition (task marked complete),
not just dismissing the brief, so 已阅 loses the semantic distinction
from `confirm`. Use 确认完成 to match the EN intent ("Confirm complete").

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

* ♻️ refactor: use @lobehub/ui built-in HtmlPreview instead of custom component

- Upgrade @lobehub/ui from ^5.10.1 to ^5.10.4
- Replace custom HtmlPreviewAction with lobe-ui's enableHtmlPreview
- Wire lobe-ui's onExpand callback to existing HtmlPreviewDrawer
- Remove HtmlPreviewAction.tsx (no longer needed)
- Keep HtmlPreviewDrawer for the expanded full-screen view

* 🐛 fix(task): sync useMarkdown destructuring with assistant MessageContent

* 🐛 fix(task): correct mangled search.X JSX expressions in MessageContent

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

* 💄 style(review): move revert icon to right edge of file row

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:13 +08:00
LobeHub Bot 71a49b033f 🌐 chore: translate non-English comments to English in src (#14654)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 02:57:13 +08:00
Arvin Xu fc275ca4dc 🐛 fix(home): blank user bubble when sending the placeholder hint (#14678)
When the home input was empty and the user clicked send, `useSend`
correctly fell back to the daily-brief hint for `message`, but it also
forwarded `mainInputEditor.getJSONState()` as `editorData`. An empty
editor still returns a non-null JSON state (e.g. `{ type: 'doc' }`),
which makes `UserMessageContent.hasEditorData` truthy — so the renderer
took the RichTextMessage branch and drew nothing, while the agent
happily processed the hint text behind a blank user bubble.

Skip `editorData` when the hint is being used so the renderer falls
back to the markdown `content`. Adds a regression test.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:13 +08:00
Arvin Xu cb8b616546 feat(database): add agent_operations table (#14416)
 feat(database): add agent_operations table

Adds an `agent_operations` table to persist agent runtime operations
beyond the 2-hour Redis TTL. Each row captures one agent operation
(operationId) with denormalized cost/token aggregates, lifecycle
timestamps, runtime config snapshot, and a `trace_s3_key` pointer to
the full ExecutionSnapshot in S3.

- `user_id` is intentionally not a FK so operation history survives
  user deletion (auditable historical data).
- `agent_id` / `topic_id` / `thread_id` / `task_id` / `chat_group_id`
  use ON DELETE SET NULL to preserve operations when their parent
  entity is removed.
- `parent_operation_id` self-references for sub-agent (callAgent) ops.
- `human_interventions` and `human_waiting_time_ms` are nullable since
  most operations have no human interaction at all.
- Indexes optimize per-user listing and per-status / per-entity lookups;
  `metadata` has a GIN index for jsonb filters.
2026-05-13 02:57:13 +08:00
Innei 217afcf1af 🐛 fix(conversation): prevent synthetic scroll from shrinking spacer (#14584)
🐛 fix: prevent synthetic scroll from shrinking spacer
2026-05-13 02:57:13 +08:00
Arvin Xu 2f33932198 ♻️ refactor(agent-runtime): extract CompletionLifecycle, HumanInterventionHandler, stepPresentation (#14441)
* ♻️ refactor(agent-runtime): extract CompletionLifecycle

Pull terminal-state handling out of AgentRuntimeService into a dedicated
class:

- buildLifecycleEvent (was buildCompletionLifecycleEvent)
- emitSignalEvents (was emitCompletionSignalEvents)
- dispatchHooks (was dispatchCompletionHooks)
- extractErrorMessage

These four methods formed one cohesive vertical: build the lifecycle
event payload, emit completion AgentSignal source events, dispatch
onComplete/onError hooks, and write error back onto the assistant
message row. extractErrorMessage was a private helper used by all three
plus by the trace-snapshot finalize call site, so it becomes a public
method on the class.

Call sites in executeStep / executeSync change from
`this.{emit|dispatch|extract...}` to `this.completionLifecycle.{...}`.

Tests: extractErrorMessage.test.ts → CompletionLifecycle.test.ts,
instantiating CompletionLifecycle directly instead of going through
AgentRuntimeService — drops a pile of unrelated mocks.

AgentRuntimeService.ts: 2084 → 1918 (-166).

All 81 agentRuntime tests pass.

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

* ♻️ refactor(agent-runtime): extract HumanInterventionHandler

Pull the 165-line `handleHumanIntervention` method out of
AgentRuntimeService into its own class, splitting the three branches
(approve / rejectAndContinue / rejectAndHalt) into private methods so
each fits in one screen. Routing in `process()` now reads top-to-bottom:
detect approval, then rejection, then unsupported humanInput.

The handler depends only on `serverDB` (for the messagePlugins lookup)
and `messageModel` (for tool/plugin updates) — much narrower than
AgentRuntimeService's full surface, so the extracted unit is easier to
unit-test in isolation.

Drop the unused `runtime: AgentRuntime` parameter from the public API:
the original method threaded it through but never called it.

Tests: handleHumanIntervention.test.ts → HumanInterventionHandler.test.ts
— same 17 cases, but instantiate the handler directly instead of
constructing a full AgentRuntimeService with 11 module mocks. Tighter
arrange step, same coverage.

AgentRuntimeService.ts: 1918 → 1742 (-176).

All 81 agentRuntime tests pass.

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

* ♻️ refactor(agent-runtime): extract step presentation builder

Pull the ~150-line `phase`-branching block out of executeStep into a
pure `buildStepPresentation` function. The block did three things in
sequence: derive content/reasoning/toolsCalling/toolsResult from the
runtime step result, build a one-line stepSummary for logging, and
assemble the StepPresentationData DTO consumed by afterStep hooks /
snapshot recorder / callbacks.

The function takes only the stepResult and an executionTimeMs; no
service state needed. Comes with a `formatTokenCount` helper for the
log line (12345 → 12.3k, 2_500_000 → 2.5m).

executeStep keeps the log call inline (one line, references presentation
fields directly) and reads `content` / `toolsCalling` off presentation
for downstream tracking + truncation logic.

13 new unit tests: phase=tool_result (json + string + isSuccess paths),
phase=tools_batch_result, done event, llm_result with content/reasoning/
tools, empty fallback, cumulative usage zero-fallback, stepUsage
forwarding, and formatTokenCount edges.

AgentRuntimeService.ts: 1742 → 1601 (-141).

All 94 agentRuntime tests pass (was 81, +13 new).

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:13 +08:00
Arvin Xu df0e635c45 🐛 fix(task-card): localize task card date independent of dayjs global locale (#14730)
* 🐛 fix(task-card): localize date format independent of dayjs global locale

Task card was rendering "5月 12" under English UI because t('time.formatThisYear')
returned the English "MMM D" format, but dayjs's global locale was still zh-cn,
making MMM resolve to the Chinese short month name. Thread the i18n language
into formatTaskItemDate so the date is rendered with the same locale as the
format string, decoupling it from dayjs's global state.

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

* 🐛 fix(task-card): import missing GenericItemType + type Run now onClick

Pre-existing CI regression from #14727 surfacing on every PR: the Run now
context menu satisfies-clause references GenericItemType without importing
it, and the onClick lacks a MenuInfo annotation, so tsgo widens the divider
literal's `type` to `string` and rejects the whole context menu array.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:13 +08:00
Arvin Xu 2202189ac1 🐛 fix(web-crawler): cap response body size to prevent serverless OOM (#14660)
* 🐛 fix(web-crawler): cap response body size to prevent serverless OOM

Production saw repeated SIGABRT crashes on `/trpc/tools/search.webSearch`
where Node aborted with V8 "allocation failed" — the naive crawler buffered
entire response bodies into heap before the 1 MB downstream truncation could
apply, so a single large page (or a batch of three under default
concurrency=3) could push rss past the lambda memory ceiling.

- ssrfSafeFetch: add opt-in `maxContentLength` that streams the response
  body via `for await` and stops at the cap (soft truncation — still a
  successful response). Breaking the iterator destroys the underlying
  stream and releases the connection. Default behaviour (full
  `arrayBuffer()` read) unchanged when the option is absent.
- naive crawler: pass `maxContentLength: MAX_HTML_SIZE` so any body beyond
  1 MB is dropped at the network layer instead of being materialised in heap.
- htmlToMarkdown: explicitly call `window.happyDOM.close()` in a finally
  block so the parsed DOM tree is released as soon as parsing finishes,
  rather than waiting for the function scope to drop.

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

*  test(ssrf-safe-fetch): add OOM regression tests for response body cap

Verify that the maxContentLength cap actually prevents the production SIGABRT
scenario, not just produces a truncated body.

- Source-pull bound: a body source with 200 MB available, capped at 1 MB,
  must not be drained beyond ~1 MB. Asserts on bytes pulled from the
  generator, which is the property that prevents OOM.
- Concurrency bound: matches production CRAWL_CONCURRENCY=3 — three
  concurrent oversized fetches should pull at most ~3 MB total, not 300 MB.
- Heap-delta bound (gated on --expose-gc): under real GC pressure,
  fetching a 50 MB body with a 1 MB cap should grow heapUsed by < 10 MB.
  Run with `NODE_OPTIONS=--expose-gc bunx vitest run` to exercise; skipped
  by default so CI doesn't false-fail on GC timing.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:13 +08:00
Innei 4e4294f57e 🐛 fix(desktop): focus onboarding auth success state (#14694) 2026-05-13 02:57:13 +08:00
Arvin Xu 79152fa222 feat(markdown): user_feedback card + task card polish + Run now context menu (#14727)
*  feat(markdown): render <user_feedback> task prompt blocks as a card

`buildTaskRunPrompt` wraps the user's pre-run comments in a
`<user_feedback>` block alongside `<task>`. The Task plugin captured
`<task>` into a card, but `<user_feedback>` had no plugin and leaked
into the chat as raw XML. Because CommonMark only treats tag names
matching `[a-zA-Z][a-zA-Z0-9-]*` as html, the underscore in
`user_feedback` puts the opening/closing tags inside a `paragraph` as
plain text — so the new remark plugin walks paragraph children rather
than html nodes.

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

* 💄 style(task-card): drop standalone status row + Agent/Parent/Topics, inline semantic status badge

The status/Priority row, Agent, Parent and Topics fields aren't useful
when the task card is rendered inside the topic chat drawer (the drawer
already exposes that context). Move the task status to a compact badge
beside the identifier and reuse `taskDetail.status.*` for the label so
"scheduled" reads as "Scheduled" / "已排期".

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

* 💄 style(user-feedback): compact one-line header + left-border quote-style card

Slims the card down to a single 12px header line ("User feedback · N
comments") with a small 12px icon, and wraps the whole block in a
subtle fill + 2px left-border accent so it reads as a quoted aside and
visually separates from the task card that follows in the same user
message body.

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

* 💄 style(user-feedback): drop fill + radius, render as plain left-rail blockquote

The filled card competed visually with the unstyled task block that
sits beside it in the same message body. Reducing to a 2px left-rail
quote without background or border-radius lets both blocks read as
parts of the same user message.

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

* 💄 style(user-feedback): collapsible card with task-style head + bottom divider

Default-collapsed `<details>` whose summary mirrors the task title row
(32px icon + bold label + small count badge), with a bottom split-line
that doubles as a divider between the user feedback head and the task
card that follows in the same message body.

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

* 💄 style(user-feedback): strip default markdown details card chrome

@lobehub/ui Markdown applies bg + padding (0.75em 1em) + box-shadow +
border-radius to every nested <details>, which made the user_feedback
head read as a wide standalone card sitting awkwardly on top of the
inline task title. Override the chrome (with !important — the lib
selector wins on specificity otherwise) so the head sits flat in the
message body, with only the bottom split line separating it from the
task that follows. The lib's right-side disclosure chevron is kept.

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

* 💄 style(user-feedback): match task card's 12px symmetric divider spacing

Add a 12px margin-bottom so the gap below the user_feedback bottom rule
mirrors the 12px above it, matching the symmetric 12px the task card
already uses around its own internal divider. Without this, the
user_feedback rule sat flush against the T-31 row while the next rule
below T-31 had a 12px gap on both sides — visually uneven.

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

* 💄 style(task-card): drop status badge from task title row

The task drawer header and the schedule strip on the task detail page
already convey status; surfacing it again on the task card inside the
chat body just added noise. Drop the badge along with the now-unused
KNOWN_STATUSES / isKnownStatus / TaskStatusIcon / useTranslation
plumbing.

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

*  feat(tasks): add "Run now" item to task card context menu

Available only for backlog and completed tasks; mirrors the inbox-agent
fallback used by the detail-page Run Now action.

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

* 🐛 fix(topic-list): preserve `#` icon placeholder for heterogeneous agents

Returning null for the icon slot collapsed the row layout, so titles on
heterogeneous-agent topics (Claude Code, Codex, …) no longer aligned
with sibling rows. Render the same HashIcon with visibility:hidden so
the box is preserved without showing the glyph.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:13 +08:00
brone1323 ece409195a 🌐 i18n: add missing task-schedule and review strings to 16 locales (#14728)
🌐 i18n: add missing translations for task-schedule and review keys across 16 locales

Adds 14 missing i18n keys to all non-zh-CN locales (ar, bg-BG, de-DE,
es-ES, fa-IR, fr-FR, it-IT, ja-JP, ko-KR, nl-NL, pl-PL, pt-BR, ru-RU,
tr-TR, vi-VN, zh-TW):

chat.json (11 keys):
- taskSchedule.summary.everyNHoursHalfPast
- taskSchedule.summary.hourlyHalfPast
- taskSchedule.timezoneSearchEmpty
- taskSchedule.timezoneSearchPlaceholder
- workingPanel.review.revert (and 7 sub-keys)

plugin.json (1 key):
- builtins.lobe-task.apiName.setTaskSchedule

setting.json (2 keys):
- serviceModel.modelAssignments.title
- serviceModel.optionalFeatures.title

These were added in recent commits but the automated i18n sync had not
yet propagated them to non-Chinese locales.
2026-05-13 02:57:13 +08:00
Innei e56edab711 💄 style: polish desktop header icons, sidebar density, and task menus (#14724)
* 💄 style: shrink desktop header icons and tighten sidebar/home density

Switches all desktop header action icons from DESKTOP_HEADER_ICON_SIZE to
DESKTOP_HEADER_ICON_SMALL_SIZE, and tightens vertical gaps in the home
sidebar, recents list, and nav header layout for a denser, calmer look.

* ♻️ refactor(agent-tasks): migrate task menus and scheduler select to @lobehub/ui base-ui

- TaskPriorityTag / TaskStatusTag: replace antd Dropdown with base-ui
  DropdownMenu and adopt the ContextMenuItem / MenuInfo typings.
- useTaskItemContextMenu: drop the DOM data-attribute submenu marker in
  favour of an internal activeSubmenuRef tracked via onOpenChange.
- TaskScheduleConfig / SchedulerForm: swap @lobehub/ui Select for the
  base-ui Select and replace the custom SearchBar dropdownRender with
  antd Select showSearch for timezone filtering.

* ♻️ refactor(review): migrate review dropdowns to @lobehub/ui base-ui DropdownMenu

Swap the antd Dropdown trios (mode picker, base-ref picker, more menu) in
the agent working-sidebar Review pane for the base-ui driven DropdownMenu,
matching the recent task menus / scheduler migration. Also tighten the
sidebar header paddingInline from 16 to 4 to align with the surrounding
density polish.

* 🐛 fix(tasks): replace unsupported onOpenChange with onTitleMouseEnter in context menu
2026-05-13 02:57:13 +08:00
René Wang 3a4bd4a83d fix: Docs image (#14726)
fix: image
2026-05-13 02:57:12 +08:00
René Wang 19912fe02d 📝 docs: add May 11 weekly changelog (#14651) 2026-05-13 02:57:12 +08:00
Arvin Xu a40fe91fa4 🐛 fix(desktop): detect Windows npm .cmd shims for CLI agents (claude/codex/…) (#14720) 2026-05-13 02:57:12 +08:00
LobeHub Bot ae2afe860a 🌐 chore: translate non-English comments to English in cli-migrate (#14708)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 02:57:12 +08:00
Arvin Xu d3f8f760b2 ⬆️ chore: bump @lobehub/ui to 5.10.5 2026-05-13 02:57:12 +08:00
Arvin Xu 846e648fea 💄 style(review-panel): hover revert button to discard per-file working-tree changes (#14716)
 feat(review-panel): hover revert button to discard per-file working-tree changes

Add a hover-revealed Undo icon to each file row in the Review panel's
unstaged view. Clicking opens a Popconfirm; confirming runs a new
`git.revertGitFile` IPC that restores the file from HEAD (or unstages +
deletes when the path doesn't exist at HEAD, covering staged-add and
untracked entries).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:12 +08:00
Innei 0007984637 feat(documents): add optimistic create/delete and inline rename for document tree (#14714)
- Insert pending rows immediately on create folder/document, with
  optimistic SWR mutation that rolls back on server error
- Auto-focus rename input on newly created items via onPendingInserted
  callback
- Defer rename commits for pending rows until the server create resolves,
  then rename against the real row id
- Optimistic recursive delete closes the confirm modal instantly, removes
  target + descendants from the tree, and rolls back on failure
- Fix folder path canonicalization in ExplorerTree rename lookup
  (toCanonicalTreePath ensures trailing slash for folders)
- Export getItemPathFromEventPath for composed-path–based item resolution
- Add unit tests for toCanonicalTreePath and ExplorerTree event helpers
2026-05-13 02:57:12 +08:00
Arvin Xu eea742fd5f fix: update Task page placeholder copy (#14704)
* fix: update Task page placeholder copy

* fix: update Task page placeholder copy (en-US)
2026-05-13 02:57:12 +08:00
Innei ca9a781bdd 💄 style: standardize header action icon sizes (#14717)
💄 style: standardize header action icons to DESKTOP_HEADER_ICON_SMALL_SIZE

Unify icon sizing across sidebar and header action buttons by replacing
hardcoded sizes and DESKTOP_HEADER_ICON_SIZE with
DESKTOP_HEADER_ICON_SMALL_SIZE for consistent visual density.

Affected components:
- SideBarHeaderLayout back button
- ToggleLeftPanelButton default size
- BackButton default size
- Agent sidebar header chevron
- InboxButton notification icon
2026-05-13 02:57:12 +08:00
Innei 18b1c25371 feat(devtools): add dev-only feature flag override panel (#14565)
Add a client-side feature flag override panel that lives behind a
floating button in dev builds. Overrides are persisted to localStorage
and merged into useServerConfigStore.featureFlags so existing flag
consumers see the toggled value without any callsite changes.

The panel is gated by NODE_ENV plus a localStorage opt-in
(LOBE_DEV_FEATURE_FLAG_PANEL_ENABLED = "1"); prod builds tree-shake
the entire feature.
2026-05-13 02:57:12 +08:00
Arvin Xu 5ff4590fc1 🐛 fix(builtin-tool-task): expose lobe-task and add setTaskSchedule (#14713)
*  feat(builtin-tool-task): expose lobe-task to users and add schedule config

The task tool is now generally available — flip it from a scenario-only
internal tool to a user-toggleable recommended skill, and let the LLM
configure recurring execution (cron or heartbeat) via createTask / editTask.

- Drop `discoverable: false` + `hidden: true` from TaskManifest registration
- Add `lobe-task` to RECOMMENDED_SKILLS so it stays installed by default
- Remove the USER_HIDDEN_BUILTIN_TOOL_IDS allowlist (only contained lobe-task);
  update selectors and AgentTool to stop filtering it out
- Extend createTask / createTasks / editTask with `automationMode`,
  `schedulePattern`, `scheduleTimezone`, `heartbeatInterval`; editTask also
  accepts `maxExecutions`
- Route schedule columns through taskService.update and maxExecutions through
  taskService.updateConfig (server merges into tasks.config.schedule);
  refresh detail once at the end of editTask

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

* ♻️ refactor(builtin-tool-task): split schedule config into dedicated setTaskSchedule tool

editTask was the wrong place for schedule fields — schedule needs its own
verb so the LLM (and any future human-in-the-loop review) can audit cron /
heartbeat changes separately from generic field edits, and createTask should
stay a pure "make a task" verb without automation knobs.

- Drop automationMode / schedulePattern / scheduleTimezone / heartbeatInterval
  from createTask + createTasks, and drop them plus maxExecutions from editTask
- Add new `setTaskSchedule(identifier, automationMode?, schedulePattern?,
  scheduleTimezone?, heartbeatInterval?, maxExecutions?)` API with its own
  manifest entry, executor method, types, i18n key, and inspector
- Schedule columns still route through taskService.update; maxExecutions still
  routes through taskService.updateConfig (server merges into
  tasks.config.schedule) — same wiring, just moved into the dedicated tool
- Update systemRole to advertise setTaskSchedule + keep editTask description
  clean of schedule mentions

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:12 +08:00
AmAzing- eb924ec881 feat: add service model assignments settings (#14712)
*  Add default agent model setting

* 💄 Refine service model assignments UI

* 💄 Clarify optional service model features
2026-05-13 02:57:12 +08:00
Innei 51cefe0154 🐛 fix(desktop): reset pendingLoginMethod on auth failure/cancel paths (#14695)
* 🐛 fix(desktop): focus onboarding auth success state

* 🐛 fix(desktop): reset pendingLoginMethod on auth failure/cancel paths

Clear pendingLoginMethod in authorizationFailed, authorizationProgress
cancelled, and remoteServerSyncError handlers to prevent users getting
stuck without a Get Started path when a re-auth attempt fails but a
prior authorization is still valid.

* Delete src/routes/(desktop)/desktop-onboarding/features/LoginStep.test.tsx

---------

Co-authored-by: Innei <inbox@innei.in>
2026-05-13 02:57:12 +08:00
Innei cd3716d5e7 ♻️ refactor(spa): use __DEV__ define instead of process.env.NODE_ENV (#14696)
* ♻️ refactor(spa): use __DEV__ define instead of process.env.NODE_ENV

The Vite `__DEV__` define and its global type declaration are already
in place (plugins/vite/sharedRendererConfig.ts, src/types/global.d.ts).
Replace `process.env.NODE_ENV` checks across SPA-only files with the
`__DEV__` boolean so the bundler can statically eliminate dev-only
branches in production builds.

Server-side files (app/, server/, libs/next, libs/trpc, libs/better-auth,
envs, instrumentation) and modules that are also imported by Next.js
SSR pages (e.g. components/Loading/BrandTextLoading) are intentionally
left untouched to avoid runtime `__DEV__ is not defined` errors.

* fix(vitest): define __DEV__ and related constants for test environment

Vitest runs outside the Vite SPA build pipeline, so the __DEV__ define
injected by sharedRendererDefine was not available during tests. This
caused ReferenceError: __DEV__ is not defined in any test file that
transitively imports code using the __DEV__ constant.

Add a  block to vitest.config.mts that mirrors the SPA defines:
- __DEV__: true (test is not production)
- __CI__: mirrors process.env.CI
- __ELECTRON__/__MOBILE__: false (not testing platform-specific code)

* fix: replace missed isDevEnv reference with __DEV__ in AgentMockDevtools
2026-05-13 02:57:12 +08:00
Neko def9acee66 ♻️ refactor(agent-signal,prompts,database,builtin-tool-self-iteration): unified structure of service, unified tool, unified name and concepts (#14699) 2026-05-13 02:57:12 +08:00
Arvin Xu 948e48beba 🐛 fix(utils): cap image binary at 3.75MB so base64 payload stays under Anthropic 5MB limit (#14711)
* 🐛 fix(utils): cap image binary at 3.75MB so base64 payload stays under Anthropic's 5MB limit

Anthropic enforces the 5MB image cap on the base64-encoded payload, not the
binary file. Base64 inflates by ~4/3, so a 4.7MB binary file becomes 6.27MB
once encoded and trips `messages.*.content.*.image.source.base64: image
exceeds 5 MB maximum`. The previous MAX_IMAGE_BYTES of 5MB matched against
file.size, letting these images through compression untouched.

Lower the threshold to floor(5MB * 3/4) ≈ 3.75MB in both the frontend
canvas compressor and the server-side Sharp fallback so the progressive
shrink loop keeps going until the base64 payload is safely under the cap.

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

* 🐛 fix(utils): tighten image binary cap to 3MB for extra base64 headroom

Drop MAX_IMAGE_BYTES from 3.75MB (exact 5MB-base64 boundary) to a flat 3MB
so the encoded payload lands around 4MB — clear of any per-provider rounding
or jitter at the 5MB hard limit.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:12 +08:00
Arvin Xu 1ae774d55e 🐛 fix(tasks): scheduler, hotkey, comment & TodoList polish (#14707)
* 🐛 fix(portal): allow TodoList to scroll when expanded content exceeds max-height

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

* 🐛 fix(tasks): route 1–N hotkey to the open submenu instead of defaulting to status

The base-ui SubmenuTrigger doesn't propagate antd's `onTitleMouseEnter`, so
the hover ref in the right-click context menu never updated and every number
press fell back to the status submenu. The standalone Priority/Status tag
dropdowns also showed 1–N hints without binding any handler at all.

- Detect the currently open submenu via `data-popup-open` + a per-submenu
  `data-task-submenu` marker on the icon; numbers are ignored when no
  submenu is open.
- Install a keydown listener on TaskPriorityTag / TaskStatusTag while their
  dropdown is open so the hint numbers actually fire.

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

* 🐛 fix(scheduler): keep Continuous unchanged while editing Max runs

Clearing the Max runs input previously emitted maxExecutions=null, which the
form re-interpreted as Continuous and auto-checked the checkbox mid-edit
(disabling the input before the user could type the replacement number).

Track Continuous as its own state derived from the persisted prop. On clear
we hold the input empty locally without touching Continuous or emitting,
and unrelated emits fall back to the persisted value so they can't flip the
checkbox either.

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

* 💄 style(tasks): always show comment Send button and unify action labels

- Make the Send button visible by default in CommentInput / FeedbackInput
  (greyed out when empty) so the field reads as an input instead of vanishing
  affordance.
- Align topic action menu labels to Title Case (Stop Run / Open Run /
  Copy Topic ID / Copy Operation ID / Copy Link) to match the rest of the
  Action microcopy.

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

*  perf(scheduler): seed SchedulerForm from props once and own state locally

The previous prop→state useEffects re-synced every time the parent prop
updated, which during the async updateSchedule → refreshTaskDetail roundtrip
clobbered the user's in-flight edits with stale store values — felt awful
on rapid changes.

Drop the three sync useEffects and seed local state from props only at
mount via a lazy useState initializer. The form now owns its values
optimistically; cross-task safety comes from `key={taskId}` on the
parent so the form remounts cleanly when switching tasks.

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

* 💄 style(scheduler): Notion-style timezone picker — drop underscores, offset on the right

Underscored labels like 'America/New_York (EST/EDT, UTC-5/-4)' read poorly in
the dropdown. Split each option into `label` (underscore → space) and `offset`,
and render the row with the city on the left and a subtle gray offset on the
right, in line with how Notion's timezone picker presents this.

IANA `value` keeps the underscore so cron and Drizzle stay happy. Search now
filters by the human label only.

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

* 💄 style(scheduler): keep zone abbreviations in the timezone offset column

Show 'EST/EDT · UTC−5/−4' instead of just 'UTC−5/−4' so users can recognize
the zone by its common abbreviation alongside the offset.

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

* 💄 style(scheduler): drop awkward ':30' suffix from hourly summary

'Every hour:00' / 'Every 2 hours:30' read like glitched concatenations. Cron
storage always rounds to 0 or 30 minutes, so call out the non-zero case as
'at half past' and stay implicit on the top of the hour.

- Every hour
- Every hour at half past
- Every 2 hours
- Every 2 hours at half past

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

* 💄 style(scheduler): collapse advanced settings by default

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

*  perf(tasks): coalesce post-write refresh and add timezone search

Two follow-up fixes for the AgentTasks scheduler popover.

##### Optimistic schedule writes, single coalesced refresh

Rapid edits in the scheduler form (toggling daily/hourly/weekly, weekday
chips, time, etc.) each triggered `taskService.update` + a full
`internal_refreshTaskDetail` per call. With overlapping requests the
refreshes returned intermediate server state and bounced TaskTriggerTag /
summary text away from the user's latest choice.

- Add `#withCoalescedRefresh` on the task config slice: it tracks a per-task
  pending-writes count and only fires `internal_refreshTaskDetail` after the
  LAST in-flight write settles.
- Give `updateSchedule` an optimistic `internal_dispatchTaskDetail` so
  external readers see the new pattern/timezone/maxExecutions immediately.
- Route both `updateSchedule` and `setAutomationMode` through the coalescer.

##### Timezone picker — search input at the top

The dropdown had antd's implicit type-into-trigger search, which most users
miss. Add a `SearchBar` inside `dropdownRender`, filter the options against
label/value/offset locally, and show an empty state when nothing matches.

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

* 💄 style(scheduler): weekday chips only show background when selected

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

* 🐛 fix(tasks): dispatch optimistic schedule under nested 'schedule' field

`TaskDetailData` exposes schedule as `schedule.{pattern,timezone,maxExecutions}`,
not flat columns. The previous optimistic dispatch used the DB-style flat keys,
which broke type-check and would never reach the in-memory selectors.

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

* 💄 style(tasks): drop Cmd+Backspace shortcut on the Delete menu item

Header dropdown only advertised the hotkey (no handler), and the right-click
context-menu handler is gone too — keeps the visual claim honest and
removes the irreversible-by-keystroke footgun.

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

*  test(agent-signal): pin `now` in proposal activity tests to fixture window

Two cases relied on the real system clock; once today crossed the
fixture's default `expiresAt` (2026-05-12), pending proposals were
classified as expired and the assertions broke.

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

* 💄 style(tasks): hide '#' placeholder icon for heterogeneous agent topics

Claude Code / Codex topics aren't chat topics in the usual sense, so the
fallback HashIcon in the sidebar row reads as noise. Skip it when the
current agent has a heterogeneousProvider.

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

* 🧪 test(tasks): provide agentMap in TopicItem store mock

`isCurrentAgentHeterogeneous` walks through `currentAgentConfig` which
indexes `s.agentMap[agentId]`. Extend the mocked store state to include
an empty `agentMap` so the selector resolves to `undefined` (= not
heterogeneous) instead of throwing.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:12 +08:00
Arvin Xu 94e4ea6712 🐛 fix(cli): remove stale cron entry from generated man page (#14709)
* 🐛 fix(cli): remove stale cron entry from generated man page

The cron command was removed from program.ts but the generated man page
still listed it. Regenerated via bun run man:generate.

* 🔖 chore(cli): release 0.0.15

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:11 +08:00
Arvin Xu bfa28506af 💄 style(tool): add word wrap toggle to tool arguments display (#14706)
 feat(tool): add word wrap toggle to tool arguments display

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:11 +08:00
Rdmclin2 fdedc9697d 🐛 fix: sidebar add agent (#14693)
* fix: sidebar add agent and group error

* feat: add billboard cta
2026-05-13 02:57:11 +08:00
Innei 877052fc1f 💄 style(nav): unify ActionIcon sizing and improve TodoList encapsulation (#14692)
- Extract SIDEBAR_HEADER_ACTION_ICON_SIZE constant for consistent sidebar header ActionIcon sizing
- Pass size prop to ToggleLeftPanelButton
- Simplify Agent selector ActionIcon to use 'small' size preset
- Move layout wrapper styles from Body into TodoList root for better component encapsulation
- Increase Nav gap from 1 to 4 for proper spacing
2026-05-13 02:57:11 +08:00
YuTengjing 4490e3ef76 feat: inline skill auth in recommended task templates (#14676)
*  feat: support refreshing recommended task templates

- Add optional `refreshSeed` through `listDailyRecommend` API, service, and
  client; SWR key includes it so a refresh actually refetches.
- Frontend stores the seed in sessionStorage (via `useSessionStorageState`)
  so a new tab or next day returns to the default daily picks.
- Home Daily Brief shows a "Refresh" affordance on the Recommendations
  subtitle row.
- Fix first-card pinning when matched candidates < RECOMMEND_COUNT: fold
  the fallback pool in so seed reorders the whole batch instead of locking
  position 0 to a single-match template.

Linear: LOBE-8689

*  feat: resolve task-template icon priority

Render the task-template card icon as self > skill provider > interest > Sparkles. Skill icons read required[0] then optional[0], skipping unresolvable providers. URL icons render via @lobehub/ui Image, component icons keep the 28x28 tile.

*  feat: inline skill auth in task template card

Single click "Add task" is now the entire flow: the button stays put, and if a required skill is missing we chain its OAuth popups and create the task automatically. Unauthorized providers (required + optional) appear as compact inline rows above the footer; the provider that already drives the card's main icon is suppressed to avoid duplicating the same logo.

*  feat: add task template detail modal

Open a detail modal when the recommended task template card is clicked,
exposing the full instruction (markdown) plus inline skill auth and the
add-task action. Rename i18n `${id}.prompt` -> `${id}.instruction` to
align with the task table column, and write both `description` and
`instruction` when creating the task. Extract shared `TemplateBriefIcon`,
`useScheduleText`, `useTaskTemplateCreate` and `useVisibleAuthSpecs` so
the card and the modal share the same creation flow and OAuth chaining.

* 🐛 fix: missing Block import in TaskTemplateCard

*  feat: render recommended templates on empty Tasks page

Replace the bare "no tasks" placeholder with a hero landing: greeting,
enlarged inline composer (hero variant), and a 2-column grid of up to
10 recommended task templates. Plumbs a new `count` option through the
service, both routers, the client service, and the recommendations hook
so the home page keeps its 3-card layout while the empty Tasks page
asks for 10.

* 🐛 fix: type cast in resolveTemplateIcon test for unknown interest

* 🌐 i18n: update translations for task template empty-state and other namespaces
2026-05-13 02:57:11 +08:00
Innei 7349ad0f53 🐛 fix: replace ScrollShadow with ScrollArea to fix React #185 infinite render loop (#14689)
Migrate all ScrollShadow usages to ScrollArea (scrollFade) to eliminate
the effect → setState → render → effect cycle that caused React error
#185 (Maximum update depth exceeded) in the scroll overflow hook.

Affected components:
- StreamingMarkdown
- AgentCouncil AutoScrollShadow
- AssistantGroup ContentBlocksScroll
- Conversation Thinking

Fixes lobehub/lobehub#14650
2026-05-13 02:57:11 +08:00
LiJian 744059c1bc 🐛 fix(heteroFinish): trigger task lifecycle on cloud sandbox agent completion (#14681)
* 🐛 fix(heteroFinish): trigger task lifecycle transition on sandbox agent completion

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

* 🐛 fix(heteroFinish): guard onTopicComplete against duplicate finish calls

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 02:57:11 +08:00
LiJian aa4533e6cb 📝 docs(cloudHeteroContext): add sandbox persistence & gh push rules (#14682)
* 📝 docs(cloudHeteroContext): add sandbox persistence & gh push rules

Inject ephemeral-sandbox warnings and mandatory GitHub push rules into
the cloud CC context block so every Claude Code run knows:
- The sandbox is wiped after inactivity — local changes will be lost
- All code changes must be committed and pushed before task is complete
- Use gh CLI (pre-authenticated) for GitHub operations

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

* 🐛 fix(cloudHeteroContext): address review comments on sandbox persistence rules

- Remove gh push guidance (gh has no push subcommand; git push is correct)
- Gate gh-auth instructions behind githubToken availability to avoid
  auth-dependent commands failing in no-token sandbox runs

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

* 📝 docs(cloudHeteroContext): add git push auth fallback guidance

Tell CC that the sandbox has git credentials ready, but if git push
fails it can self-recover via:
1. gh auth setup-git (reconfigures git credential helper)
2. inline token URL as last resort (oauth2:$GITHUB_TOKEN@github.com)

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 02:57:11 +08:00
YuTengjing ea1d926de4 📝 docs(skills): frontmatter cleanup + argument-hint (#14683)
* 🔨 chore: control skill triggering via frontmatter flags

- Rename debug skill to debug-package (avoid confusion with debugging workflows)
- Add disable-model-invocation to add-* skills so they are manual-only
- Add user-invocable: false to reference/architecture skills so they auto-load only when relevant

* 🔨 chore: rename skill reference dirs to plural references

Align with the skill-creator convention (scripts/, references/, assets/).

* 📝 docs(skills): split oversized SKILL.md files and refine triggers

- upstash-workflow: 1126L → 189L, extract implementation / best-practices / examples references
- data-fetching: 854L → 613L, move parent-keyed-map walkthrough to references
- store-data-structures: 625L → 314L, extract types and reducer references
- upstash-workflow/cloud.md, version-release/release-notes-style.md: add TOCs
- linear: rewrite ALL-CAPS MUSTs into prose explaining why; mark user-invocable: false
- version-release: mark disable-model-invocation: true (manual /version-release only)
- debug-package: expand description with concrete trigger phrases and tokens

* 📝 docs(skills): regularize microcopy structure

Move language-specific guidelines into references/zh.md and references/en.md
so SKILL.md can point to them via the standard progressive-disclosure pattern.
Previously the two files sat next to SKILL.md but were not referenced anywhere,
making them invisible to Claude Code loading.

* 📝 docs(skills): move builtin-tool refs into references subdir

Aligns builtin-tool with the references/ layout used elsewhere
(microcopy, store-data-structures). 3 md files move, SKILL.md
links updated.

* 📝 docs(skills): broaden trigger descriptions for core skills

Adds concrete API names, file paths and natural-language phrases so
auto-triggering catches more relevant prompts. Touches zustand,
drizzle, i18n, react, typescript, modal, hotkey.

* 📝 docs(skills): add argument-hint to user-only skills
2026-05-13 02:57:11 +08:00
𝑾𝒖𝒙𝒉 dfe19323b8 🐛 fix(hotkey): remove redundant onClear to prevent double updateHotkey calls (#14663)
Previously, clicking the clear button on HotkeyInput triggered both
`onClear` and `onChange` (since HotkeyInput internally calls
`setHotkeyValue('')` which fires `onChange`). This caused two
concurrent requests to `updateDesktopHotkey` and showed two toast
messages (success/error) for a single user action.

Fix: remove the redundant `onClear` prop. HotkeyInput's clear action
already fires `onChange('')`, so the single `onChange` handler is
sufficient.

Co-authored-by: Innei <i@innei.in>
2026-05-13 02:57:10 +08:00
Innei 0e58fa7126 ♻️ refactor(web-onboarding): merge agent-marketplace identifier into onboarding tool (#14672)
* ♻️ refactor(web-onboarding): merge agent-marketplace identifier into onboarding tool

Drop the standalone `lobe-agent-marketplace` builtin tool and fold its
`showAgentMarketplace` / `submitAgentPick` APIs into `lobe-web-onboarding`
so onboarding exposes a single tool identifier.

- Move marketplace API entries (with humanIntervention/renderDisplayControl)
  into WebOnboardingManifest; extend WebOnboardingApiName.
- Compose AgentMarketplaceExecutionRuntime inside WebOnboardingExecutionRuntime;
  the client WebOnboardingExecutor now owns showAgentMarketplace/submitAgentPick
  with telemetry hooks. Drop the separate client/server executor + runtime files.
- Merge marketplace Inspector / Intervention / Render maps under the
  web-onboarding identifier. Remove AgentMarketplace* entries from
  builtin-tools registries and from the builtin web-onboarding agent's
  plugins list.
- Switch customInteractionHandlers to route by (identifier, apiName) so
  the marketplace picker handler fires only on `showAgentMarketplace`.
- Drop the `lobe-agent-marketplace` fallback string in
  OnboardingActionHintInjector; match by apiName only.
- Rename plugin/setting locale keys under `lobe-web-onboarding.*`.

* 🐛 fix(onboarding): reserve scroll headroom for agent marketplace overlay

- Add a footerSlot spacer in ChatList matching the marketplace panel height so the latest message can be scrolled into view above the absolute overlay.
- Nudge the marketplace overlay inset by 2px to hide subpixel border seams.
- Document turn output order in the onboarding system role to avoid trailing filler text after tool calls.
2026-05-13 02:57:10 +08:00
YuTengjing b79c5d8e70 🐛 fix: reject inactive OIDC access (#14674)
* 🐛 fix: reject inactive OIDC access

* 🐛 fix: honor expired OIDC bans

* 🐛 fix: decouple OIDC inactive error from tRPC

*  test: fix OIDC auth type checks
2026-05-13 02:57:10 +08:00
Arvin Xu f591f7ac34 💄 style(web-onboarding): add Render for saveUserQuestion & showAgentMarketplace (#14667)
 feat(builtin-tool-web-onboarding): add Render for saveUserQuestion + showAgentMarketplace

Tool messages for `saveUserQuestion` and `showAgentMarketplace` previously
fell back to the raw Arguments/Response table once the call resolved
because neither API had a Render registered. Wire both up:

- `saveUserQuestion`: new Render mirroring the Intervention's detail-card
  style — agent identity (emoji + name), full name, and interests chips —
  rendered conditionally per the fields actually saved.
- `showAgentMarketplace`: reuse the existing `SubmitAgentPick` Render.
  After the picker submits, `customInteractionHandlers` rewrites the
  `showAgentMarketplace` tool message's `pluginState` to the same
  `{ summaries, installedAgentIds, ... }` shape, so the card grid
  renders without a new component.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:10 +08:00
Arvin Xu 3f43e69fa6 ♻️ refactor(knowledge-base): share RAG runtime across client/server via KnowledgeBaseSearchService (#14673)
* ♻️ refactor(knowledge-base): share runtime across client/server via KnowledgeBaseSearchService

Extract a server-side `KnowledgeBaseSearchService` (semanticSearchForChat
fan-out + getFileContents branching + groupAndRankFiles) so both the lambda
chunk router and the builtin tool server runtime orchestrate RAG through one
implementation. Wire the builtin knowledge-base tool to the shared
ExecutionRuntime in the package by moving the client executor to
`src/client/executor/` and registering a thin server runtime factory.

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

* ♻️ refactor(knowledge-base): move PG 23505 handling into adapters, restore executor path

ExecutionRuntime is dual-end so it cannot detect PG error codes — only the
server adapter can. Move the unique-constraint check there and translate the
lambda router's `FILE_ALREADY_IN_KNOWLEDGE_BASE` sentinel in the client
adapter, so the runtime's generic catch surfaces the human-readable message
on both code paths. Restore `src/executor/` as a top-level sibling of
`src/client/` to match the convention of every other builtin tool.

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

* ♻️ refactor(knowledge-base): collapse executor into /client, drop ./executor export

The executor is just another client-only adapter (alongside Inspector and
Render) — no reason for it to sit at the package root with a dedicated
subpath. Move it under `src/client/executor/`, re-export from
`src/client/index.ts`, drop the `./executor` entry from package.json, and
update the consumer to import from `@lobechat/builtin-tool-knowledge-base/client`.

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

*  test(knowledge-base): cover KnowledgeBaseSearchService

13 unit tests across both methods:
- getFileContents: docs_* direct read, missing doc, file_* via findByFileId,
  parseFile fallback, parse failure surfaces as error entry, missing file,
  mixed batch.
- semanticSearchForChat: chunk grouping + relevance ranking, BM25 skip when
  no knowledgeIds, knowledgeIds → fileIds expansion, vector/BM25 isolated
  failure capture (preserves the other path's results + structured
  rejections), full failure path.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:10 +08:00
Arvin Xu 314619d798 ♻️ refactor(bot): close activator bypass + converge device-access checks (#14664)
* ♻️ refactor(aiAgent): introduce deviceToolRegistry as single source of truth

Centralise "what counts as a device tool" into one module so the next
device-tool addition only touches one file. Removes the hardcoded
`new Set(['local-system', 'remote-device'])` from `deviceToolAudit.ts`,
which had drifted from `LocalSystemManifest.identifier` /
`RemoteDeviceManifest.identifier` imports elsewhere.

Foundation for the LOBE-8768 activator-bypass fix landing next.

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

* 🐛 fix(aiAgent): block activator from bypassing canUseDevice gate

External bot senders could still reach the owner's machine by having the
LLM call `lobe-activator.activateTools(["lobe-remote-device"])`, because
`enableCheckerFactory.allowExplicitActivation` short-circuits before the
canUseDevice rule, and the engine's `manifestSchemas` always contained
the full builtin list (LOBE-8768 B1).

Fix by filtering builtin manifests **physically** through
`buildAllowedBuiltinTools` at both feed-points (ToolsEngine input and
the activator-discovery `toolManifestMap`). When `canUseDevice=false`,
the device manifests no longer exist in either map, so explicit
activation cannot resolve them — the rule-layer gate becomes
defense-in-depth instead of the sole barrier.

Validates with the prod incident's repro path: an external sender's
`<available_tools>` no longer advertises `lobe-remote-device`, and an
activator call to enable it returns "not found".

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

* ♻️ refactor(bot,messenger): centralise isOwner derivation in buildBotContext

The same fail-closed expression
`!!operatorUserId && senderExternalUserId === operatorUserId` was
duplicated across `BotMessageRouter.onNewMention`, `.onSubscribedMessage`,
the DM catch-all, and `MessengerRouter.dispatchToAgent` — four sites,
one rule, one place to silently regress.

Route all four through `buildBotContext`. The helper now owns the
fail-closed contract referenced by `ChatTopicBotContext.isOwner`'s
docstring, so adding the next platform/router can't accidentally
default to "trusted when in doubt".

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

* 🐛 fix(aiAgent): apply device filter post-merge across all manifest sources

The previous fix only filtered the `builtinTools` source. An installed
plugin or a Skill/Klavis manifest declaring
`identifier: 'lobe-remote-device'` would still survive in
`manifestSchemas` and reach `toolManifestMap` via either
`getEnabledPluginManifests` or the direct ingest loops in
`aiAgent/index.ts` — letting an external bot sender activate the device
identifier through the activator.

Two changes close the gap:

  1. `ServerAgentToolsEngineConfig.excludeIdentifiers` — applied **after**
     combining plugin + builtin + additional manifests in
     `createServerToolsEngine`. `createServerAgentToolsEngine` passes
     `DEVICE_TOOL_IDENTIFIERS` whenever `canUseDevice` is false.

  2. `isManifestIngestAllowed` in `aiAgent.execAgent` — a single
     identifier guard reused at every `toolManifestMap` / `toolSourceMap`
     write (engine-returned plugin manifests, lobehub-skill loop,
     klavis loop). New ingest points inherit the wall automatically.

New test pins the regression: a plugin + an additional manifest
spoofing the device identifiers are dropped from `availablePlugins`
when `excludeIdentifiers` is set.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:10 +08:00
Arvin Xu d9fe275a4c ♻️ refactor(task): snapshot agent model into task.config at create time (#14670)
*  feat(task): snapshot agent model into task.config at create time

Pin the assignee agent's current model/provider into task.config when a
task is created so later changes to the agent's default model don't
silently affect already-created tasks. On first run, backfill the
snapshot for tasks created before this change.

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

* 🐛 fix(task-runner): fall back to inbox agent when task has no assignee

`TaskRunnerService.runTask` previously threw `BAD_REQUEST` for any task
without `assigneeAgentId`, which broke runs created without `--agent`.
Resolve and persist the user's built-in inbox agent instead, surfacing
an `INTERNAL_SERVER_ERROR` only if that resolution itself fails.

Picked from #14671 (closes once landed).

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

* ♻️ refactor(task): collapse router orchestration into TaskService

Move multi-step task verbs out of the TRPC router into `TaskService`:
`createTask`, `cancelTopic`, `deleteTopic`, `runReview`, `updateStatus`,
`previewSubtaskLayers`, `runReadySubtasks`. The router keeps only input
validation + error wrapping; the tool runtime now shares the same
`createTask` path (was duplicating the model snapshot + parent
resolution).

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

* 🚨 ci: fix tsgo errors from TaskService extraction

`runReadySubtasks` router was rebuilding the `data` payload via a
conditional spread, which forced TS to infer a discriminated union that
broke `result.data.skipped` access in the integration test. Pass the
service result straight through so `skipped` stays a single optional
field. Also cast the stubbed `taskService` in the tool runtime unit
tests to bypass strict structural typing — same pattern the other
dep stubs already use.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:10 +08:00
YuTengjing 03b3e2fc12 🔥 chore: drop task template tracking (#14666)
* 🔥 chore: drop task template tracking

The recommendation surface is about to be redesigned, so the analytics
funnel added in #14517 is being removed up front. A fresh tracking
schema will land alongside the redesigned UI.

- Delete `analytics.ts` plus its test and the tracking-focused
  `TaskTemplateCard.test.tsx`.
- Drop `RecommendedTaskTemplate` / `TaskTemplateRecommendationSource` /
  `TaskTemplateFallbackPool` and revert the service to plain
  `TaskTemplate[]`.
- Strip impression, dismiss, create-clicked/result and
  skill-connect-clicked/result calls from `TaskTemplateCard.tsx`, while
  keeping the createTask + navigate-to-task flow from #14540.
- Remove `recommendationBatchId` / `userInterestCount` / `onCreated`
  plumbing from `useDailyBriefRecommendationsUI`,
  `DailyBriefRecommendationsView`, and the card props.
- Revert `useSkillConnection` to the pre-tracking variant (no
  onConnectResult / SkillConnectionResult).

* 🐛 fix: remove created template from recommendation cache

After #14540 changed the create-task flow to auto-navigate to
`/task/{id}`, removing the `onCreated` plumbing from #14517 in the same
sweep meant the SWR recommendation cache was never mutated on success.
Combined with the server-side `recordCreated` being a no-op and
`listDailyRecommend` not excluding created IDs, returning to Home
showed the same recommendation as actionable again — letting users
trigger duplicate scheduled tasks from the same template.

Re-add the minimal cache-eviction plumbing (no analytics):

- TaskTemplateCard exposes `onCreated` and calls it on success
- useDailyBriefRecommendationsUI shares `removeTemplateFromList` for
  both dismiss and created flows
- DailyBriefRecommendationsView passes `onCreated` through
2026-05-13 02:57:10 +08:00
YuTengjing b0ee35dd35 🐛 fix: drop unreachable aihubmix empty-apiKey test (#14669)
* 🐛 fix: drop unreachable aihubmix empty-apiKey test

The `should return empty array when API key is missing` test asserts a
contract that doesn't hold: RouterRuntime.models() constructs the
underlying runtime via the OpenAI-compatible factory before calling
modelsOption, and the factory throws InvalidProviderAPIKey on empty
apiKey at construction time — so aihubmix's own `if (!apiKey) return []`
short-circuit can never actually fire.

Just delete the dead test. The defensive guard in aihubmix's modelsOption
stays as intent documentation. Also tighten an implicit-any in the
adjacent `should normalize model_id field to id` test.

* 🔥 chore: drop dead empty-apiKey guard in aihubmix modelsOption

* 💄 style: tighten aihubmix apiKey assertion to string
2026-05-13 02:57:10 +08:00
Zhijie He a1fac45b3a 💄 style: add reasoning_effort support for Grok 4.3 (#14642)
* style: add reasoning_effort for Grok 4.3

* style: remove grok 4.1 series & grok-imagine-image-pro (Model retirement)

style: remove grok 4.1 series & grok-imagine-image-pro (Model retirement)

style: remove grok 4.1 series & grok-imagine-image-pro (Model retirement)
2026-05-13 02:57:10 +08:00
Arvin Xu e0ead0c47a 💄 style: increase chat topic title length (#14659)
* 💄 style: increase chat topic title length

- bump initial topic title slice from 20 to 40 chars
- bump dev fallback slice from 30 to 40 chars
- bump thread title slice from 20 to 40 chars
- raise LLM summary title prompt limit from 50/10w to 80/15w

* 💄 style: bump topic/thread title slice from 40 to 80 chars

Align slice limits with the LLM summary prompt cap (80 chars) so the
initial visible title is no shorter than what the summarizer can return.
2026-05-13 02:57:10 +08:00
Bianzinan f4de472e82 fix(aihubmix): use full models endpoint to return complete model list (#14511)
* fix(aihubmix): use full models endpoint to return complete model list

The /v1/models endpoint at api.aihubmix.com returns only per-user-group
models (~256). The new endpoint at aihubmix.com/api/v1/models returns
the complete catalog (800+). Fetch from the full endpoint directly.

* fix(aihubmix): normalize model_id to id from full models endpoint

The https://aihubmix.com/api/v1/models endpoint uses `model_id` instead
of `id`. Map it to `id` before passing to processMultiProviderModelList
to prevent toLowerCase() errors and empty model list.

* fix(aihubmix): add apiKey guard, AbortController timeout, and better error messages

- Extract apiKey with runtime guard to fail fast when key is missing
- Add AbortController with 10s timeout to prevent indefinite hanging
- Include response body in error message for easier debugging
- Add APP-Code header comment pointing to docs
- Expand tests: mock global fetch, cover missing key / HTTP error / network error / AbortError cases

* fix(aihubmix): add field mapping adapter and fix timeout scope

Address review feedback from #14511:

- Update AiHubMixModelCard interface to reflect the new endpoint schema
  with full JSDoc (model_id, desc, types, features, input_modalities,
  context_length, max_output, pricing.cache_read/cache_write)
- Add mapAiHubMixModel() to adapt API response fields to LobeHub model
  card fields before passing to processMultiProviderModelList:
    desc             -> description
    model_name       -> displayName
    context_length   -> contextWindowTokens
    max_output       -> maxOutput
    types            -> type  (llm/t2t->chat, image_generation/t2i->image,
                               video/t2v->video, tts, stt, embedding,
                               rerank/reranking->rerank)
    pricing.cache_read  -> pricing.cachedInput
    pricing.cache_write -> pricing.writeCacheInput
    features(tools/function_calling) -> functionCall
    features(thinking)               -> reasoning
    features(web)                    -> search
    input_modalities(image)          -> vision
- Fix timeout scope: move clearTimeout into the finally block so the
  AbortController stays active during response.json() body read, not
  just during the initial fetch() call
- Update baseURL from https://api.aihubmix.com to https://aihubmix.com
  to match official integration docs (https://docs.aihubmix.com/cn/api/Aihubmix-Integration)
- Strengthen normalize test: assert list.some(m => m.id === 'some-model')
  instead of just Array.isArray to detect normalization failures
- Add field-mapping test using vi.spyOn on processMultiProviderModelList
  to assert that all adapted fields are passed correctly

* fix(aihubmix): filter out unsupported rerank types to prevent chat fallback

- Remove rerank/reranking from TYPE_MAP; they have no LobeHub AiModelType
  equivalent and would silently fall back to 'chat' in processModelCard
- Add UNSUPPORTED_AIHUBMIX_TYPES set and filter before mapAiHubMixModel()
- Add regression test asserting rerank/reranking models are excluded and
  llm models still pass through

---------

Co-authored-by: Bianzinan <bianzinan@users.noreply.github.com>
2026-05-13 02:57:10 +08:00
Innei 5f14b7e463 feat(activator): require activation reason (#14597) 2026-05-13 02:57:09 +08:00
Innei a9eb904cf4 🐛 fix(onboarding): skip marketplace on early exit, drop CJK in prompts (#14598)
* 🐛 fix(onboarding): skip marketplace on early exit, drop CJK examples in prompts

Honor the user's wish to leave: when the onboarding agent detects a true
early-exit signal in any phase, persist what is known, send a brief
farewell, and call finishOnboarding directly. The marketplace handoff is
mandatory only on normal Phase 4 / Summary completion. Previously the
spec forced the agent to invent categoryHints from environment cues
when discovery was thin, producing noisy recommendations for users who
explicitly asked to stop.

- Replace systemRole §Early Exit with a 4-step flow (no marketplace, no
  summary), and remove the trailing "respect their time" rationale that
  contradicted the new policy.
- Update toolSystemRole turn-protocol exception accordingly; mark
  persistence as best-effort (do not retry on failure) since the
  Pre-Finish Checklist is overridden on early exit.
- Update OnboardingActionHintInjector L101/L127 hints to match the new
  flow, and append an EXCEPTION clause to the Summary not-opened hint
  so a true exit signal in Summary skips the marketplace too.
- Strip CJK example phrases from prompt text; rely on the LLM's
  multilingual recognition with "equivalents in any language" hints.

* 🔨 refactor(FollowUpChips): remove unused consume function and reset editor state on chip click
🔨 style(InterventionBar): remove overflow hidden from container style

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

* 🐛 fix(ci): align FollowUpChips test with removed consume and increase timeout for PGlite cold-start

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-05-13 02:57:09 +08:00
Neko 1374fd29e8 feat(agent-signal,server,prompts): consolidate in self-review implemented (#14657) 2026-05-13 02:57:09 +08:00
Arvin Xu 31e9130cf0 💄 style(hetero-agent): read-only SubAgent threads with breadcrumb header and thread switcher (#14658)
*  feat(hetero-agent): read-only SubAgent threads with breadcrumb header and thread switcher

- Hide chat input on SubAgent threads (execution is driven by the parent agent) and replace it with an inline read-only hint
- Render the hint as the last item inside the virtual list so it scrolls with messages instead of being pinned to the viewport bottom
- ChatList exposes a new `footerSlot` prop that VirtualizedList injects as a synthetic trailing data item
- Header now shows `topic / thread` breadcrumb; thread title is a popover trigger that lists sibling threads in the same topic for one-click switching
- Hide the working-directory tag while inside a thread — directory switching doesn't belong in this read-only view
- Unify user-facing strings to "SubAgent" (badge, hint, open/close labels)

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

* 💄 style(chat-input): soften queue tray preview borders

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

* 🐛 fix(conversation): scrollToBottom lands on the true last VList item

scrollToBottom targeted displayMessages.length - 1, which leaves any
trailing synthetic items (spacer, SubAgent footer hint) below the
viewport. In SubAgent threads this kept atBottom = false after the
BackBottom click or auto-scroll, so the button appeared stuck.

VirtuaScrollMethods now exposes getTotalCount, which VirtualizedList
fills from the live data length (messages + spacer + optional
footerSlot) via a ref. scrollToBottom uses that to scroll to the real
last index.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:09 +08:00
Arvin Xu 84b802cf96 💄 style(chat-input): show skeleton in action bar while config is loading (#14656)
* 💄 style(chat-input): show skeleton in action bar while config is loading

Before agent / group config hydrates, action buttons read DEFAULT_*
fallbacks and the send button would dispatch against a not-yet-ready
target. Add an `isConfigLoading` prop on DesktopChatInput that swaps the
action bar + send area for skeleton placeholders. The chat page passes
`agentSelectors.isAgentConfigLoading`, group chat passes
`agentGroupSelectors.isGroupsInit`. The editor itself stays usable so
users can start typing immediately.

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

* 💄 style(home,i18n): use 已阅 for brief confirm/confirmDone in zh-CN

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

* 🐛 fix(home): use 确认完成 for brief.action.confirmDone in zh-CN

confirmDone signals the terminal transition (task marked complete),
not just dismissing the brief, so 已阅 loses the semantic distinction
from `confirm`. Use 确认完成 to match the EN intent ("Confirm complete").

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

* 🐛 fix(home): use "Confirm complete" for brief.action.confirmDone in en-US

Match the semantic distinction the call site relies on:
`confirm` is dismiss-only for recurring scheduled runs, while
`confirmDone` marks the terminal completion transition. The test
mock already used "Confirm complete" — align the source defaults.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:09 +08:00
Arvin Xu e261a6ff98 💄 style(home): add Recommendations module with hetero agent action library (#14645)
*  feat(home): add Recommendations module with hetero agent action library

Introduce a `Recommendations` section that renders above the existing daily-brief
task templates. The module is driven by an extensible action registry with per-action
eligibility checks; the first registered actions surface "Add Claude Code agent" and
"Add Codex agent" cards on desktop when the matching local CLI is detected and the
user hasn't added that hetero agent yet.

- New `src/features/Recommendations/` with action types, registry, hetero-agent
  factory, eligibility hook, parallel CLI detection (SWR-cached) and card UI.
- Extract `createHeterogeneousAgent` from `useCreateMenuItems` into a shared
  `useCreateHeteroAgent` hook so the sidebar menu and Recommendations card share
  one creation path (create + refresh sidebar + navigate to chat).
- `DailyBrief` now renders `<Recommendations />` in place of the standalone
  template-only section; visibility is driven by the new
  `useRecommendationsVisible` hook.
- Add `recommendations.*` i18n keys to the `home` namespace (default + zh-CN +
  en-US dev preview).

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

* 💄 style(home): polish Recommendations card with brand avatar and tighter copy

Use brand Avatar icons with rounded square shape, drop the duplicate title, and tighten copy (Coding Agent tag, Add Agent CTA).

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:09 +08:00
Rdmclin2 3fb8daaa08 🔨 chore: optimize system bot (#14649)
* feat: add already consumed alert

* feat: support slack send slack commends  emphemeral in channel

* chore: handle parse commands imperial

* fix: slack messenger callback ok

* feat: add messager connectionId per user

* fix: add userId to webhookbody

* fix: test case
2026-05-13 02:57:09 +08:00
Arvin Xu 49c3d7e367 feat(hetero-agent): support AskUserQuestion tools for claude code (#14639)
*  feat(hetero-agent): AskUserQuestion MCP server + bridge skeleton (LOBE-8725 step 1+2)

Foundation for LOBE-8725 — interactive AskUserQuestion via local MCP. CC's
built-in tool short-circuits in `-p` mode, so we host an in-process MCP
server that exposes an equivalent `ask_user_question` tool. The handler
blocks until the consumer submits an answer (or the 5min deadline / op
shutdown fires), surfacing a structured `agent_intervention_request` /
`agent_intervention_response` round-trip on the existing event stream.

Added in this commit:

- `packages/heterogeneous-agents/src/askUser/`
  - `AskUserBridge` — per-op pending map with timeout / cancel / progress
    keepalive support; emits an async-iterable of outbound events
  - `AskUserMcpServer` — process-wide HTTP/Streamable MCP server,
    `?op=<id>` query routes via `AsyncLocalStorage` →
    `onsessioninitialized` → sessionId↔opId map; tool handler hands off
    to the matching bridge and pumps `notifications/progress` back to CC
    every 30s as wire-level keepalive (required for >5min waits, see
    spike notes)
  - `constants.ts` — shared tool/server names + the stable `apiName`
    the adapter rewrites to
  - Unit tests cover bridge lifecycle (resolve / cancel / timeout /
    progress / event stream) and an end-to-end MCP probe via
    `StreamableHTTPClientTransport`

- `packages/agent-gateway-client/src/types.ts` — wire-level
  `agent_intervention_request` / `agent_intervention_response` event
  variants + payload interfaces. Re-exported through the package barrel.

- `packages/heterogeneous-agents/src/adapters/claudeCode.ts` — when CC's
  `tool_use` carries `mcp__lobe_cc__ask_user_question`, the adapter
  rewrites `apiName` to `askUserQuestion` so the renderer routes on a
  clean domain key. Identifier stays `claude-code`. Applied to both the
  main-agent and subagent paths for symmetry (subagent ask isn't
  expected today, but doesn't hurt).

- `src/server/routers/lambda/aiAgent.ts` — Zod input schema for
  `aiAgent.heteroIngest` extended with the two new event types so the
  CLI sandbox can forward them through the server.

No producer wiring yet — Steps 3-5 plug this into Electron main, the
renderer executor, and the new UI.

*  feat(hetero-agent): wire AskUserQuestion MCP into Electron CC driver (LOBE-8725 step 3)

Plug the Step 1 skeleton (`AskUserMcpServer` + `AskUserBridge`) into the
desktop Claude Code spawn path. CC's local MCP `ask_user_question` tool now
goes live during real prompts; renderer-submitted answers route back via
new IPC.

Changes
- `apps/desktop/src/main/modules/heterogeneousAgent/types.ts` — add
  optional `mcpConfigPath` to `HeterogeneousAgentBuildPlanParams` so
  controller-managed temp configs flow into the driver.
- `apps/desktop/src/main/modules/heterogeneousAgent/drivers/claudeCode.ts`
  — append `--mcp-config <path>` when provided. Disallowed-tools pin
  stays so CC's built-in AskUserQuestion remains off (avoids double-
  registration of the same tool name).
- `apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts`
  - Lazy-singleton `AskUserMcpServer` started on first claude-code prompt
    (de-duped concurrent first-callers via in-flight promise).
  - Per-op `setupInterventionForOp(opId, sessionId)`: registers an
    `AskUserBridge`, writes `os.tmpdir()/lobe-cc-mcp-<opId>.json` with
    `alwaysLoad: true` so CC eager-loads the tool (1-hop call, no
    ToolSearch detour — see LOBE-8725 spike), pumps `bridge.events()`
    into the existing `heteroAgentEvent` broadcast.
  - Cleanup paths: exit handler `await intervention.cleanup()` settles
    pending MCP handlers + unlinks the temp config; pre-spawn errors
    short-circuit the same cleanup so we don't leak bridges on
    `buildSpawnPlan` / trace-session failures.
  - `before-quit` stops the MCP server (in addition to killing CC
    processes).
  - New `@IpcMethod() submitIntervention({ operationId, toolCallId,
    result?, cancelled?, cancelReason? })` — renderer side will dispatch
    answers / cancellations through this in Step 4/5.
  - codex unchanged — bridge setup is gated on `agentType === 'claude-code'`.
- `src/services/electron/heterogeneousAgent.ts` — renderer-side proxy
  for `submitIntervention`.
- New `claudeCode.test.ts` covers the four driver-arg paths
  (`--mcp-config` presence, ordering vs `--resume`, AskUserQuestion stay
  disallowed). Existing 28 controller tests still pass.

What still doesn't run end-to-end
- The renderer `heteroExecutor` doesn't consume `agent_intervention_request`
  yet — events go through the broadcast but the chat store ignores them.
- No UI to render the intervention card or to call `submitIntervention`.
Both lands in Steps 4/5 next.

*  feat(hetero-agent): correlate intervention with tool message + renderer handler (LOBE-8725 step 3.5+4)

Bridge now uses the caller-supplied toolCallId (CC's `claudecode/toolUseId`
from MCP `_meta`) instead of a random UUID, so the
`agent_intervention_request` event references the same id as the existing
tool message on the renderer side.

Renderer-side `heteroExecutor` learns the new event:

- Added `persistInterventionRequest(...)` next to `persistToolResult` —
  stamps `pluginState.askUserQuestion` (apiName + identifier + questions
  parsed from `arguments` + deadline + status='pending' + toolCallId)
  onto the matching tool message via `messageService.updateToolMessage`.
- New branch in `handleStreamEvent` for `'agent_intervention_request'`:
  defers behind `persistQueue` (so it lands AFTER `persistToolBatch`
  populates `toolMsgIdByCallId`), then mirrors the same pluginState onto
  the in-memory message via `internal_dispatchMessage` so the UI lights
  up immediately — no fetchAndReplaceMessages round-trip needed.
- The eventual `tool_result` for the same toolCallId hits the existing
  `tool_result` branch unchanged: it overwrites `pluginState` with
  whatever the result carries (typically undefined for our MCP tool, so
  `pluginState.askUserQuestion` clears and the intervention UI yields to
  the regular Render).

Bridge tests cover the new contract:
- caller-supplied toolCallId becomes the wire correlation key
- duplicate-toolCallId pendings reject loudly so two-handler clobbers
  surface immediately

153 package tests + 1167 desktop main tests + 51 hetero executor tests
still green; type-check clean.

*  feat(claude-code): AskUserQuestion intervention render component (LOBE-8725 step 5)

Dedicated Render for the synthetic `askUserQuestion` apiName the adapter
rewrites the local MCP `mcp__lobe_cc__ask_user_question` tool to. Lives
under CC's render registry so the existing chat tool-detail flow picks
it up automatically — no changes to the conversation framework.

- New `AskUserQuestionItem` / `AskUserQuestionArgs` /
  `AskUserQuestionPluginState` types (mirrors CC's own
  AskUserQuestion schema verbatim).
- `ClaudeCodeApiName` gains an `AskUserQuestion = 'askUserQuestion'`
  member so the renders / inspectors / streamings registries can key
  off the same enum value.
- `client/Render/AskUserQuestion/index.tsx` is the component:
  - `pluginState.askUserQuestion?.status === 'pending'` → renders the
    questions form (Select for single-select, CheckboxGroup for
    multi-select), a 5-min countdown ticking once a second, Submit /
    Skip buttons. Reads `operationId` via `messageOperationMap` so we
    can route through `heterogeneousAgentService.submitIntervention`.
  - Otherwise → renders the questions as muted captions plus the
    final answer text from `content`. Surfaces a warning when the
    tool_result was an error (timeout / cancelled / session ended).
  - Submit button stays disabled until every question has a
    selection; Skip always enabled (sends `cancelled: true`).
- `ClaudeCodeRenders[ClaudeCodeApiName.AskUserQuestion]` registers
  the new component.

What this does NOT do
- Doesn't touch `BuiltinToolInterventions` — the form is rendered
  inside the regular tool body (Render slot), not the canonical
  intervention slot. Cleanest for now: the framework intervention
  flow assumes `submitToolInteraction` store actions, which would
  fight our IPC path. We can refactor onto that surface later if
  CC grows additional interactions (approval, file picker).
- Doesn't translate strings — i18n in a follow-up.

Type-check clean. Step 6 (real desktop e2e via CC) is next.

*  feat(claude-code): render AskUserQuestion form during pending state (LOBE-8725 step 5 follow-up)

Step 5 registered the Render component but stopped at the registry — the
chat tool-detail still returned the loading placeholder while
`isToolCalling` was true, so users only ever saw a spinner during the 5
min intervention window.

Detect `pluginState.askUserQuestion?.status === 'pending'` (only set on
CC + apiName=askUserQuestion tool messages) and route to the registered
builtin Render inline before the placeholder branch. Once the
intervention resolves, the eventual `tool_result` clears
`pluginState.askUserQuestion` and the regular Render takes over.

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

*  feat(hetero-agent): wire regenerate / continue for hetero runtime (LOBE-8519 follow-up)

LOBE-8519 left two TODOs in `generationSlice` where hetero runtime
silently fell through to client mode — regenerate would secretly hit the
agent's underlying LLM, and continue would synthesize a fake "please
continue" turn that confuses CC / Codex.

- regenerateMessage: re-create the assistant row branched off the same
  user message, resolve resume sessionId (drop on cwd mismatch), then
  spawn a child `execHeterogeneousAgent` op so Stop only kills the
  executor, not the parent regenerate op. Mirrors sendMessage's hetero
  branch.
- continueGenerationMessage: hetero CLIs have no continue primitive —
  each prompt is a fresh user turn — so bail out instead of polluting
  the session.
- continueGenerationMessage: gateway mode now branches a server-side
  resume run instead of falling through to client.

Surfaced while testing CC AskUserQuestion end-to-end on the
LOBE-8725 branch (regenerating after an answered question went through
the wrong runtime).

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

* 🐛 fix(local-testing): electron-dev.sh boots on macOS bash 3.2

Two bugs surfaced when invoking the local-testing helper from a fresh
session on macOS:

- `find_project_pids` / `do_stop` end with `grep -v '^$'` whose exit
  code propagates through `pipefail`. With `set -e`, an empty pid set
  silently kills the whole script — `do_start` reported success, no
  Electron, no error. Trail with `|| true`.
- `setsid` is GNU coreutils, not on macOS. Fall back to plain `bash -c`;
  process-tree teardown still works because `expand_descendants` walks
  the tree directly.

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

* 🐛 fix(hetero-agent): per-session MCP transport for sequential ops (LOBE-8725)

`AskUserMcpServer` shared a single `StreamableHTTPServerTransport` across
every CC subprocess. The SDK transport latches `_initialized=true`
after the first `initialize`, so the second op's CC subprocess sees
`Invalid Request: Server already initialized` (400) and reports the
`lobe_cc` server as `failed`. From the model's POV the MCP tool is
absent — it falls back to ToolSearch, can't find anything, and
verbalizes the question instead.

Refactor to the canonical multi-tenant pattern: one transport + one
`McpServer` per session, looked up by the SDK-managed `mcp-session-id`
header. New transports are minted on the first POST without a session
id (must be an `initialize` request); subsequent requests route via
the stored map; `onsessionclosed` cleans up.

The first run of any process still works as before — this only matters
once a second op spins up. Added a 3-op sequential regression test
that fails on the old single-transport implementation and passes now.

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

* ♻️ refactor(claude-code): move AskUserQuestion onto canonical Intervention surface (LOBE-8725)

Step 5's first cut shoehorned the pending form into the Render slot and
drove submit/skip with a custom `pluginState.askUserQuestion.status`
field, which forced three layers of glue:

- `Tool/Detail` had to bypass the loading placeholder via an
  identifier+apiName hardcode so the form would surface during
  `isToolCalling`
- The executor had to `messageService.getMessages → replaceMessages`
  after `agent_intervention_request` to drag the freshly-created tool
  row into in-memory state (the framework's own `tool_end →
  fetchAndReplaceMessages` only fires after the user answers)
- The executor also had to `associateMessageWithOperation` for the tool
  row so the form could look up the running CC op for IPC

All three were patches around skipping the canonical surface. This
commit moves AskUserQuestion onto `pluginIntervention.status='pending'`
and the `BuiltinToolInterventions` registry, which the framework
already drives end-to-end:

- `packages/builtin-tool-claude-code/src/client/Intervention/AskUserQuestion.tsx`
  — pure form, no IPC, no store reads. Resolves through the standard
  `onInteractionAction({type:'submit'|'skip'|'cancel'})` callback.
- `Render/AskUserQuestion` shrinks to the answered/aborted view only;
  the framework hides Render while pending, so no status switching.
- New `Inspector/AskUserQuestion` shows a compact "askUserQuestion · {header}"
  chip in the inline tool body, matching the rest of CC's tools.
- Registries: `ClaudeCodeInspectors`, `ClaudeCodeRenders`, and the new
  `ClaudeCodeInterventions` all key off `ClaudeCodeApiName.AskUserQuestion`;
  `BuiltinToolInterventions` gains a `[ClaudeCodeIdentifier]` entry.

Hetero needs a different action handler than `submitToolInteraction`
(which spawns `executeClientAgent` — wrong for a CC subprocess that's
already blocked on an MCP call). Two thin pieces wire that:

- `submitHeteroIntervention` (chat store) — sets
  `pluginIntervention` via `optimisticUpdateMessagePlugin` (which
  already syncs DB + in-memory + parent-assistant `tools[].intervention`
  in one shot), then forwards the answer through
  `heterogeneousAgentService.submitIntervention` IPC. Operation lookup
  walks the tool message's `parentId` to hit the assistant's
  `messageOperationMap` entry — drops the explicit
  `associateMessageWithOperation` call from the executor.
- `customInteractionHandlers.isHeteroInteractionIdentifier` flags
  `ClaudeCodeIdentifier`; `Tool/Detail/Intervention` short-circuits
  there before reaching the existing `submitToolInteraction` path.

Executor change collapses to one line:
`optimisticUpdateMessagePlugin(toolMsgId, { intervention: { status: 'pending' } })`.
The post-intervention refresh, the associate call, and the
`persistInterventionRequest` helper all go away.

Removed:
- `AskUserQuestionPluginState` type (custom field is gone)
- `Tool/Detail` `askUserPending` inline-render branch
- Executor `messageService.getMessages + replaceMessages` round-trip
- Executor `associateMessageWithOperation` for tool rows
- `persistInterventionRequest` helper

Verified end-to-end against a real CC subprocess on desktop:
- Inline body shows the new Inspector chip; pending form lives in the
  bottom InterventionBar (canonical surface)
- Submit ships answer through MCP, CC continues with structured result
- Skip flips status to `rejected`, framework's RejectedResponse
  shows "User skipped"; CC receives isError and falls back to text
- `mcp_servers.lobe_cc.status === 'connected'` on a 3rd sequential op
  (the per-session transport fix from the previous commit)
- `alwaysLoad: true` still produces 1-hop calls (no ToolSearch hop)

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

* 💄 style(claude-code): inline numbered option cards for AskUserQuestion intervention (LOBE-8725)

Select dropdown was the wrong primitive — it hides options behind an extra
click and doesn't read like a question to answer. CC's underlying tool is
1-4 questions × 2-4 options, so the whole option set always fits inline.

- Each option renders as a clickable card: numbered chip (1/2/3/4) +
  bold label + secondary description on a single row. Hover tints the
  background; selected state lights up `colorPrimary` on both the chip
  and the card outline so the pick is unmistakable at a glance.
- Multi-select (`q.multiSelect`) toggles instead of replacing, with a
  "(multi-select)" hint in the question header.
- Multi-question support gets a proper visual hierarchy: each question
  past the first sits below a dashed divider, headed by a `Q1/N` tag
  + the original `q.header` chip. The `Q*/N` lets the user track
  progress without counting.
- Inspector picks up the question count too: now shows
  "askUserQuestion · {first header} +N" when multiple are queued.

Verified end-to-end on desktop with a CC-driven 2-question prompt
(4-option + 3-option). Both selections feed back to CC as a single
"User answers" payload, CC echoes both picks in its continuation.

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

*  feat(claude-code): tabbed multi-question + draft + timeout fallback for AskUserQuestion (LOBE-8725)

- Multi-question forms now use a top tab strip; single question renders inline.
- Picking a single-select option auto-advances to the next unanswered question.
- Drafts persist to tool message `pluginState.askUserDraft` so picks survive
  remount / HMR; new `setInterventionDraft` action on the chat store dispatches
  the pluginState patch.
- Timeout fallback: when the 5-min countdown expires, auto-submit option 1 for
  every unanswered question instead of letting the bridge time out into a
  cancelled isError — model gets a structured answer it can act on.
- Visual: selected option now uses filled `colorPrimaryBg` + right-aligned
  check icon; index chip stays neutral.

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

* 🐛 fix(hetero-agent): synchronously unlink temp mcp.json on app quit (LOBE-8725)

The async exit-handler cleanup raced Electron's main-process teardown and
left `lobe-cc-mcp-<opId>.json` files in `os.tmpdir()` after every quit. Sync
unlink in the quit hook is the only reliable guarantee.

Also handle SIGTERM / SIGINT — `before-quit` only fires on user-driven Cmd+Q
or `app.quit()`, not on external kills (test harness, OS shutdown).

Verified by manual test: pending askUserQuestion forms now leave zero
residue after both Cmd+Q and SIGTERM paths.

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

*  feat(claude-code): persist structured AskUserQuestion answers + Q&A render (LOBE-8725)

Submit now writes the structured `{ questionText: pickedLabel(s) }` payload
to the tool message's `pluginState.askUserAnswers` (in-memory + DB merge), so
Render no longer has to scrape the bridge's prose `User answers:` content.

Render shows one Q&A block per question — header + question + a checkmark
card per picked option (multi-select fans out into multiple rows). Falls
back to a `—` placeholder when answers are missing (older messages or
skipped flows), and keeps the existing `pluginError` warning for cancel /
no-answer paths.

Also surfaces the answers in the Skill state inspector tab, which was
previously empty for completed askUserQuestion messages.

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

*  test(hetero-agent): cover synchronous quit cleanup of AskUserQuestion temp configs (LOBE-8725)

Locks down the regression fixed in c0de0cdb7c — async exit-handler cleanup
losing to Electron's main-process teardown. Four cases: `before-quit`
(Cmd+Q / `app.quit()` path), `SIGTERM` (test harness / OS shutdown),
`SIGINT` (Ctrl-C), and idempotency (already-deleted temp file must not
throw on the second pass).

`process.on` and `process.exit` are stubbed in the signal-path tests so the
controller's listener attaches to a spy, not the test runner's process —
otherwise we'd leak a real SIGTERM listener every test.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:09 +08:00
Neko 71ddedaa83 ️ perf(agent-signal,prompts,types,database,server): fixed many minor self-review issues, harden the structure, verified with eval (#14647) 2026-05-13 02:57:09 +08:00
Arvin Xu 60a127b1e5 💄 style(copyable-label): wrap long tool-call params instead of truncating (#14640)
* 💄 style(copyable-label): wrap long values instead of truncating

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

* ♻️ refactor(copyable-label): make wrap an opt-in via Descriptions prop

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

* 🐛 fix(descriptions): omit GridProps wrap to avoid type collision

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:09 +08:00
Arvin Xu b85a1ad851 💄 style: format tool execution time as Xmin Ys instead of X.Y min (#14641)
🐛 fix: format tool execution time as `Xmin Ys` instead of `X.Y min`

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:08 +08:00
Arvin Xu 7daed90d0e 🐛 fix(model-runtime): enrich stream parse errors with provider/model context (#14636)
*  feat(model-runtime): enrich stream parse errors with provider/model context

When the OpenAI / Anthropic SDK iterator throws (most often a JSON
SyntaxError on a malformed SSE chunk — e.g. an upstream response with an
illegal backslash escape), `convertIterableToStream` previously only
surfaced `message`/`name`/`stack`. Downstream error logs (agent-gateway
errors table) end up with just "Bad escaped character in JSON at
position 160050" and no way to correlate which provider/model produced
it or whether the same offset keeps recurring.

This change threads optional `{ provider, model }` context through
`convertIterableToStream` / `readableFromAsyncIterable` and enriches the
FIRST_CHUNK_ERROR payload with:

- `provider` / `model` so triage can group identical upstream failures
- `parsePosition` extracted from V8 JSON SyntaxError messages
- `causeName` / `causeMessage` when `error.cause` is set (many wrapped
  errors carry the actionable detail in `cause` and the bare triplet
  drops it)

Threaded through OpenAI/Responses/Anthropic stream handlers, which all
already receive `payload` containing provider/model.

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

* 🐛 fix(model-runtime): walk error.cause for parsePosition + JSON-safe payload

Two review findings on #14636:

1. Wrapped SyntaxErrors lost their parsePosition. Provider SDKs commonly
   rethrow `JSON.parse` failures wrapped in their own error class
   (e.g. `APIError(cause: SyntaxError)`), so the outer `error.name` is
   no longer `'SyntaxError'` and the previous check skipped extraction
   for the exact case this enrichment was meant to diagnose. Now
   `extractParsePosition` walks both the outer error and any `Error`
   cause, and accepts any error whose message still carries the
   `"JSON at position N"` signature even if the SyntaxError name was
   lost in wrapping.

2. Cause cloning could blow up the entire diagnostic path.
   `structuredClone` succeeds on values that `JSON.stringify` later
   throws on (BigInt, circular refs), so a non-Error cause carrying
   either would surface as `payload.cause = clonedObject`, then the
   outer `JSON.stringify(payload)` would throw inside the catch handler,
   and the FIRST_CHUNK_ERROR chunk never gets emitted. Replaced with
   `safeJsonStringify` (BigInt → string, cycles → `[Circular]`) and
   route the cause object through `toJsonSafe` so the returned shape is
   always plain JSON.

Added tests for both: a wrapped APIError(cause: SyntaxError) yields
parsePosition, and a cause containing both BigInt and a circular ref
still emits a parseable error chunk.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:08 +08:00
Arvin Xu 0babdcfc00 🐛 fix(home): strip markdown links from daily-brief input placeholder (#14635)
The daily-brief hint will start carrying `[name](url)` markdown links so
the AI can resolve referenced entities when the user submits via the
hint. The placeholder layer is the only consumer that wants the visible
label without the link syntax — extract a small `stripMarkdownLinks`
util and apply it at `InputArea/index.tsx` only. `useSend` continues to
forward the raw hint, so the agent still receives the link in the
outgoing message.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:08 +08:00
YuTengjing d445a89c85 🐛 fix: consume visual content parts in server runtime (#14637) 2026-05-13 02:57:08 +08:00
Arvin Xu 3c8101128e feat(bot): gate device tools by sender identity (#14634)
*  feat(bot): gate device tools by sender identity (LOBE-8715)

External users who @-mentioned a bot ran the agent as the bot owner and
could call LocalSystem / RemoteDevice tools — a confused-deputy hole that
let any group member indirectly read/write the owner's machine.

- `ChatTopicBotContext` carries `senderExternalUserId` + `isOwner`
- `BotMessageRouter` / `MessengerRouter` compute `isOwner` at the entry
  point (fail-closed when `settings.userId` is missing)
- `resolveDeviceAccessPolicy` maps sender identity to
  `{ canUseDevice, reason }`; trusted-list branch is reserved for future
  work without engine changes
- `AgentToolsEngine` gates `LocalSystem` + `RemoteDevice` on `canUseDevice`
- `RemoteDeviceManifest.systemRole` is no longer injected on
  external-sender turns — closes the device-list information leak
- Per-call audit log (`lobe-server:agent-device-tool-audit`) at the
  dispatch site records sender, isOwner, reason, identifier, apiName

Fixes LOBE-8715

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

* 🚨 chore(bot): replace `any` on botContext / botPlatformContext with concrete types

Picks up the existing `BotPlatformContext` (`@lobechat/context-engine`)
and `ChatTopicBotContext` (`@lobechat/types`) — both already exported —
instead of the inherited `any` placeholders on:

- `OperationCreationParams.{botContext, botPlatformContext, deviceAccessPolicy}`
- `InternalExecAgentParams.botPlatformContext`
- `RuntimeExecutorContext.botPlatformContext`

`deviceAccessPolicy.reason` is now `DeviceAccessReason` instead of `string`.

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

* 🔒 fix(bot): clear activeDeviceId when canUseDevice=false (LOBE-8715)

The previous patch gated `LocalSystemManifest` in the engine's enabledToolIds,
but `buildStepToolDelta` re-injects local-system from `state.metadata.activeDeviceId`
on every step regardless of whether the engine excluded it. Auto-activation
in `aiAgent.execAgent` populated `activeDeviceId` whenever
`(discordContext || botContext) && onlineDevices.length === 1`, so an
external bot sender with one device online could still get local-system
tools against the owner's device.

- `aiAgent/index.ts`: skip `activeDeviceId` derivation entirely when
  `canUseDevice` is false. `deviceSystemInfo` short-circuits naturally on
  `if (activeDeviceId) {...}`, so no extra change needed there.
- `RuntimeExecutors.ts`: belt-and-suspenders — if
  `state.metadata.deviceAccessPolicy.canUseDevice` is false, swallow
  `activeDeviceId` before passing to `buildStepToolDelta`, so a future
  plumbing bug at the source can't reopen the bypass.

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

* 🔒 feat(bot): allow device tools on personal-scope platforms (WeChat) (LOBE-8715)

Not every bot platform can identify an owner. WeChat's LobeHub integration
encodes every inbound thread as 1:1 (`packages/chat-adapter-wechat/src/adapter.ts:465`)
and its settings schema has no `userId` field, so `isOwner` is structurally
false on every WeChat turn. The previous policy denied every WeChat call
with `bot-owner-not-configured` — fail-closed but unusable.

This commit treats platforms whose integration is structurally personal-
scope as trusted. WeChat is the only member today; LINE is intentionally
excluded because its adapter handles group/room threads even though its
schema also lacks `userId` — those must be fixed at the schema layer
before being whitelisted.

- New `bot-personal-platform` reason in `DeviceAccessReason`
- `PERSONAL_SCOPE_BOT_PLATFORMS = new Set(['wechat'])`
- Personal-scope check sits AFTER `isOwner` so a future WeChat schema
  with a `userId` field still resolves as the more specific `bot-owner`
- Tests: WeChat without isOwner → allow; WeChat with isOwner=true → still
  `bot-owner` (more specific wins); regression guard ensuring Discord /
  Slack / Telegram / Feishu / Lark / QQ / LINE keep going through the
  standard isOwner gate

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

*  test(engine): opt existing device gate tests into canUseDevice=true (LOBE-8715)

The `LocalSystem` / `RemoteDevice` enable rules now short-circuit on
`canUseDevice` (default `false`), so tests that exercise the
engine-internal gates (`runtimeMode`, `deviceContext`, `clientRuntime`)
must explicitly pass `canUseDevice: true` — otherwise they assert the
right behavior for the wrong reason or fail outright (e.g. the desktop
RemoteDevice-suppression case the reviewer flagged).

- All `LocalSystem` / `RemoteDevice` / `LocalSystem + RemoteDevice` /
  `clientRuntime === "desktop" (Phase 6.4)` blocks now set
  `canUseDevice: true`.
- The "disable RemoteDevice in bot conversations" test was repurposed:
  the dropped `!isBotConversation` clause is now subsumed by `canUseDevice`,
  so for a trusted bot caller (canUseDevice=true) RemoteDevice DOES surface.
  The original intent — block when caller is untrusted — is captured in
  the new `canUseDevice gate` block.
- New `canUseDevice gate` describe block asserts:
    1. `canUseDevice=false` blocks LocalSystem even on a desktop caller
    2. `canUseDevice=false` blocks RemoteDevice with proxy configured
    3. Omitting `canUseDevice` → fail-closed default (deny)

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

*  test(execAgent): set isOwner=true on device auto-activation tests (LOBE-8715)

These pre-existing tests model an owner using the bot through Discord and
assert that `activeDeviceId` auto-populates when one device is online.
After LOBE-8715, `activeDeviceId` is gated on `canUseDevice` from
`resolveDeviceAccessPolicy`, so a `botContext` without `isOwner: true`
resolves to `bot-external-sender` → `canUseDevice=false` →
`activeDeviceId=undefined`.

Filling out the `botContext` mocks with `isOwner: true` (plus the other
required fields the type now demands) preserves the tests' original
intent while exercising the new gate.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:08 +08:00
YuTengjing 9982de3a5c 🐛 fix: store onboarding interests as keys (#14624) 2026-05-13 02:57:08 +08:00
Arvin Xu 7f6fdd7c14 🔥 chore(web-crawler): remove WeChat URL rules (#14633)
Drop the `weixin.sogou.com` and `mp.weixin.qq.com` rules from the crawler
URL ruleset since they are no longer needed.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:08 +08:00
LobeHub Bot d13f2e3ad8 🌐 chore: translate non-English strings to English in apps/cli, apps/device-gateway, and apps/desktop scripts (#14626)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 02:57:08 +08:00
LiJian 7675bd9fb5 🐛 fix(hetero-agent): sync new-step assistant across replicas (#14631)
* 🐛 fix(hetero-agent): sync new-step assistant across replicas

* 🐛 fix(hetero-agent): tighten new-step assistant fallback

* fix: slove the test
2026-05-13 02:57:08 +08:00
LiJian 457d112a74 🐛 fix: remove the old cron job from lobehub (#14630)
* fix: remove the old cron job from lobehub

* fix: add some ts back
2026-05-13 02:57:08 +08:00
LiJian 6595961e5a 🐛 fix: refresh content baseline from DB on every ingest call (#14603)
* 🐛 fix: refresh content baseline from DB on every ingest call

Vercel serverless routes consecutive batches to different Lambda
instances. A warm replica's in-memory `accumulatedContent` only
reflects batches it processed; it has no visibility into batches
handled by other replicas.

The failure pattern (worst when a repo is selected, since CC makes
tool calls early):

1. Lambda A — batch 1 (text "你好!...") → flushBatchContent writes
2. Lambda B — batch 2 (text "...任务。") → restores from DB, appends,
   writes longer text to DB
3. Lambda A — batch 3 (tools_calling only, warm state) → its stale
   `accumulatedContent` = batch-1 text → persistMainToolBatch Phase 1
   writes `{ tools, content: stale-short-text }` → OVERWRITES the
   correct longer DB value → content truncated at "你"

Fix: re-read the current assistant message from DB at the start of
every `ingest()` call. Since `flushBatchContent` writes at the end of
every batch, DB is authoritative. The refresh gives each Lambda the
latest flushed baseline, so new text in the current batch extends
the correct full string.

Cost: one extra `findById` round-trip per warm ingest call.

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

*  feat: auto-inject GitHub OAuth token into CC sandbox

Previously the GitHub token was only resolved when repos were selected
AND GITHUB_CRED_KEY was explicitly configured in the agent config —
so CC running without pre-selected repos had no GitHub access and had
to ask the user for a PAT manually.

Changes:
- aiAgent/index.ts: always try to resolve the token using key 'github'
  (standard LobeHub OAuth connector default); GITHUB_CRED_KEY still
  overrides. No longer guarded behind topicRepos.length > 0.
- sandboxRunner.ts: new buildCredsSetupScript() runs before CC starts:
    mkdir -p ~/.creds
    printf 'GITHUB_ACCESS_TOKEN=%s\n' <token> > ~/.creds/env
    gh auth login --hostname github.com --with-token
  Writes ~/.creds/env in the same format as injectCredsToSandbox(["github"])
  so CC can source it in sub-shells. Creds step runs before repo clone step.
- cloudHeteroContext.ts: system prompt now tells CC that GITHUB_TOKEN is
  set, gh CLI is pre-authenticated, and ~/.creds/env has GITHUB_ACCESS_TOKEN
  with the source/auth recipe for sub-shell usage.

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

* 🐛 fix: adopt max-length content on DB refresh to guard flushBatch retry

The unconditional DB overwrite in ingest() broke the retry contract:
if flushBatchContent threw after events were already marked in
processedKeys, a retry on the same warm instance would read the stale
(shorter) DB value and wipe the in-memory chunks — which processedKeys
would then skip, losing them permanently.

Fix: only adopt the DB value when it is LONGER than in-memory.
This preserves both behaviours:
- Multi-replica stale (the original fix): DB has more content from
  another replica → dbContent.length > in-memory → adopt DB. ✓
- flushBatchContent retry on same Lambda: DB still has the old shorter
  value, in-memory has the correct accumulation → keep in-memory. ✓

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 02:57:08 +08:00
Arvin Xu ae8f9cfb27 🐛 fix(hetero-agent): disable Claude Code AskUserQuestion to avoid auto-decline (#14629)
* 🐛 fix(hetero-agent): disable Claude Code AskUserQuestion to avoid auto-decline

CC's built-in AskUserQuestion self-injects an `is_error: "Answer questions?"`
tool_result inside the CLI in `-p` non-interactive mode before the host can
surface the questions, so the model falls back to plain-text prompting after
a wasted round-trip. Add `--disallowedTools AskUserQuestion` to both spawn
sites (desktop driver + lh hetero exec) so the model goes straight to text.

To be revisited once a local MCP-backed replacement is wired to LobeHub's
intervention UI.

* ♻️ refactor(hetero-agent): share CC base args, opt-in partial deltas

- Promote CLAUDE_CODE_BASE_ARGS in `@lobechat/heterogeneous-agents/spawn` to
  the canonical source of truth for invariant CC CLI flags (`-p`, stream-json
  IO, `--verbose`, `--disallowedTools AskUserQuestion`); export it so the
  desktop driver can compose on top instead of duplicating.
- Pull `--include-partial-messages` out of the base. It's now a
  `SpawnAgentOptions.includePartialMessages` flag, off by default so
  `lh hetero exec` standalone/sandbox runs don't pay for delta noise they
  don't render. The desktop driver opts in (chat bubble streams live).
- Permission mode stays caller-specific: desktop hardcodes bypassPermissions
  (always user-mode), the package keeps its root-vs-user branch for cloud
  sandbox.

* 🎨 style(hetero-agent): pass spawn-args builders an options object

Positional list grew to four args with mixed types — switch to a single
`BuildSpawnArgsParams` object so call sites read by field name and adding
future per-agent flags doesn't push every other caller around.
2026-05-13 02:57:08 +08:00
Arvin Xu 96165e453a 🐛 fix(local-system): guard readFile against binary blobs and oversized output (#14602)
* 🐛 fix(local-system): guard readFile against binary blobs and oversized output

Previously `lobe-local-system.readFile` would happily decode any extension
as UTF-8 and return the entire content. Reading a 27KB base64-encoded git
bundle blew up the next LLM call to 3.28M tokens / 416s and triggered a
DB rollback. The default 200-line cap was bypassed because base64 was a
single very long line.

Add four layers of protection in `readLocalFile`:
- Hard-reject extensions outside the text-readable + special-parser
  whitelist with a structured error pointing the agent at runCommand.
- Sniff the first 8KB and refuse files that look binary (null bytes or
  >30% non-printable chars).
- 10MB hard size cap before the file is read into memory.
- Cap each returned line at 8K chars and total output at 500K chars,
  with `truncated` / `linesTruncated` flags surfaced in the result.

Refs LOBE-8703.

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

* 🐛 fix(file-loaders): preserve UTF-16 text files without a BOM in binary sniffer

The binary sniffer rejected UTF-16LE/BE files that lacked a BOM because
their alternating 0x00 bytes tripped the null-byte heuristic. `TextLoader`
already has a `detectUtf16NoBom` heuristic for these Windows-style exports;
extract it to a shared `detectUtf16` util and run it in the sniffer before
the null-byte check, decoding with the matching variant for the printable
ratio test instead of declaring the file binary.

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

* 💄 style(local-system): render WriteFile new files as a unified diff

Switch the WriteFile render from a syntax-highlighted preview to a
synthesized "new file" unified diff via PatchDiff, matching the
EditLocalFile visual. Markdown files keep their rendered preview.

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

*  test(local-system): exercise readFile / readFiles end-to-end

The previous LocalFileCtr.readFile / readFiles tests deep-mocked
node:fs/promises and @lobechat/file-loaders. Since the controller is a
thin pass-through to readLocalFile, the assertions ended up testing
shell internals (already covered in packages/local-file-shell), and
broke as soon as readLocalFile gained new pre-flight checks.

Move them into a sibling LocalFileCtr.readFile.test.ts that runs
against a real tmpdir + real file-loaders, so adding more upstream
guards no longer requires touching this suite.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:08 +08:00
YuTengjing 521566bdb7 feat: add user activity business hook (#14601) 2026-05-13 02:57:08 +08:00
Hardy ab7b9e3e69 ♻️ refactor(siliconcloud): sync models with API, fix duplicates, adjust reasoning params (#14464)
* ♻️ refactor(siliconcloud): sync models with API, fix duplicates, adjust reasoning params

* 🐛 fix(siliconcloud): fix GLM-4.7 checkModel casing to match model ID
2026-05-13 02:57:08 +08:00
AmAzing- fa55b3fb25 🌐 i18n: update banner copy translations (#14623) 2026-05-13 02:57:08 +08:00
AmAzing- e300766046 💬 i18n: remove trailing punctuation from banner titles (#14622) 2026-05-13 02:57:08 +08:00
YuTengjing 9b032f0773 feat: add Gemini 3.1 Flash-Lite provider cards (#14604) 2026-05-13 02:57:08 +08:00
YuTengjing 629213189b ♻️ refactor: remove model extend param options (#14607) 2026-05-13 02:57:08 +08:00
René Wang f38f0c258b 📝 docs: add intro and screenshot to task scheduler changelog (#14585) 2026-05-13 02:57:07 +08:00
Neko 38b793f41b 🐛 fix(database,utils,userMemories): should perfer to use paradedb.match(...) instead of hardcoded normalizer (#14590) 2026-05-13 02:57:07 +08:00
Arvin Xu 11ec59b8c8 🐛 fix(database): attach error listeners to Neon/Node pools to prevent Lambda crash (#14606)
* 🐛 fix(database): attach error listeners to Neon/Node pools to prevent Lambda crash

NeonPool (and NodePool) inherit pg.Pool semantics: when a backend connection
drops on an idle client the pool emits 'error'. With no listener Node
escalates that into uncaughtException — on Vercel this killed the entire
Lambda process (exit 129) and produced a 1805-crash avalanche in 5 minutes,
spiking Neon connection count from 30 to 330+ as half-closed sockets
accumulated (LOBE-8704).

Primary fix: attach `.on('error', ...)` to both pool variants in
`packages/database/src/core/web-server.ts` so the error is logged but
swallowed; the pool recovers on its own per pg docs.

Defense in depth: register `uncaughtException` / `unhandledRejection`
handlers in `instrumentation.ts` (gated to nodejs runtime) so any future
unhandled error doesn't take down the process either.

Refs: https://node-postgres.com/apis/pool#error

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

* 🔧 chore: drop process-wide uncaughtException handler

Per review on #14606: the catch-all listener in instrumentation.ts swallowed
every uncaughtException / unhandledRejection — not just NeonPool errors —
leaving the process in an undefined state instead of letting the platform
restart it, and would mask future production bugs.

LOBE-8704 is fully addressed by the targeted pool listeners in
packages/database/src/core/web-server.ts; the broad backstop is unnecessary
and unsafe.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:07 +08:00
sxjeru 867e22a90e 💄 style: Add new DeepSeek-V4 models (#14110)
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: YuTengjing <ytj2713151713@gmail.com>
2026-05-13 02:57:07 +08:00
Arvin Xu 4bfd434552 🐛 fix: gateway client-tool pluginState + drop redundant Exit code: 0 tail (#14596)
* 🐛 fix(agent-runtime): forward pluginState through gateway client tool result

Gateway-mode client tool results lost the `state` field at three points:
the toolResult Zod schema didn't declare it (silently stripped by safeParse),
the ToolResultPayload interface didn't carry it, and projectToExecutionResult
didn't return it. As a result the "技能状态" tab was always empty for tools
dispatched via Agent Gateway, even though clients send `state` correctly and
non-gateway paths persist it as `pluginState`.

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

* 🐛 fix(prompts): suppress redundant `Exit code: 0` tail in command result

For successful runs, "Command completed successfully." already conveys
the same signal — appending "Exit code: 0" was just noise the LLM had
to skim past. Non-zero exit codes (130 SIGINT, 137 OOM, etc.) keep the
line so the diagnostic information remains available.

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

* 🐛 fix(prompts): treat non-zero exit code as command failure in result header

`success` is the envelope ("the service responded") and `exitCode` is the
command's own status — they're independent. With `success: true` +
`exitCode: 137` the prior format rendered "Command completed successfully."
on top of a SIGKILL/OOM, lying to the LLM.

Now the header is derived from both: any non-zero exit folds the message
into the failure branch as "Command failed with exit code N[: error]".
The trailing "Exit code: N" line is gone — the same info now lives in the
header, so success rendering is also free of the redundant zero tail.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:07 +08:00
sxjeru 307cd8e523 🐛 fix(gemini): handle zero cachedContentTokenCount in usage conversion (#14567)
Co-authored-by: YuTengjing <ytj2713151713@gmail.com>
2026-05-13 02:57:07 +08:00
Arvin Xu a2750098f4 💄 style(topic): add copy session ID to topic dropdown menu (#14595)
 feat(topic): add copy session ID to topic dropdown menu

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:07 +08:00
Arvin Xu 12e37f1e46 feat: home daily brief with linkable welcome + paired input hint (#14589)
*  feat: home daily brief with linkable welcome + paired input hint

Add a per-user "daily brief" surface to the home page. A cron-driven
backend (in the cloud repo) writes paired { welcome, hint } entries
into Redis under `aiGeneration:home_brief:{userId}`. This change exposes
that data through:

- `RedisKeys.aiGeneration.homeBrief` key builder
- `home.getDailyBrief` lambda router query that reads the cached payload
- `homeService.getDailyBrief` client and `useHomeDailyBrief` hook with
  shared rotating index via `useSyncExternalStore`
- `WelcomeText` runs a custom typewriter (supports real `\n` line breaks
  and parses inline `[label](url)` markdown links so cached entity
  references become clickable; falls back to the i18n welcome list)
- `InputArea` shows the matching hint as the chat input placeholder

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

* ♻️ refactor: extract daily-brief Redis read into HomeService

Mirrors the AgentService pattern: the lambda home router was reaching
into Redis directly, which mixed I/O concerns with the routing layer.
Move the read into a dedicated `HomeService` so future home-page reads
have a clear home and the router stays thin.

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

* 🐛 fix: keep WelcomeText typewriter index in sync with shared store

Before: DailyTypewriter held its own `sentenceIndex` state, separate
from the module-level `currentIndex` in `useHomeDailyBrief`. After
the home page rotated past the first pair, navigating away and back
remounted the typewriter and reset its local index to 0 — but the
external index stayed where it was. InputArea read the hint at the
stale external index while WelcomeText restarted at pair 0, breaking
the welcome / hint pairing.

Make the typewriter fully controlled: drop the local `sentenceIndex`,
expose `currentIndex` from `useHomeDailyBrief`, and pass it as a prop.
On `pause`, the typewriter just calls `onSentenceComplete` — the
parent flips the shared index, the new prop flows back, the reset
effect re-arms typing for the new sentence. Single source of truth,
remount-safe.

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

* ♻️ refactor(redis): factor JSON cache reads into getJSONFromRedis util

Three call sites were inlining the same "fetch + null-check + JSON.parse
+ try/catch" recipe against a scoped Redis client:

- AgentService.getAgentWelcomeFromRedis
- HomeService.readDailyBriefFromRedis (new)

Move the recipe into a small `getJSONFromRedis<T>` helper next to the
other Redis utilities and have both services delegate to it. Caller
keeps responsibility for resolving the right scoped client (we don't
want to hide the prefix selection inside the helper).

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

* 🐛 fix(home): use live editor content for Enter-to-send guard

When typing into the home input and pressing Enter immediately, the
empty-message guard sometimes wrongly bailed out. The cause: the guard
read the cached `inputMessage` in `useChatStore`, which is populated by
the editor's async `onMarkdownContentChange`. Lexical commits its
update on a microtask after each keystroke, so a fast type-then-Enter
fires the send path before the cache catches up.

`SendButtonHandler` already passes `getMarkdownContent` through — read
it instead, falling back to the cached value if the handler is invoked
without it. Also propagate the live message into all `inputActiveMode`
branches.

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

*  feat(home): accept daily-brief hint as the message on empty Enter

Press Enter on the empty home input → send the currently displayed
daily-brief hint as the message (smart-compose / Tab-to-accept style).
Trims the cosmetic trailing ellipsis and rotates the carousel so the
next press picks up a different pair.

Falls through to the previous "no content, skip" path when there's
neither a typed message nor a hint to use.

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

* 🐛 fix(home): scope daily-brief SWR key + rotation index by userId

The SWR key was a constant string, so an account switch within the same
SPA session — sign out + sign in as another user, or a multi-account
swap that keeps `isSignedIn` true — could surface the previous user's
cached pairs from the same slot. The keyspace in Redis is per-user,
so the served data leaks personalization.

Include the resolved userId in the SWR key, and reset the module-level
rotation index on user change so the new account starts from pair 0
rather than inheriting a stale offset (which could also point past the
end of a smaller pairs list).

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:07 +08:00
LiJian 09c66ffb4c 🐛 fix: first inject the cloudecc runtime session should use the existingStatus (#14592)
* 🐛 fix: skip reconnect when gateway action already established a connection

Race condition on new-topic first message:
1. switchTopic loads runningOperation → useGatewayReconnect fires
2. executeGatewayAgent calls connectToGateway (status: connecting)
3. reconnectToGatewayOperation overwrites with resumeOnConnect:true
4. Gateway sees resume on a brand-new session → no events → stuck

Second message works because the client store's runningOperation is
stale (from the first op), so SWR deduplications and no reconnect fires.

Fix: bail out of reconnectToGatewayOperation if gatewayConnections
already shows connecting/connected for that operationId.

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

* 🐛 fix: always pass --cwd /workspace for cloud CC to ensure session resume

CC stores session files at ~/.claude/projects/<encoded-cwd>/.
Without an explicit --cwd the actual working directory can differ
between sandbox invocations, so --resume <heteroSessionId> fails
to locate the previous session files even though the container is
persistent and the ID is correctly stored in topic.metadata.

Default cwd to /workspace for cloud runs (desktop keeps its own
explicit path), guaranteeing a stable session-file location across
page reloads within the same sandbox lifecycle.

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

* 🐛 fix: extend reconnect guard to cover all in-flight connection statuses

The previous guard only skipped reconnect for 'connecting'/'connected'
but the connection can already be in 'authenticating' or 'reconnecting'
by the time useGatewayReconnect fires, leaving the race window open.

Flip the condition: skip for any status that is not 'disconnected'.

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

* 🐛 fix: restore cold replica state in HeterogeneousPersistenceHandler

Vercel serverless functions are stateless per-request, so `operationStates`
is empty on every `heteroIngest` call. loadOrCreateState always cold-creates.

#14539 fixed `toolMsgIdByCallId` restoration but left `accumulatedContent`,
`toolState.payloads`, and `toolState.persistedIds` empty on cold load,
causing two bugs:

- Content truncation: cold instance starts with `accumulatedContent=''`,
  accumulates only the current batch's text, then writes that shorter string
  on the next step boundary or terminal — overwriting the longer content the
  previous write had already stored in DB.

- Tool duplication / tools[] overwrite: `persistedIds={}` on cold load
  means every `tools_calling` event re-creates already-persisted tool
  messages, and `payloads=[]` means phase 1/3 writes only the current
  batch's tools, wiping previous tools from `assistant.tools[]`.

Fix: in `loadOrCreateState`, fetch the current assistant message and restore
`accumulatedContent`, `accumulatedReasoning`, `toolState.payloads`, and
`toolState.persistedIds` from it. Cold load is now equivalent to warm load.

Also adds two regression tests covering the cold-replica scenarios.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 02:57:07 +08:00
Arvin Xu 909b1ec461 💄 style: use visible divider between queued messages (#14593)
💄 style(QueueTray): use visible divider color between queued messages

The previous `colorBorderSecondary` rendered the divider effectively
invisible on the elevated dark surface. Switch to `colorFillTertiary`
so stacked queued messages have a perceptible separator.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:07 +08:00
Rdmclin2 8274be0d1d 🐛 fix: slack connect error & slash commands (#14591)
* feat: displayToolCalls default undefined

* chore: restrict billboard to home page

* fix: add slack bot scope

* fix: show billboard in home nav
2026-05-13 02:57:07 +08:00
Neko b7a50206bf feat(agent-signal,prompts,database): self-review now proposal actions to briefs, and automatically execute actions (#14583) 2026-05-13 02:57:07 +08:00
Innei 5c1113031d 💄 style(intervention): polish confirmation bar layout (#14587) 2026-05-13 02:57:07 +08:00
AmAzing- fa17c75f90 chore: Refine homepage banner copy for channels and skills (#14588) 2026-05-13 02:57:07 +08:00
AmAzing- 0c659dbe22 🛠️ fix: unify SKILL.md frontmatter parsing and edit validation in agent documents (#14566) 2026-05-13 02:57:07 +08:00
LiJian d2c379c78d feat: add signOperationJwt with 4h expiry for hetero-agent operations (#14586)
*  feat: add signOperationJwt with 4h expiry for hetero-agent operations

- Add `signOperationJwt(userId)` to internalJwt.ts with 4h expiry and
  `purpose: 'hetero-operation'`, so Claude Code / Codex tasks running
  beyond 5 minutes no longer hit 401 on heteroIngest / heteroFinish
- Update `execAgent` hetero path to use `signOperationJwt` instead of
  `signUserJWT`; gatewayToken continues to use 5m `signUserJWT`
- Add unit tests in `__tests__/internalJwt.test.ts` with correct mocks
  for `jose` (SignJWT class + importJWK) and `authEnv`, covering all
  three signing functions and the expiry difference assertion

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

* 🔒 security: restrict hetero-operation JWT scope to heteroIngest/heteroFinish

A leaked 4-hour sandbox LOBEHUB_JWT must not be replayable against any
other authenticated lambda route.

- Forward `purpose` claim from JWT payload through validateOIDCJWT →
  tokenData → oidcAuth context so middlewares can inspect it
- oidcAuth: reject tokens with purpose 'hetero-operation' — they cannot
  reach any normal authedProcedure route
- New heteroOperationAuth middleware: exclusively accepts
  purpose 'hetero-operation' tokens, rejects all others
- Export heteroAuthedProcedure (baseProcedure + heteroOperationAuth +
  userAuth) from trpc/lambda/index.ts
- heteroIngest / heteroFinish now use heteroAgentProcedure built on
  heteroAuthedProcedure + serverDatabase + HeterogeneousAgentService
- Tests: heteroOperationAuth (4), oidcAuth (4), update heteroIngest
  test caller to supply purpose:'hetero-operation' context (23 total)

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 02:57:07 +08:00
Innei d73de25623 💄 style(settings): remove image avatar from lab input markdown rendering item (#14582) 2026-05-13 02:57:07 +08:00
YuTengjing a02ecbc40d 🐛 fix: polish task agent manager (#14569) 2026-05-13 02:57:07 +08:00
AmAzing- f1f2e58e01 feat: migrate Notion to LobeHub Market (#14578)
Migrate Notion to LobeHub Market
2026-05-13 02:57:06 +08:00
Arvin Xu 5f8ec8bbfb 🐛 fix(agent-runtime): recover malformed tool_call names instead of finishing silently (#14577)
* 🐛 fix(agent-runtime): recover malformed tool_call names instead of finishing silently

When an LLM emits tool_call names without the `____` separator (e.g. `activateTools`
instead of `lobe-activator____activateTools`), the resolver dropped them silently and
the harness finished with "completed without tool calls" — empty assistant bubble,
no error in dashboards.

Three layers of defense:

- Resolver fallback: when the bare name uniquely matches an API across known
  manifests, recover the identifier; ambiguous matches still drop to avoid
  false binding.
- StreamingHandler logs unresolved tool_call names so the silent-drop path is
  observable in debug output.
- GeneralChatAgent surfaces the unresolvable count and names in reasonDetail
  so dashboards can distinguish this from a genuine no-tool completion.

Fixes LOBE-8696

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

* 🐛 fix(agent-runtime): restrict bare-name fallback to tools offered this turn

Address review feedback on the LOBE-8696 resolver fallback. The
manifests map passed to ToolNameResolver.resolve is broader than the
tools actually sent to the LLM (the client builds it from every
installed plugin and every builtin; the server can preserve manifests
even after a step deactivates a tool). Without a turn-scope
restriction:

- A model returning a malformed bare name could resolve to a tool that
  was not enabled for this turn.
- A disabled duplicate API name could shadow the enabled call and make
  it look ambiguous, dropping a valid call.

Pipe an `offeredToolNames` list (the names actually sent in this LLM
payload) into resolve(): when set, the missing-prefix fallback only
considers manifests whose generated tool name appears in the list.

- ToolNameResolver.resolve gains an optional `offeredToolNames` param.
- internal_transformToolCalls forwards the list through.
- createAgentExecutors builds resolvedAgentConfig before the
  StreamingHandler so the closure can bind the offered names — same
  list that gets sent to the model.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:06 +08:00
LiJian 7792f63453 feat: Cloud Claude Code V3 — repo picker, GitHub token, sandbox context (#14568)
*  feat: Cloud Claude Code V3 — repo picker, GitHub token, sandbox context

- Add CloudRepoSwitcher component (web-only multi-select repo picker)
  - Pre-topic selections buffered in module singleton (pendingTopicRepos)
  - Consumed by gateway.ts at topic creation time via appContext.initialTopicMetadata
  - Eliminates race condition where updateTopicMetadata dropped silently
- Extend ChatTopicMetadata with repos[] field for multi-repo binding
- Add initialTopicMetadata to ExecAgentAppContext so repos are written to
  topic metadata at creation time (server-side, zero race condition)
- Extend ExecAgentSchema Zod schema with initialTopicMetadata
- Inject GITHUB_TOKEN env var into sandbox so CC can use git/gh CLI
- Build cloudHeteroContext with GitHub auth section when token is available
- Add workingDirectory selector for web (repos[0] fallback)
- Add refreshTopic call in gateway path after new topic creation
- Add CloudHeterogeneousConfig profile editor for GITHUB_REPOS / GITHUB_CRED_KEY
- Extend sandboxRunner with repo clone setup script and systemContext support

* 🐛 fix: add open-source stub for pendingTopicRepos to fix Vite build

* ♻️ refactor: move pendingTopicRepos real impl into submodule, remove cloud override

* 🐛 fix: consume pendingTopicRepos only after topic creation succeeds

* 🐛 fix: add missing getPendingTopicRepos import in gateway

* 🔒 fix: address security and dead-code issues from PR review

- sandboxRunner: sanitize repo dir name to prevent shell injection
- sandboxRunner: use git insteadOf (-c flag) so token is never stored in .git/config
- cloudHeteroContext: fix return type from string|undefined to string (dead branch)
- CloudRepoSwitcher: remove unreachable empty-list branch in popover content

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

* 💬 i18n: add claude setup-token hint to token description

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

* 🐛 fix: remove incorrect web hetero→gateway forced routing in agentDispatcher

On web, heterogeneousProvider is ignored — routing falls through to isGatewayMode.
Cloud CC only runs when gateway mode is enabled; gateway.ts handles sandbox
spawning when it detects a hetero provider.

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

* 🐛 fix: restore web hetero→gateway routing; update stale test

On web, a configured heterogeneousProvider always routes to gateway —
the cloud sandbox is the only execution environment regardless of
isGatewayMode. The test assumed the pre-cloud-CC world where web
ignored hetero providers entirely.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 02:57:06 +08:00
Innei 2959ec3883 📝 docs(version-release): enforce git-derived PR refs and metrics (#14575)
* 📝 docs(version-release): enforce git-derived PR refs and metrics

Add the skill's first-class hard rules for computing release-note inputs
from git instead of memory: latest-tag base via `git describe`, PR refs
from commit subjects, metric counts from `wc -l`, handle resolution via
`gh pr view`, and a pre-publish `comm -23` diff that must be empty.
Also adds @cy948 to the team roster and notes Tsuki / René Wang's
commit-author aliases so contributor classification stops drifting.

* ♻️ refactor(version-release): split skill into router + per-flow references

SKILL.md was 426 lines covering three distinct flows. Split it so each
flow lives next to its own checklist:

- reference/minor-release.md — minor workflow (lifted from SKILL.md)
- reference/patch-release-scenarios.md — patch flows (existing)
- reference/release-notes-style.md — long-form changelog standard,
  template, and Computing Inputs hard rules (lifted from SKILL.md)

SKILL.md now reads as a router (~100 lines) with shared CI trigger
rules, post-release automation, precheck, and hard rules. Cross-links
between references replace the previous in-file jumps. Also fixes a
prettier-mangled redirect (`< some-pr-by-them >`) by using a `$PR`
variable instead of an angle-bracket placeholder.

* 📝 docs(version-release): add Hotfix and DB Migration variants to release-notes-style

The Canonical Structure was implicitly long-form (Minor / Weekly), and
hotfix authors had to read `changelog-example/hotfix.md` to learn it
existed. Make the divergence explicit:

- New § Variants for Shorter Releases describes Hotfix structure
  (Scope / What's Fixed / Upgrade / Owner) and DB Migration structure
  (Migration overview / Operator impact / Rollback) as overrides of the
  canonical long-form layout.
- Renamed the canonical section to "Canonical Structure (Long-Form:
  Minor / Weekly)" so the boundary is visible.
- Added Hotfix entry to Release Size Heuristics.
- Added a Hotfix subsection to Quick Checklist so the verification
  gates differ from long-form (no metric line / no Contributors / Owner
  resolved via gh).
2026-05-13 02:57:06 +08:00
YuTengjing 181b7eb117 🐛 fix: remove signin captcha flow (#14573) 2026-05-13 02:57:06 +08:00
YuTengjing 2bdd901ce2 🐛 fix: add temporary email auth error locale (#14564) 2026-05-13 02:57:06 +08:00
Rdmclin2 e4b5e52aff 🐛 fix: add bot callback service (#14570)
fix: add bot callback service
2026-05-13 02:57:06 +08:00
LiJian 1a6e07b5ef 🐛 fix: sanitize sensitive comments and examples from production JS bundle (#14557)
* 🐛 fix: sanitize sensitive comments and examples from production JS bundle

- Replace app.example.com with RFC 2606 example.com in agent-browser skill content
- Replace password-stdin examples with interactive auth prompts
- Remove hardcoded password-like strings from code examples
- Reword flagged code comments in page-agent system role

Addresses TAC Security CASA Tier 2 DAST Info findings:
Information Disclosure - Suspicious Comments (CWE-615)

The flagged strings appeared in SPA production bundles:
- /_spa/assets/chat-*.js
- /_spa/assets/index-*.js

* 🐛 fix: revert --interactive to --password-stdin in auth vault examples

The --interactive flag does not exist in agent-browser CLI (only --password
and --password-stdin are supported). Using --interactive would cause auth
save to fail and block login workflows.

Reverted both auth vault examples to use echo | --password-stdin pattern,
which pipes the password via stdin — the recommended secure approach.
2026-05-13 02:57:06 +08:00
Arvin Xu a7cc553212 💄 style(task): activity card stop run + register /tasks in SPA proxy (#14559)
*  feat(task): add stop run action to activity card menu

Surface the existing cancelTopic flow in the task detail activity card so
users can interrupt a running topic without opening the chat drawer.

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

*  feat(task): confirm before stopping a running topic

Wrap the new Stop run action in a confirmModal so an accidental click can't
silently abort an in-flight run.

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

* 🐛 fix(spa): register /tasks and /task in SPA proxy matcher

Without these matcher entries, the Next.js middleware never rewrote /tasks
and /task/:taskId to the SPA catch-all, so the activity feed entries 404'd
in production builds even though the routes were wired in the SPA router.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:57:06 +08:00
YuTengjing c208723904 💄 style: update auth captcha retry copy (#14561) 2026-05-13 02:57:06 +08:00
Rdmclin2 760a342557 🐛 fix: multiple account link (#14562)
* feat: avoid rebind link same account

* chore: update i18n locales

* feat: avoid discord account misslink

* feat: support slack account mis match

* fix: avoid claim conflict
2026-05-13 02:57:06 +08:00
Arvin Xu ce08b9b116 feat(agent-runtime): persist agent operations to agent_operations table (#14736)
*  feat(agent-runtime): persist agent operations to `agent_operations` table

Wire start-time INSERT and terminal UPDATE into the agent runtime so
operation history outlives the 2-hour Redis TTL. Adds
`AgentOperationModel` with `recordStart` / `recordCompletion` /
`findById` (scoped by userId so a leaked operationId can't flip another
user's row) and threads both calls through `CompletionLifecycle`, which
now owns both ends of the persistence lifecycle. Also plumbs
`parentOperationId` through `ExecAgentParams` → `OperationCreationParams`
so sub-agent invocations carry their parent lineage. Per-step aggregate
updates are intentionally out of scope.

Refs LOBE-8848

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

* 🐛 fix(agent-runtime): update CompletionLifecycle test constructor to 2 args

CompletionLifecycle now constructs MessageModel internally from
(db, userId), so the test builder passing a third messageModel arg
tripped tsgo --noEmit.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:53:35 +08:00
Rdmclin2 efa57ad4ab feat: support slack mpim and fix discord dm problem (#14733)
* feat: support mpim

* chore: add errorMsg

* fix: discord commands thinking error

* fix: discord typing error

* feat: add oauth process for discord
2026-05-13 00:55:25 +07:00
Arvin Xu 844f885b60 🐛 fix(hetero-agent): wire AskUserBridge response events to renderer (#14732)
Close the wire-protocol gap that left CC's AskUserQuestion form stuck on
"pending" after the bridge gave up. AskUserBridge now emits an
agent_intervention_response event on every terminal path (timeout,
user resolve, cancel, cancelAll), and heterogeneousAgentExecutor handles
it by stamping pluginIntervention.status = 'rejected' for timeout /
session_ended (user-driven paths are filtered out — already optimistic).

Layered defenses so a late Submit no longer throws "Operation not found":
- cleanupCompletedOperations: find→filter so every messageOperationMap
  entry pointing to the cleaned op is removed (assistant + tool message
  pairs previously stranded one entry as a dangling reference).
- internal_getConversationContext: log + fall back to global state when
  the op has been GC'd, instead of throwing.
- submitHeteroIntervention: detect a stale opId before passing it into
  the optimistic chain.

Scoped as a short-term backstop until LOBE-8746 retires the AskUser MCP
bridge entirely.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 01:46:32 +08:00
Arvin Xu ccddbaa25d ♻️ refactor(builtin-tool): move sub-agent dispatch from lobe-gtd to lobe-agent (#14715)
* ♻️ refactor(builtin-tool): move sub-agent dispatch from lobe-gtd to lobe-agent

Move the `execTask` / `execTasks` capability out of `packages/builtin-tool-gtd/`
and into `packages/builtin-tool-lobe-agent/`, renaming the public APIs to
`callSubAgent` / `callSubAgents`. The "subtask" naming inside GTD overlapped
with the new lobe-task tool's task model and conflated planning with
sub-agent dispatch.

- API names: `execTask` → `callSubAgent`, `execTasks` → `callSubAgents`
- TS types: `ExecTaskParams` → `CallSubAgentParams`, etc.; introduce
  `SubAgentTask` to replace `ExecTaskItem`
- Client UI (Inspector / Render / Streaming) ported under
  `packages/builtin-tool-lobe-agent/src/client/`
- Central registries (`packages/builtin-tools/src/{inspectors,renders,streamings}.ts`)
  updated to register lobe-agent
- GTD `meta.description` and system role no longer mention async tasks;
  they point to lobe-agent for sub-agent dispatch
- `isSubTask` filtering in `agentConfigResolver` now excludes `lobe-agent`
  (new owner of sub-agent dispatch) instead of `lobe-gtd`
- i18n: new `builtins.lobe-agent.apiName.callSubAgent*` and
  `workflow.toolDisplayName.callSubAgent*` keys in default/zh-CN/en-US

Kept the executor's emitted `state.type` values (`execTask` / `execTasks` /
`execClientTask` / `execClientTasks`) unchanged so the agent-runtime
instruction layer (`exec_task` / `exec_tasks` / `exec_client_task*`) and all
downstream tests / heterogeneous executors (`builtin-tool-agent-management`,
server `agentManagement` runtime) continue to work without modification.

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

* ♻️ refactor(chat): rename isSubTask flag to isSubAgent

After moving sub-agent dispatch from lobe-gtd to lobe-agent, the flag name
no longer matches what it controls. Rename `isSubTask` → `isSubAgent` across
the chat / agent runtime layer and update related comments and test labels.

- `agentConfigResolver` context field + filter helper
- `streamingExecutor.internal_createAgentState` + `executeClientAgent`
  signatures and call sites
- `createAgentExecutors` (exec_task / exec_client_task handlers) and
  `GroupOrchestrationExecutors` (batch_exec_async_tasks)
- `chatService.createAssistantMessageStream` `resolvedAgentConfig` docs
- Test descriptions and assertions in `agentConfigResolver.test.ts` and
  `streamingExecutor.test.ts`

No behavior change — the flag's filter target (`lobe-agent` identifier) is
unchanged.

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

* ♻️ refactor(agent-runtime): rename exec_task wire identifiers to exec_sub_agent

Bring the agent-runtime "wire" naming in line with the lobe-agent
callSubAgent / callSubAgents API rename. Three layers are renamed in lockstep
to keep the bridge between tool executors and the runtime consistent:

1. Tool-emitted state.type discriminators
   - 'execTask' → 'execSubAgent'
   - 'execTasks' → 'execSubAgents'
   - 'execClientTask' → 'execClientSubAgent'
   - 'execClientTasks' → 'execClientSubAgents'

2. AgentInstruction.type and matching TS interfaces
   - 'exec_task' / 'exec_tasks' / 'exec_client_task' / 'exec_client_tasks'
     → 'exec_sub_agent' / 'exec_sub_agents' / 'exec_client_sub_agent' /
       'exec_client_sub_agents'
   - AgentInstructionExecTask → AgentInstructionExecSubAgent (and the three
     siblings)
   - ExecTaskItem → SubAgentTask

3. AgentRuntimeContext.phase + matching payload types
   - 'task_result' → 'sub_agent_result'
   - 'tasks_batch_result' → 'sub_agents_batch_result'
   - TaskResultPayload → SubAgentResultPayload
   - TasksBatchResultPayload → SubAgentsBatchResultPayload

Also renames the operation-type discriminator 'execClientTask' /
'execClientTasks' to 'execClientSubAgent' / 'execClientSubAgents' and updates
its locale string in default / zh-CN / en-US.

Tests / fixtures / mocks updated in lockstep:
- packages/agent-runtime/src/agents/{GeneralChatAgent.ts,__tests__/...}
- packages/builtin-tool-{lobe-agent,agent-management}/src/...
- src/server/services/toolExecution/serverRuntimes/agentManagement.ts
- packages/agent-mock/src/cases/builtins/todo-write-stress.ts (helper renamed
  to callSubAgent)
- src/store/chat/agents/createAgentExecutors.ts + exec-task / exec-tasks tests
  + fixtures/mockInstructions.ts (createExecSubAgent[s]Instruction)
- src/store/chat/slices/aiChat/actions/streamingExecutor.ts (phase check)
- packages/conversation-flow/src/__tests__/fixtures/**/*.json (8 fixtures
  retargeted from lobe-gtd/execTask[s] to lobe-agent/callSubAgent[s] with the
  new state.type wire values)

No behavior change — the agent runtime, executors and tests all go through
the same code paths; only the strings on the wire change.

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

* ♻️ refactor(builtin-tool): absorb GTD tool (plan + todo) into lobe-agent

Delete `packages/builtin-tool-gtd/` and fold its full surface — plan, todo,
ExecutionRuntime, all client UI (Inspector / Render / Streaming /
Intervention / SortableTodoList) and the system role — into
`packages/builtin-tool-lobe-agent/`. Single `lobe-agent` identifier now
owns: plan + todo management, sub-agent dispatch, and visual media analysis.

Also restructures the lobe-agent package so the executor lives under
`./client/` alongside the UI it ships with, and drops the dedicated
`./executor` export — consumers go through `./client` for everything
client-side.

Package-level changes:
- DELETE `packages/builtin-tool-gtd/` entirely.
- `packages/builtin-tool-lobe-agent/`
  - Move `src/executor/` → `src/client/executor/`. Drop `./executor` from
    `package.json` exports; expose `lobeAgentExecutor` via `./client` only.
  - Rename `GTDExecutionRuntime` → `PlanExecutionRuntime` and place under
    `src/client/executor/PlanRuntime/`. Re-export from package root so the
    server runtime can consume it without pulling in client UI deps.
  - Extend `LobeAgentExecutor` with `createPlan` / `updatePlan` /
    `createTodos` / `updateTodos` / `clearTodos`, all delegated to the
    shared runtime.
  - Add Plan + Todo API entries to the manifest (with their original
    descriptions, humanIntervention, renderDisplayControl).
  - Move all GTD client UI verbatim:
    `Inspector/{ClearTodos,CreatePlan,CreateTodos,UpdatePlan,UpdateTodos}`,
    `Render/{CreatePlan,TodoList}`, `Streaming/CreatePlan`,
    `Intervention/{AddTodo,ClearTodos,CreatePlan}`,
    `components/SortableTodoList`. Register them in
    `LobeAgentInspectors / Renders / Streamings`, add new
    `LobeAgentInterventions`.
  - Merge GTD system role into lobe-agent's (`<plan_and_todos>` plus the
    existing `<sub_agents>` and `<run_in_client>` sections).
  - `package.json`: pick up `@lobechat/prompts` dep and `@lobehub/editor` +
    `antd` + `lucide-react` peer-deps inherited from GTD.

Central registries (`packages/builtin-tools/src/*`) and consumers:
- Remove every `GTDManifest / Inspectors / Renders / Streamings /
  Interventions` import + registration; existing `LobeAgent*` registrations
  now cover them.
- Replace `[GTDManifest.identifier]: GTDInterventions` with
  `[LobeAgentManifest.identifier]: LobeAgentInterventions`.
- Drop `@lobechat/builtin-tool-gtd` workspace dep from
  `packages/builtin-tools/package.json`, `packages/builtin-agents/package.json`
  and root `package.json`.
- Remove `gtdExecutor` from `src/store/tool/slices/builtin/executors/index.ts`;
  switch `lobeAgentExecutor` import to `/client`.
- Replace `serverRuntimes/gtd.ts` with a service factory
  `serverRuntimes/lobeAgentPlan.ts` (`createServerPlanRuntimeService`).
  `serverRuntimes/lobeAgent.ts` instantiates `PlanExecutionRuntime` with
  that service so the registry exposes one runtime per `lobe-agent`
  identifier covering both visual analysis and plan/todo.
- `services/chat/mecha/contextEngineering.ts`: gate plan/todo injection on
  `LobeAgentIdentifier` instead of `GTDIdentifier`.
- `agentConfigResolver.test.ts`: switch fixture plugin IDs to
  `LobeAgentIdentifier`.
- `packages/const/src/recommendedSkill.ts`: drop the standalone `lobe-gtd`
  recommendation — `lobe-agent` already covers it via `defaultToolIds`.

i18n migration (default + zh-CN + en-US; other locales regenerate on
`pnpm i18n`):
- `builtins.lobe-gtd.*` → `builtins.lobe-agent.*` in `plugin.ts/json`.
- `lobe-gtd.*` (tool namespace) → `lobe-agent.*` in `tool.ts/json`.
- Remove `tools.builtins.lobe-gtd.{description,readme,title}` from
  `setting.ts/json` (lobe-agent has its own meta now).
- Update all client component `t(...)` keys to the new namespace.

Mocks / fixtures / tests:
- `packages/agent-mock/src/cases/builtins/todo-write-stress.ts`: all
  `identifier: 'lobe-gtd'` → `'lobe-agent'`; helper comments updated.
- `packages/types/src/stepContext.ts`: comment refers to
  `builtin-tool-lobe-agent` (the only consumer of `StepContextTodoItem`).
- `packages/model-runtime/src/core/streams/google/google-ai.test.ts`:
  function-call names from `lobe-gtd____createPlan` etc. → `lobe-agent____*`.
- `src/store/chat/slices/message/selectors/dbMessage.test.ts`: same.
- `src/features/DevPanel/RenderGallery/fixtures/lobe-gtd.ts` deleted; its
  plan/todo fixtures are folded into `fixtures/lobe-agent.ts` alongside the
  existing `callSubAgent[s]` ones.
- Replace `console.log` → `console.info` in moved client components to
  satisfy lobe-agent's stricter ESLint rules (GTD package allowed
  `console.log`; lobe-agent inherits the repo-wide `no-console` rule).

No behavior change for end users: `lobe-agent` now owns all the APIs,
identifiers, and UI that previously lived in `lobe-gtd`, but as a single
consolidated package under a single tool identifier.

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

* ♻️ refactor(context-engine): drop residual GTD naming, rename to PlanInjector / TodoInjector

Follow-up to 9ca5c9d (which absorbed the GTD tool package into lobe-agent).
That commit moved the package surface but left the GTD vocabulary embedded
in context-engine providers, types, metadata fields, XML tags, and a pile
of comments. This change finishes the sweep so the only remaining GTD
references are user-facing docs and the legitimate Productivity & GTD Coach
methodology suggestion.

context-engine
- `GTDPlanInjector` → `PlanInjector`; types `GTDPlan`/`GTDPlanInjectorConfig`
  → `Plan`/`PlanInjectorConfig`; metadata `gtdPlanId`/`gtdPlanInjected` →
  `planId`/`planInjected`; XML tag `<gtd_plan>` → `<plan>`; debug channel
  `provider:GTDPlanInjector` → `provider:PlanInjector`.
- `GTDTodoInjector` → `TodoInjector`; types `GTDTodoItem`/`GTDTodoList`/
  `GTDTodoStatus`/`GTDTodoInjectorConfig` → `TodoItem`/`TodoList`/
  `TodoStatus`/`TodoInjectorConfig`; metadata `gtdTodo*` → `todo*`;
  XML tag `<gtd_todos>` → `<todos>`, wrapper `gtd_todo_context` →
  `todo_context`; debug channel renamed similarly.
- `MessagesEngineParams.gtd?: GTDConfig` → `planTodo?: PlanTodoConfig`;
  internal vars `isGTDPlanEnabled`/`isGTDTodoEnabled` →
  `isPlanEnabled`/`isTodoEnabled`. Re-exports updated in `providers/index.ts`
  and `engine/messages/{index,types}.ts`.

prompts
- `packages/prompts/src/prompts/gtd/` → `planTodo/` (only export was
  `formatTodoStateSummary`, which kept its name). Updated `prompts/index.ts`
  re-export.

src/services
- `contextEngineering.ts`: `GTDConfig` import → `PlanTodoConfig`;
  `isGTDEnabled`/`gtdConfig` → `isPlanTodoEnabled`/`planTodoConfig`; payload
  field `gtd` → `planTodo`; log message wording.

Tests
- `dbMessage.test.ts`: helper `createGTDToolMessage` →
  `createLobeAgentToolMessage`; `gtdMessage` → `lobeAgentMessage`; all `it`
  descriptions reworded to "lobe-agent" instead of "GTD".
- `agentConfigResolver.test.ts`: test descriptions reworded.

Comments / docs (no behavior change)
- agent-runtime (`instruction.ts`, `runtime.ts`, `generalAgent.ts`,
  `messageSelectors.ts`), `types/{stepContext,tool/builtin}.ts`,
  `builtin-agents/group-supervisor`, `builtin-tool-claude-code/types.ts`,
  `builtin-tool-lobe-agent/Render/TodoList`, `createAgentExecutors.ts:1426`,
  `AssistantGroup/{constants,Fallback.test}`, `agent-mock/todo-write-stress`,
  `.agents/skills/builtin-tool/references/architecture.md`.

Intentionally left alone
- `docs/usage/agent/gtd.{mdx,zh-CN.mdx}` and other docs — user-facing
  product brand "GTD Tools".
- `src/locales/default/suggestQuestions.ts` "Productivity & GTD Coach" —
  references the methodology, not the tool.
- `ToolSystemRoleProvider.test.ts` `'gtd-tool'` fixture — generic test
  identifier, unrelated.
- Translated locale files still carrying `lobe-gtd.*` keys — regenerated by
  `pnpm i18n` from the updated default namespace.

Verified: `bun run type-check` passes; touched test files
(dbMessage, agentConfigResolver) and full context-engine + prompts test
suites pass.

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

* 🐛 fix(builtin-tool-lobe-agent): reset TodoList auto-save status to idle

`performSave` (the debounced auto-save path) was leaving `saveStatus` stuck
on 'saved' forever — `saveNow` had the 1.5s setTimeout-to-idle but the
auto-save twin didn't, so the inline indicator never eased back to idle
after a settle. Add the same idle-reset to performSave so both paths
behave the same.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 01:13:04 +08:00
Arvin Xu 4ffce4fbbf 💄 style: use @lobehub/ui built-in HtmlPreview instead of custom component (#14703)
* 💄 style(home,i18n): use 已阅 for brief confirm/confirmDone in zh-CN

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

* 🐛 fix(home): use 确认完成 for brief.action.confirmDone in zh-CN

confirmDone signals the terminal transition (task marked complete),
not just dismissing the brief, so 已阅 loses the semantic distinction
from `confirm`. Use 确认完成 to match the EN intent ("Confirm complete").

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

* ♻️ refactor: use @lobehub/ui built-in HtmlPreview instead of custom component

- Upgrade @lobehub/ui from ^5.10.1 to ^5.10.4
- Replace custom HtmlPreviewAction with lobe-ui's enableHtmlPreview
- Wire lobe-ui's onExpand callback to existing HtmlPreviewDrawer
- Remove HtmlPreviewAction.tsx (no longer needed)
- Keep HtmlPreviewDrawer for the expanded full-screen view

* 🐛 fix(task): sync useMarkdown destructuring with assistant MessageContent

* 🐛 fix(task): correct mangled search.X JSX expressions in MessageContent

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

* 💄 style(review): move revert icon to right edge of file row

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 01:08:18 +08:00
LobeHub Bot 9da8ed0a6c 🌐 chore: translate non-English comments to English in src (#14654)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 00:54:54 +08:00
Arvin Xu e8ab37e5d4 🐛 fix(home): blank user bubble when sending the placeholder hint (#14678)
When the home input was empty and the user clicked send, `useSend`
correctly fell back to the daily-brief hint for `message`, but it also
forwarded `mainInputEditor.getJSONState()` as `editorData`. An empty
editor still returns a non-null JSON state (e.g. `{ type: 'doc' }`),
which makes `UserMessageContent.hasEditorData` truthy — so the renderer
took the RichTextMessage branch and drew nothing, while the agent
happily processed the hint text behind a blank user bubble.

Skip `editorData` when the hint is being used so the renderer falls
back to the markdown `content`. Adds a regression test.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 00:51:41 +08:00
Arvin Xu 9dff0acd36 feat(database): add agent_operations table (#14416)
 feat(database): add agent_operations table

Adds an `agent_operations` table to persist agent runtime operations
beyond the 2-hour Redis TTL. Each row captures one agent operation
(operationId) with denormalized cost/token aggregates, lifecycle
timestamps, runtime config snapshot, and a `trace_s3_key` pointer to
the full ExecutionSnapshot in S3.

- `user_id` is intentionally not a FK so operation history survives
  user deletion (auditable historical data).
- `agent_id` / `topic_id` / `thread_id` / `task_id` / `chat_group_id`
  use ON DELETE SET NULL to preserve operations when their parent
  entity is removed.
- `parent_operation_id` self-references for sub-agent (callAgent) ops.
- `human_interventions` and `human_waiting_time_ms` are nullable since
  most operations have no human interaction at all.
- Indexes optimize per-user listing and per-status / per-entity lookups;
  `metadata` has a GIN index for jsonb filters.
2026-05-13 00:51:03 +08:00
Innei 84c89f9c03 🐛 fix(conversation): prevent synthetic scroll from shrinking spacer (#14584)
🐛 fix: prevent synthetic scroll from shrinking spacer
2026-05-13 00:18:10 +08:00
Arvin Xu a5ea379079 ♻️ refactor(agent-runtime): extract CompletionLifecycle, HumanInterventionHandler, stepPresentation (#14441)
* ♻️ refactor(agent-runtime): extract CompletionLifecycle

Pull terminal-state handling out of AgentRuntimeService into a dedicated
class:

- buildLifecycleEvent (was buildCompletionLifecycleEvent)
- emitSignalEvents (was emitCompletionSignalEvents)
- dispatchHooks (was dispatchCompletionHooks)
- extractErrorMessage

These four methods formed one cohesive vertical: build the lifecycle
event payload, emit completion AgentSignal source events, dispatch
onComplete/onError hooks, and write error back onto the assistant
message row. extractErrorMessage was a private helper used by all three
plus by the trace-snapshot finalize call site, so it becomes a public
method on the class.

Call sites in executeStep / executeSync change from
`this.{emit|dispatch|extract...}` to `this.completionLifecycle.{...}`.

Tests: extractErrorMessage.test.ts → CompletionLifecycle.test.ts,
instantiating CompletionLifecycle directly instead of going through
AgentRuntimeService — drops a pile of unrelated mocks.

AgentRuntimeService.ts: 2084 → 1918 (-166).

All 81 agentRuntime tests pass.

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

* ♻️ refactor(agent-runtime): extract HumanInterventionHandler

Pull the 165-line `handleHumanIntervention` method out of
AgentRuntimeService into its own class, splitting the three branches
(approve / rejectAndContinue / rejectAndHalt) into private methods so
each fits in one screen. Routing in `process()` now reads top-to-bottom:
detect approval, then rejection, then unsupported humanInput.

The handler depends only on `serverDB` (for the messagePlugins lookup)
and `messageModel` (for tool/plugin updates) — much narrower than
AgentRuntimeService's full surface, so the extracted unit is easier to
unit-test in isolation.

Drop the unused `runtime: AgentRuntime` parameter from the public API:
the original method threaded it through but never called it.

Tests: handleHumanIntervention.test.ts → HumanInterventionHandler.test.ts
— same 17 cases, but instantiate the handler directly instead of
constructing a full AgentRuntimeService with 11 module mocks. Tighter
arrange step, same coverage.

AgentRuntimeService.ts: 1918 → 1742 (-176).

All 81 agentRuntime tests pass.

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

* ♻️ refactor(agent-runtime): extract step presentation builder

Pull the ~150-line `phase`-branching block out of executeStep into a
pure `buildStepPresentation` function. The block did three things in
sequence: derive content/reasoning/toolsCalling/toolsResult from the
runtime step result, build a one-line stepSummary for logging, and
assemble the StepPresentationData DTO consumed by afterStep hooks /
snapshot recorder / callbacks.

The function takes only the stepResult and an executionTimeMs; no
service state needed. Comes with a `formatTokenCount` helper for the
log line (12345 → 12.3k, 2_500_000 → 2.5m).

executeStep keeps the log call inline (one line, references presentation
fields directly) and reads `content` / `toolsCalling` off presentation
for downstream tracking + truncation logic.

13 new unit tests: phase=tool_result (json + string + isSuccess paths),
phase=tools_batch_result, done event, llm_result with content/reasoning/
tools, empty fallback, cumulative usage zero-fallback, stepUsage
forwarding, and formatTokenCount edges.

AgentRuntimeService.ts: 1742 → 1601 (-141).

All 94 agentRuntime tests pass (was 81, +13 new).

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 00:12:15 +08:00
Arvin Xu b9fb68464d 🐛 fix(task-card): localize task card date independent of dayjs global locale (#14730)
* 🐛 fix(task-card): localize date format independent of dayjs global locale

Task card was rendering "5月 12" under English UI because t('time.formatThisYear')
returned the English "MMM D" format, but dayjs's global locale was still zh-cn,
making MMM resolve to the Chinese short month name. Thread the i18n language
into formatTaskItemDate so the date is rendered with the same locale as the
format string, decoupling it from dayjs's global state.

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

* 🐛 fix(task-card): import missing GenericItemType + type Run now onClick

Pre-existing CI regression from #14727 surfacing on every PR: the Run now
context menu satisfies-clause references GenericItemType without importing
it, and the onClick lacks a MenuInfo annotation, so tsgo widens the divider
literal's `type` to `string` and rejects the whole context menu array.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:31:51 +08:00
Arvin Xu ca873e3c34 🐛 fix(web-crawler): cap response body size to prevent serverless OOM (#14660)
* 🐛 fix(web-crawler): cap response body size to prevent serverless OOM

Production saw repeated SIGABRT crashes on `/trpc/tools/search.webSearch`
where Node aborted with V8 "allocation failed" — the naive crawler buffered
entire response bodies into heap before the 1 MB downstream truncation could
apply, so a single large page (or a batch of three under default
concurrency=3) could push rss past the lambda memory ceiling.

- ssrfSafeFetch: add opt-in `maxContentLength` that streams the response
  body via `for await` and stops at the cap (soft truncation — still a
  successful response). Breaking the iterator destroys the underlying
  stream and releases the connection. Default behaviour (full
  `arrayBuffer()` read) unchanged when the option is absent.
- naive crawler: pass `maxContentLength: MAX_HTML_SIZE` so any body beyond
  1 MB is dropped at the network layer instead of being materialised in heap.
- htmlToMarkdown: explicitly call `window.happyDOM.close()` in a finally
  block so the parsed DOM tree is released as soon as parsing finishes,
  rather than waiting for the function scope to drop.

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

*  test(ssrf-safe-fetch): add OOM regression tests for response body cap

Verify that the maxContentLength cap actually prevents the production SIGABRT
scenario, not just produces a truncated body.

- Source-pull bound: a body source with 200 MB available, capped at 1 MB,
  must not be drained beyond ~1 MB. Asserts on bytes pulled from the
  generator, which is the property that prevents OOM.
- Concurrency bound: matches production CRAWL_CONCURRENCY=3 — three
  concurrent oversized fetches should pull at most ~3 MB total, not 300 MB.
- Heap-delta bound (gated on --expose-gc): under real GC pressure,
  fetching a 50 MB body with a 1 MB cap should grow heapUsed by < 10 MB.
  Run with `NODE_OPTIONS=--expose-gc bunx vitest run` to exercise; skipped
  by default so CI doesn't false-fail on GC timing.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:21:08 +08:00
Innei ddc67bc3db 🐛 fix(desktop): focus onboarding auth success state (#14694) 2026-05-12 22:57:34 +08:00
Arvin Xu dfb5e0176e feat(markdown): user_feedback card + task card polish + Run now context menu (#14727)
*  feat(markdown): render <user_feedback> task prompt blocks as a card

`buildTaskRunPrompt` wraps the user's pre-run comments in a
`<user_feedback>` block alongside `<task>`. The Task plugin captured
`<task>` into a card, but `<user_feedback>` had no plugin and leaked
into the chat as raw XML. Because CommonMark only treats tag names
matching `[a-zA-Z][a-zA-Z0-9-]*` as html, the underscore in
`user_feedback` puts the opening/closing tags inside a `paragraph` as
plain text — so the new remark plugin walks paragraph children rather
than html nodes.

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

* 💄 style(task-card): drop standalone status row + Agent/Parent/Topics, inline semantic status badge

The status/Priority row, Agent, Parent and Topics fields aren't useful
when the task card is rendered inside the topic chat drawer (the drawer
already exposes that context). Move the task status to a compact badge
beside the identifier and reuse `taskDetail.status.*` for the label so
"scheduled" reads as "Scheduled" / "已排期".

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

* 💄 style(user-feedback): compact one-line header + left-border quote-style card

Slims the card down to a single 12px header line ("User feedback · N
comments") with a small 12px icon, and wraps the whole block in a
subtle fill + 2px left-border accent so it reads as a quoted aside and
visually separates from the task card that follows in the same user
message body.

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

* 💄 style(user-feedback): drop fill + radius, render as plain left-rail blockquote

The filled card competed visually with the unstyled task block that
sits beside it in the same message body. Reducing to a 2px left-rail
quote without background or border-radius lets both blocks read as
parts of the same user message.

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

* 💄 style(user-feedback): collapsible card with task-style head + bottom divider

Default-collapsed `<details>` whose summary mirrors the task title row
(32px icon + bold label + small count badge), with a bottom split-line
that doubles as a divider between the user feedback head and the task
card that follows in the same message body.

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

* 💄 style(user-feedback): strip default markdown details card chrome

@lobehub/ui Markdown applies bg + padding (0.75em 1em) + box-shadow +
border-radius to every nested <details>, which made the user_feedback
head read as a wide standalone card sitting awkwardly on top of the
inline task title. Override the chrome (with !important — the lib
selector wins on specificity otherwise) so the head sits flat in the
message body, with only the bottom split line separating it from the
task that follows. The lib's right-side disclosure chevron is kept.

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

* 💄 style(user-feedback): match task card's 12px symmetric divider spacing

Add a 12px margin-bottom so the gap below the user_feedback bottom rule
mirrors the 12px above it, matching the symmetric 12px the task card
already uses around its own internal divider. Without this, the
user_feedback rule sat flush against the T-31 row while the next rule
below T-31 had a 12px gap on both sides — visually uneven.

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

* 💄 style(task-card): drop status badge from task title row

The task drawer header and the schedule strip on the task detail page
already convey status; surfacing it again on the task card inside the
chat body just added noise. Drop the badge along with the now-unused
KNOWN_STATUSES / isKnownStatus / TaskStatusIcon / useTranslation
plumbing.

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

*  feat(tasks): add "Run now" item to task card context menu

Available only for backlog and completed tasks; mirrors the inbox-agent
fallback used by the detail-page Run Now action.

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

* 🐛 fix(topic-list): preserve `#` icon placeholder for heterogeneous agents

Returning null for the icon slot collapsed the row layout, so titles on
heterogeneous-agent topics (Claude Code, Codex, …) no longer aligned
with sibling rows. Render the same HashIcon with visibility:hidden so
the box is preserved without showing the glyph.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:39:11 +08:00
brone1323 a109d22c8d 🌐 i18n: add missing task-schedule and review strings to 16 locales (#14728)
🌐 i18n: add missing translations for task-schedule and review keys across 16 locales

Adds 14 missing i18n keys to all non-zh-CN locales (ar, bg-BG, de-DE,
es-ES, fa-IR, fr-FR, it-IT, ja-JP, ko-KR, nl-NL, pl-PL, pt-BR, ru-RU,
tr-TR, vi-VN, zh-TW):

chat.json (11 keys):
- taskSchedule.summary.everyNHoursHalfPast
- taskSchedule.summary.hourlyHalfPast
- taskSchedule.timezoneSearchEmpty
- taskSchedule.timezoneSearchPlaceholder
- workingPanel.review.revert (and 7 sub-keys)

plugin.json (1 key):
- builtins.lobe-task.apiName.setTaskSchedule

setting.json (2 keys):
- serviceModel.modelAssignments.title
- serviceModel.optionalFeatures.title

These were added in recent commits but the automated i18n sync had not
yet propagated them to non-Chinese locales.
2026-05-12 22:13:31 +08:00
Innei b8587cef73 💄 style: polish desktop header icons, sidebar density, and task menus (#14724)
* 💄 style: shrink desktop header icons and tighten sidebar/home density

Switches all desktop header action icons from DESKTOP_HEADER_ICON_SIZE to
DESKTOP_HEADER_ICON_SMALL_SIZE, and tightens vertical gaps in the home
sidebar, recents list, and nav header layout for a denser, calmer look.

* ♻️ refactor(agent-tasks): migrate task menus and scheduler select to @lobehub/ui base-ui

- TaskPriorityTag / TaskStatusTag: replace antd Dropdown with base-ui
  DropdownMenu and adopt the ContextMenuItem / MenuInfo typings.
- useTaskItemContextMenu: drop the DOM data-attribute submenu marker in
  favour of an internal activeSubmenuRef tracked via onOpenChange.
- TaskScheduleConfig / SchedulerForm: swap @lobehub/ui Select for the
  base-ui Select and replace the custom SearchBar dropdownRender with
  antd Select showSearch for timezone filtering.

* ♻️ refactor(review): migrate review dropdowns to @lobehub/ui base-ui DropdownMenu

Swap the antd Dropdown trios (mode picker, base-ref picker, more menu) in
the agent working-sidebar Review pane for the base-ui driven DropdownMenu,
matching the recent task menus / scheduler migration. Also tighten the
sidebar header paddingInline from 16 to 4 to align with the surrounding
density polish.

* 🐛 fix(tasks): replace unsupported onOpenChange with onTitleMouseEnter in context menu
2026-05-12 21:42:28 +08:00
René Wang ba750161ca fix: Docs image (#14726)
fix: image
2026-05-12 20:19:55 +08:00
René Wang 60c55b731c 📝 docs: add May 11 weekly changelog (#14651) 2026-05-12 20:06:45 +08:00
Arvin Xu 09230e7af5 🐛 fix(desktop): detect Windows npm .cmd shims for CLI agents (claude/codex/…) (#14720) 2026-05-12 17:46:48 +08:00
LobeHub Bot fac91067ce 🌐 chore: translate non-English comments to English in cli-migrate (#14708)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 17:36:22 +08:00
Arvin Xu 0b5c1fb53f ⬆️ chore: bump @lobehub/ui to 5.10.5 2026-05-12 17:17:02 +08:00
Arvin Xu 5d21b9e149 💄 style(review-panel): hover revert button to discard per-file working-tree changes (#14716)
 feat(review-panel): hover revert button to discard per-file working-tree changes

Add a hover-revealed Undo icon to each file row in the Review panel's
unstaged view. Clicking opens a Popconfirm; confirming runs a new
`git.revertGitFile` IPC that restores the file from HEAD (or unstages +
deletes when the path doesn't exist at HEAD, covering staged-add and
untracked entries).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:03:31 +08:00
Innei 9e0e76fda2 feat(documents): add optimistic create/delete and inline rename for document tree (#14714)
- Insert pending rows immediately on create folder/document, with
  optimistic SWR mutation that rolls back on server error
- Auto-focus rename input on newly created items via onPendingInserted
  callback
- Defer rename commits for pending rows until the server create resolves,
  then rename against the real row id
- Optimistic recursive delete closes the confirm modal instantly, removes
  target + descendants from the tree, and rolls back on failure
- Fix folder path canonicalization in ExplorerTree rename lookup
  (toCanonicalTreePath ensures trailing slash for folders)
- Export getItemPathFromEventPath for composed-path–based item resolution
- Add unit tests for toCanonicalTreePath and ExplorerTree event helpers
2026-05-12 16:40:17 +08:00
Arvin Xu 66b9c67494 fix: update Task page placeholder copy (#14704)
* fix: update Task page placeholder copy

* fix: update Task page placeholder copy (en-US)
2026-05-12 16:25:23 +08:00
Innei 2d4822ad7b 💄 style: standardize header action icon sizes (#14717)
💄 style: standardize header action icons to DESKTOP_HEADER_ICON_SMALL_SIZE

Unify icon sizing across sidebar and header action buttons by replacing
hardcoded sizes and DESKTOP_HEADER_ICON_SIZE with
DESKTOP_HEADER_ICON_SMALL_SIZE for consistent visual density.

Affected components:
- SideBarHeaderLayout back button
- ToggleLeftPanelButton default size
- BackButton default size
- Agent sidebar header chevron
- InboxButton notification icon
2026-05-12 15:48:56 +08:00
Innei a50b230fae feat(devtools): add dev-only feature flag override panel (#14565)
Add a client-side feature flag override panel that lives behind a
floating button in dev builds. Overrides are persisted to localStorage
and merged into useServerConfigStore.featureFlags so existing flag
consumers see the toggled value without any callsite changes.

The panel is gated by NODE_ENV plus a localStorage opt-in
(LOBE_DEV_FEATURE_FLAG_PANEL_ENABLED = "1"); prod builds tree-shake
the entire feature.
2026-05-12 15:33:51 +08:00
Arvin Xu 5d6d01601d 🐛 fix(builtin-tool-task): expose lobe-task and add setTaskSchedule (#14713)
*  feat(builtin-tool-task): expose lobe-task to users and add schedule config

The task tool is now generally available — flip it from a scenario-only
internal tool to a user-toggleable recommended skill, and let the LLM
configure recurring execution (cron or heartbeat) via createTask / editTask.

- Drop `discoverable: false` + `hidden: true` from TaskManifest registration
- Add `lobe-task` to RECOMMENDED_SKILLS so it stays installed by default
- Remove the USER_HIDDEN_BUILTIN_TOOL_IDS allowlist (only contained lobe-task);
  update selectors and AgentTool to stop filtering it out
- Extend createTask / createTasks / editTask with `automationMode`,
  `schedulePattern`, `scheduleTimezone`, `heartbeatInterval`; editTask also
  accepts `maxExecutions`
- Route schedule columns through taskService.update and maxExecutions through
  taskService.updateConfig (server merges into tasks.config.schedule);
  refresh detail once at the end of editTask

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

* ♻️ refactor(builtin-tool-task): split schedule config into dedicated setTaskSchedule tool

editTask was the wrong place for schedule fields — schedule needs its own
verb so the LLM (and any future human-in-the-loop review) can audit cron /
heartbeat changes separately from generic field edits, and createTask should
stay a pure "make a task" verb without automation knobs.

- Drop automationMode / schedulePattern / scheduleTimezone / heartbeatInterval
  from createTask + createTasks, and drop them plus maxExecutions from editTask
- Add new `setTaskSchedule(identifier, automationMode?, schedulePattern?,
  scheduleTimezone?, heartbeatInterval?, maxExecutions?)` API with its own
  manifest entry, executor method, types, i18n key, and inspector
- Schedule columns still route through taskService.update; maxExecutions still
  routes through taskService.updateConfig (server merges into
  tasks.config.schedule) — same wiring, just moved into the dedicated tool
- Update systemRole to advertise setTaskSchedule + keep editTask description
  clean of schedule mentions

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:25:53 +08:00
AmAzing- b49340742b feat: add service model assignments settings (#14712)
*  Add default agent model setting

* 💄 Refine service model assignments UI

* 💄 Clarify optional service model features
2026-05-12 14:59:09 +08:00
Innei b29816e927 🐛 fix(desktop): reset pendingLoginMethod on auth failure/cancel paths (#14695)
* 🐛 fix(desktop): focus onboarding auth success state

* 🐛 fix(desktop): reset pendingLoginMethod on auth failure/cancel paths

Clear pendingLoginMethod in authorizationFailed, authorizationProgress
cancelled, and remoteServerSyncError handlers to prevent users getting
stuck without a Get Started path when a re-auth attempt fails but a
prior authorization is still valid.

* Delete src/routes/(desktop)/desktop-onboarding/features/LoginStep.test.tsx

---------

Co-authored-by: Innei <inbox@innei.in>
2026-05-12 14:30:06 +08:00
Innei f03a1f0022 ♻️ refactor(spa): use __DEV__ define instead of process.env.NODE_ENV (#14696)
* ♻️ refactor(spa): use __DEV__ define instead of process.env.NODE_ENV

The Vite `__DEV__` define and its global type declaration are already
in place (plugins/vite/sharedRendererConfig.ts, src/types/global.d.ts).
Replace `process.env.NODE_ENV` checks across SPA-only files with the
`__DEV__` boolean so the bundler can statically eliminate dev-only
branches in production builds.

Server-side files (app/, server/, libs/next, libs/trpc, libs/better-auth,
envs, instrumentation) and modules that are also imported by Next.js
SSR pages (e.g. components/Loading/BrandTextLoading) are intentionally
left untouched to avoid runtime `__DEV__ is not defined` errors.

* fix(vitest): define __DEV__ and related constants for test environment

Vitest runs outside the Vite SPA build pipeline, so the __DEV__ define
injected by sharedRendererDefine was not available during tests. This
caused ReferenceError: __DEV__ is not defined in any test file that
transitively imports code using the __DEV__ constant.

Add a  block to vitest.config.mts that mirrors the SPA defines:
- __DEV__: true (test is not production)
- __CI__: mirrors process.env.CI
- __ELECTRON__/__MOBILE__: false (not testing platform-specific code)

* fix: replace missed isDevEnv reference with __DEV__ in AgentMockDevtools
2026-05-12 14:29:58 +08:00
Neko 29db177524 ♻️ refactor(agent-signal,prompts,database,builtin-tool-self-iteration): unified structure of service, unified tool, unified name and concepts (#14699) 2026-05-12 14:08:23 +08:00
Arvin Xu 5d8d2abe4c 🐛 fix(utils): cap image binary at 3.75MB so base64 payload stays under Anthropic 5MB limit (#14711)
* 🐛 fix(utils): cap image binary at 3.75MB so base64 payload stays under Anthropic's 5MB limit

Anthropic enforces the 5MB image cap on the base64-encoded payload, not the
binary file. Base64 inflates by ~4/3, so a 4.7MB binary file becomes 6.27MB
once encoded and trips `messages.*.content.*.image.source.base64: image
exceeds 5 MB maximum`. The previous MAX_IMAGE_BYTES of 5MB matched against
file.size, letting these images through compression untouched.

Lower the threshold to floor(5MB * 3/4) ≈ 3.75MB in both the frontend
canvas compressor and the server-side Sharp fallback so the progressive
shrink loop keeps going until the base64 payload is safely under the cap.

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

* 🐛 fix(utils): tighten image binary cap to 3MB for extra base64 headroom

Drop MAX_IMAGE_BYTES from 3.75MB (exact 5MB-base64 boundary) to a flat 3MB
so the encoded payload lands around 4MB — clear of any per-provider rounding
or jitter at the 5MB hard limit.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:04:12 +08:00
Arvin Xu 49c8d17e2c 🐛 fix(tasks): scheduler, hotkey, comment & TodoList polish (#14707)
* 🐛 fix(portal): allow TodoList to scroll when expanded content exceeds max-height

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

* 🐛 fix(tasks): route 1–N hotkey to the open submenu instead of defaulting to status

The base-ui SubmenuTrigger doesn't propagate antd's `onTitleMouseEnter`, so
the hover ref in the right-click context menu never updated and every number
press fell back to the status submenu. The standalone Priority/Status tag
dropdowns also showed 1–N hints without binding any handler at all.

- Detect the currently open submenu via `data-popup-open` + a per-submenu
  `data-task-submenu` marker on the icon; numbers are ignored when no
  submenu is open.
- Install a keydown listener on TaskPriorityTag / TaskStatusTag while their
  dropdown is open so the hint numbers actually fire.

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

* 🐛 fix(scheduler): keep Continuous unchanged while editing Max runs

Clearing the Max runs input previously emitted maxExecutions=null, which the
form re-interpreted as Continuous and auto-checked the checkbox mid-edit
(disabling the input before the user could type the replacement number).

Track Continuous as its own state derived from the persisted prop. On clear
we hold the input empty locally without touching Continuous or emitting,
and unrelated emits fall back to the persisted value so they can't flip the
checkbox either.

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

* 💄 style(tasks): always show comment Send button and unify action labels

- Make the Send button visible by default in CommentInput / FeedbackInput
  (greyed out when empty) so the field reads as an input instead of vanishing
  affordance.
- Align topic action menu labels to Title Case (Stop Run / Open Run /
  Copy Topic ID / Copy Operation ID / Copy Link) to match the rest of the
  Action microcopy.

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

*  perf(scheduler): seed SchedulerForm from props once and own state locally

The previous prop→state useEffects re-synced every time the parent prop
updated, which during the async updateSchedule → refreshTaskDetail roundtrip
clobbered the user's in-flight edits with stale store values — felt awful
on rapid changes.

Drop the three sync useEffects and seed local state from props only at
mount via a lazy useState initializer. The form now owns its values
optimistically; cross-task safety comes from `key={taskId}` on the
parent so the form remounts cleanly when switching tasks.

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

* 💄 style(scheduler): Notion-style timezone picker — drop underscores, offset on the right

Underscored labels like 'America/New_York (EST/EDT, UTC-5/-4)' read poorly in
the dropdown. Split each option into `label` (underscore → space) and `offset`,
and render the row with the city on the left and a subtle gray offset on the
right, in line with how Notion's timezone picker presents this.

IANA `value` keeps the underscore so cron and Drizzle stay happy. Search now
filters by the human label only.

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

* 💄 style(scheduler): keep zone abbreviations in the timezone offset column

Show 'EST/EDT · UTC−5/−4' instead of just 'UTC−5/−4' so users can recognize
the zone by its common abbreviation alongside the offset.

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

* 💄 style(scheduler): drop awkward ':30' suffix from hourly summary

'Every hour:00' / 'Every 2 hours:30' read like glitched concatenations. Cron
storage always rounds to 0 or 30 minutes, so call out the non-zero case as
'at half past' and stay implicit on the top of the hour.

- Every hour
- Every hour at half past
- Every 2 hours
- Every 2 hours at half past

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

* 💄 style(scheduler): collapse advanced settings by default

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

*  perf(tasks): coalesce post-write refresh and add timezone search

Two follow-up fixes for the AgentTasks scheduler popover.

##### Optimistic schedule writes, single coalesced refresh

Rapid edits in the scheduler form (toggling daily/hourly/weekly, weekday
chips, time, etc.) each triggered `taskService.update` + a full
`internal_refreshTaskDetail` per call. With overlapping requests the
refreshes returned intermediate server state and bounced TaskTriggerTag /
summary text away from the user's latest choice.

- Add `#withCoalescedRefresh` on the task config slice: it tracks a per-task
  pending-writes count and only fires `internal_refreshTaskDetail` after the
  LAST in-flight write settles.
- Give `updateSchedule` an optimistic `internal_dispatchTaskDetail` so
  external readers see the new pattern/timezone/maxExecutions immediately.
- Route both `updateSchedule` and `setAutomationMode` through the coalescer.

##### Timezone picker — search input at the top

The dropdown had antd's implicit type-into-trigger search, which most users
miss. Add a `SearchBar` inside `dropdownRender`, filter the options against
label/value/offset locally, and show an empty state when nothing matches.

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

* 💄 style(scheduler): weekday chips only show background when selected

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

* 🐛 fix(tasks): dispatch optimistic schedule under nested 'schedule' field

`TaskDetailData` exposes schedule as `schedule.{pattern,timezone,maxExecutions}`,
not flat columns. The previous optimistic dispatch used the DB-style flat keys,
which broke type-check and would never reach the in-memory selectors.

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

* 💄 style(tasks): drop Cmd+Backspace shortcut on the Delete menu item

Header dropdown only advertised the hotkey (no handler), and the right-click
context-menu handler is gone too — keeps the visual claim honest and
removes the irreversible-by-keystroke footgun.

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

*  test(agent-signal): pin `now` in proposal activity tests to fixture window

Two cases relied on the real system clock; once today crossed the
fixture's default `expiresAt` (2026-05-12), pending proposals were
classified as expired and the assertions broke.

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

* 💄 style(tasks): hide '#' placeholder icon for heterogeneous agent topics

Claude Code / Codex topics aren't chat topics in the usual sense, so the
fallback HashIcon in the sidebar row reads as noise. Skip it when the
current agent has a heterogeneousProvider.

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

* 🧪 test(tasks): provide agentMap in TopicItem store mock

`isCurrentAgentHeterogeneous` walks through `currentAgentConfig` which
indexes `s.agentMap[agentId]`. Extend the mocked store state to include
an empty `agentMap` so the selector resolves to `undefined` (= not
heterogeneous) instead of throwing.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:01:59 +08:00
Arvin Xu c62af095f5 🐛 fix(cli): remove stale cron entry from generated man page (#14709)
* 🐛 fix(cli): remove stale cron entry from generated man page

The cron command was removed from program.ts but the generated man page
still listed it. Regenerated via bun run man:generate.

* 🔖 chore(cli): release 0.0.15

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:40:55 +08:00
Arvin Xu 9c746d5784 💄 style(tool): add word wrap toggle to tool arguments display (#14706)
 feat(tool): add word wrap toggle to tool arguments display

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:31:59 +08:00
Rdmclin2 a74cd2bf9f 🐛 fix: sidebar add agent (#14693)
* fix: sidebar add agent and group error

* feat: add billboard cta
2026-05-12 10:27:38 +07:00
Innei 1a368ea823 💄 style(nav): unify ActionIcon sizing and improve TodoList encapsulation (#14692)
- Extract SIDEBAR_HEADER_ACTION_ICON_SIZE constant for consistent sidebar header ActionIcon sizing
- Pass size prop to ToggleLeftPanelButton
- Simplify Agent selector ActionIcon to use 'small' size preset
- Move layout wrapper styles from Body into TodoList root for better component encapsulation
- Increase Nav gap from 1 to 4 for proper spacing
2026-05-12 00:59:13 +08:00
YuTengjing 98156dba8d feat: inline skill auth in recommended task templates (#14676)
*  feat: support refreshing recommended task templates

- Add optional `refreshSeed` through `listDailyRecommend` API, service, and
  client; SWR key includes it so a refresh actually refetches.
- Frontend stores the seed in sessionStorage (via `useSessionStorageState`)
  so a new tab or next day returns to the default daily picks.
- Home Daily Brief shows a "Refresh" affordance on the Recommendations
  subtitle row.
- Fix first-card pinning when matched candidates < RECOMMEND_COUNT: fold
  the fallback pool in so seed reorders the whole batch instead of locking
  position 0 to a single-match template.

Linear: LOBE-8689

*  feat: resolve task-template icon priority

Render the task-template card icon as self > skill provider > interest > Sparkles. Skill icons read required[0] then optional[0], skipping unresolvable providers. URL icons render via @lobehub/ui Image, component icons keep the 28x28 tile.

*  feat: inline skill auth in task template card

Single click "Add task" is now the entire flow: the button stays put, and if a required skill is missing we chain its OAuth popups and create the task automatically. Unauthorized providers (required + optional) appear as compact inline rows above the footer; the provider that already drives the card's main icon is suppressed to avoid duplicating the same logo.

*  feat: add task template detail modal

Open a detail modal when the recommended task template card is clicked,
exposing the full instruction (markdown) plus inline skill auth and the
add-task action. Rename i18n `${id}.prompt` -> `${id}.instruction` to
align with the task table column, and write both `description` and
`instruction` when creating the task. Extract shared `TemplateBriefIcon`,
`useScheduleText`, `useTaskTemplateCreate` and `useVisibleAuthSpecs` so
the card and the modal share the same creation flow and OAuth chaining.

* 🐛 fix: missing Block import in TaskTemplateCard

*  feat: render recommended templates on empty Tasks page

Replace the bare "no tasks" placeholder with a hero landing: greeting,
enlarged inline composer (hero variant), and a 2-column grid of up to
10 recommended task templates. Plumbs a new `count` option through the
service, both routers, the client service, and the recommendations hook
so the home page keeps its 3-card layout while the empty Tasks page
asks for 10.

* 🐛 fix: type cast in resolveTemplateIcon test for unknown interest

* 🌐 i18n: update translations for task template empty-state and other namespaces
2026-05-12 00:28:24 +08:00
Innei 3ef4083dfb 🐛 fix: replace ScrollShadow with ScrollArea to fix React #185 infinite render loop (#14689)
Migrate all ScrollShadow usages to ScrollArea (scrollFade) to eliminate
the effect → setState → render → effect cycle that caused React error
#185 (Maximum update depth exceeded) in the scroll overflow hook.

Affected components:
- StreamingMarkdown
- AgentCouncil AutoScrollShadow
- AssistantGroup ContentBlocksScroll
- Conversation Thinking

Fixes lobehub/lobehub#14650
2026-05-12 00:15:12 +08:00
LiJian a5299696de 🐛 fix(heteroFinish): trigger task lifecycle on cloud sandbox agent completion (#14681)
* 🐛 fix(heteroFinish): trigger task lifecycle transition on sandbox agent completion

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

* 🐛 fix(heteroFinish): guard onTopicComplete against duplicate finish calls

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 23:31:26 +08:00
LiJian f64c74db90 📝 docs(cloudHeteroContext): add sandbox persistence & gh push rules (#14682)
* 📝 docs(cloudHeteroContext): add sandbox persistence & gh push rules

Inject ephemeral-sandbox warnings and mandatory GitHub push rules into
the cloud CC context block so every Claude Code run knows:
- The sandbox is wiped after inactivity — local changes will be lost
- All code changes must be committed and pushed before task is complete
- Use gh CLI (pre-authenticated) for GitHub operations

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

* 🐛 fix(cloudHeteroContext): address review comments on sandbox persistence rules

- Remove gh push guidance (gh has no push subcommand; git push is correct)
- Gate gh-auth instructions behind githubToken availability to avoid
  auth-dependent commands failing in no-token sandbox runs

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

* 📝 docs(cloudHeteroContext): add git push auth fallback guidance

Tell CC that the sandbox has git credentials ready, but if git push
fails it can self-recover via:
1. gh auth setup-git (reconfigures git credential helper)
2. inline token URL as last resort (oauth2:$GITHUB_TOKEN@github.com)

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 23:21:15 +08:00
YuTengjing 83b2a00314 📝 docs(skills): frontmatter cleanup + argument-hint (#14683)
* 🔨 chore: control skill triggering via frontmatter flags

- Rename debug skill to debug-package (avoid confusion with debugging workflows)
- Add disable-model-invocation to add-* skills so they are manual-only
- Add user-invocable: false to reference/architecture skills so they auto-load only when relevant

* 🔨 chore: rename skill reference dirs to plural references

Align with the skill-creator convention (scripts/, references/, assets/).

* 📝 docs(skills): split oversized SKILL.md files and refine triggers

- upstash-workflow: 1126L → 189L, extract implementation / best-practices / examples references
- data-fetching: 854L → 613L, move parent-keyed-map walkthrough to references
- store-data-structures: 625L → 314L, extract types and reducer references
- upstash-workflow/cloud.md, version-release/release-notes-style.md: add TOCs
- linear: rewrite ALL-CAPS MUSTs into prose explaining why; mark user-invocable: false
- version-release: mark disable-model-invocation: true (manual /version-release only)
- debug-package: expand description with concrete trigger phrases and tokens

* 📝 docs(skills): regularize microcopy structure

Move language-specific guidelines into references/zh.md and references/en.md
so SKILL.md can point to them via the standard progressive-disclosure pattern.
Previously the two files sat next to SKILL.md but were not referenced anywhere,
making them invisible to Claude Code loading.

* 📝 docs(skills): move builtin-tool refs into references subdir

Aligns builtin-tool with the references/ layout used elsewhere
(microcopy, store-data-structures). 3 md files move, SKILL.md
links updated.

* 📝 docs(skills): broaden trigger descriptions for core skills

Adds concrete API names, file paths and natural-language phrases so
auto-triggering catches more relevant prompts. Touches zustand,
drizzle, i18n, react, typescript, modal, hotkey.

* 📝 docs(skills): add argument-hint to user-only skills
2026-05-11 22:48:38 +08:00
𝑾𝒖𝒙𝒉 c0b9124956 🐛 fix(hotkey): remove redundant onClear to prevent double updateHotkey calls (#14663)
Previously, clicking the clear button on HotkeyInput triggered both
`onClear` and `onChange` (since HotkeyInput internally calls
`setHotkeyValue('')` which fires `onChange`). This caused two
concurrent requests to `updateDesktopHotkey` and showed two toast
messages (success/error) for a single user action.

Fix: remove the redundant `onClear` prop. HotkeyInput's clear action
already fires `onChange('')`, so the single `onChange` handler is
sufficient.

Co-authored-by: Innei <i@innei.in>
2026-05-11 22:47:58 +08:00
Innei b794eb1fb9 ♻️ refactor(web-onboarding): merge agent-marketplace identifier into onboarding tool (#14672)
* ♻️ refactor(web-onboarding): merge agent-marketplace identifier into onboarding tool

Drop the standalone `lobe-agent-marketplace` builtin tool and fold its
`showAgentMarketplace` / `submitAgentPick` APIs into `lobe-web-onboarding`
so onboarding exposes a single tool identifier.

- Move marketplace API entries (with humanIntervention/renderDisplayControl)
  into WebOnboardingManifest; extend WebOnboardingApiName.
- Compose AgentMarketplaceExecutionRuntime inside WebOnboardingExecutionRuntime;
  the client WebOnboardingExecutor now owns showAgentMarketplace/submitAgentPick
  with telemetry hooks. Drop the separate client/server executor + runtime files.
- Merge marketplace Inspector / Intervention / Render maps under the
  web-onboarding identifier. Remove AgentMarketplace* entries from
  builtin-tools registries and from the builtin web-onboarding agent's
  plugins list.
- Switch customInteractionHandlers to route by (identifier, apiName) so
  the marketplace picker handler fires only on `showAgentMarketplace`.
- Drop the `lobe-agent-marketplace` fallback string in
  OnboardingActionHintInjector; match by apiName only.
- Rename plugin/setting locale keys under `lobe-web-onboarding.*`.

* 🐛 fix(onboarding): reserve scroll headroom for agent marketplace overlay

- Add a footerSlot spacer in ChatList matching the marketplace panel height so the latest message can be scrolled into view above the absolute overlay.
- Nudge the marketplace overlay inset by 2px to hide subpixel border seams.
- Document turn output order in the onboarding system role to avoid trailing filler text after tool calls.
2026-05-11 21:29:41 +08:00
YuTengjing 5ef0238b22 🐛 fix: reject inactive OIDC access (#14674)
* 🐛 fix: reject inactive OIDC access

* 🐛 fix: honor expired OIDC bans

* 🐛 fix: decouple OIDC inactive error from tRPC

*  test: fix OIDC auth type checks
2026-05-11 21:20:04 +08:00
Arvin Xu dd02ac7062 💄 style(web-onboarding): add Render for saveUserQuestion & showAgentMarketplace (#14667)
 feat(builtin-tool-web-onboarding): add Render for saveUserQuestion + showAgentMarketplace

Tool messages for `saveUserQuestion` and `showAgentMarketplace` previously
fell back to the raw Arguments/Response table once the call resolved
because neither API had a Render registered. Wire both up:

- `saveUserQuestion`: new Render mirroring the Intervention's detail-card
  style — agent identity (emoji + name), full name, and interests chips —
  rendered conditionally per the fields actually saved.
- `showAgentMarketplace`: reuse the existing `SubmitAgentPick` Render.
  After the picker submits, `customInteractionHandlers` rewrites the
  `showAgentMarketplace` tool message's `pluginState` to the same
  `{ summaries, installedAgentIds, ... }` shape, so the card grid
  renders without a new component.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 20:50:16 +08:00
Arvin Xu ae3dc902e3 ♻️ refactor(knowledge-base): share RAG runtime across client/server via KnowledgeBaseSearchService (#14673)
* ♻️ refactor(knowledge-base): share runtime across client/server via KnowledgeBaseSearchService

Extract a server-side `KnowledgeBaseSearchService` (semanticSearchForChat
fan-out + getFileContents branching + groupAndRankFiles) so both the lambda
chunk router and the builtin tool server runtime orchestrate RAG through one
implementation. Wire the builtin knowledge-base tool to the shared
ExecutionRuntime in the package by moving the client executor to
`src/client/executor/` and registering a thin server runtime factory.

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

* ♻️ refactor(knowledge-base): move PG 23505 handling into adapters, restore executor path

ExecutionRuntime is dual-end so it cannot detect PG error codes — only the
server adapter can. Move the unique-constraint check there and translate the
lambda router's `FILE_ALREADY_IN_KNOWLEDGE_BASE` sentinel in the client
adapter, so the runtime's generic catch surfaces the human-readable message
on both code paths. Restore `src/executor/` as a top-level sibling of
`src/client/` to match the convention of every other builtin tool.

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

* ♻️ refactor(knowledge-base): collapse executor into /client, drop ./executor export

The executor is just another client-only adapter (alongside Inspector and
Render) — no reason for it to sit at the package root with a dedicated
subpath. Move it under `src/client/executor/`, re-export from
`src/client/index.ts`, drop the `./executor` entry from package.json, and
update the consumer to import from `@lobechat/builtin-tool-knowledge-base/client`.

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

*  test(knowledge-base): cover KnowledgeBaseSearchService

13 unit tests across both methods:
- getFileContents: docs_* direct read, missing doc, file_* via findByFileId,
  parseFile fallback, parse failure surfaces as error entry, missing file,
  mixed batch.
- semanticSearchForChat: chunk grouping + relevance ranking, BM25 skip when
  no knowledgeIds, knowledgeIds → fileIds expansion, vector/BM25 isolated
  failure capture (preserves the other path's results + structured
  rejections), full failure path.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 20:49:17 +08:00
Arvin Xu 853998b560 ♻️ refactor(bot): close activator bypass + converge device-access checks (#14664)
* ♻️ refactor(aiAgent): introduce deviceToolRegistry as single source of truth

Centralise "what counts as a device tool" into one module so the next
device-tool addition only touches one file. Removes the hardcoded
`new Set(['local-system', 'remote-device'])` from `deviceToolAudit.ts`,
which had drifted from `LocalSystemManifest.identifier` /
`RemoteDeviceManifest.identifier` imports elsewhere.

Foundation for the LOBE-8768 activator-bypass fix landing next.

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

* 🐛 fix(aiAgent): block activator from bypassing canUseDevice gate

External bot senders could still reach the owner's machine by having the
LLM call `lobe-activator.activateTools(["lobe-remote-device"])`, because
`enableCheckerFactory.allowExplicitActivation` short-circuits before the
canUseDevice rule, and the engine's `manifestSchemas` always contained
the full builtin list (LOBE-8768 B1).

Fix by filtering builtin manifests **physically** through
`buildAllowedBuiltinTools` at both feed-points (ToolsEngine input and
the activator-discovery `toolManifestMap`). When `canUseDevice=false`,
the device manifests no longer exist in either map, so explicit
activation cannot resolve them — the rule-layer gate becomes
defense-in-depth instead of the sole barrier.

Validates with the prod incident's repro path: an external sender's
`<available_tools>` no longer advertises `lobe-remote-device`, and an
activator call to enable it returns "not found".

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

* ♻️ refactor(bot,messenger): centralise isOwner derivation in buildBotContext

The same fail-closed expression
`!!operatorUserId && senderExternalUserId === operatorUserId` was
duplicated across `BotMessageRouter.onNewMention`, `.onSubscribedMessage`,
the DM catch-all, and `MessengerRouter.dispatchToAgent` — four sites,
one rule, one place to silently regress.

Route all four through `buildBotContext`. The helper now owns the
fail-closed contract referenced by `ChatTopicBotContext.isOwner`'s
docstring, so adding the next platform/router can't accidentally
default to "trusted when in doubt".

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

* 🐛 fix(aiAgent): apply device filter post-merge across all manifest sources

The previous fix only filtered the `builtinTools` source. An installed
plugin or a Skill/Klavis manifest declaring
`identifier: 'lobe-remote-device'` would still survive in
`manifestSchemas` and reach `toolManifestMap` via either
`getEnabledPluginManifests` or the direct ingest loops in
`aiAgent/index.ts` — letting an external bot sender activate the device
identifier through the activator.

Two changes close the gap:

  1. `ServerAgentToolsEngineConfig.excludeIdentifiers` — applied **after**
     combining plugin + builtin + additional manifests in
     `createServerToolsEngine`. `createServerAgentToolsEngine` passes
     `DEVICE_TOOL_IDENTIFIERS` whenever `canUseDevice` is false.

  2. `isManifestIngestAllowed` in `aiAgent.execAgent` — a single
     identifier guard reused at every `toolManifestMap` / `toolSourceMap`
     write (engine-returned plugin manifests, lobehub-skill loop,
     klavis loop). New ingest points inherit the wall automatically.

New test pins the regression: a plugin + an additional manifest
spoofing the device identifiers are dropped from `availablePlugins`
when `excludeIdentifiers` is set.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 20:45:52 +08:00
Arvin Xu e51c38c182 ♻️ refactor(task): snapshot agent model into task.config at create time (#14670)
*  feat(task): snapshot agent model into task.config at create time

Pin the assignee agent's current model/provider into task.config when a
task is created so later changes to the agent's default model don't
silently affect already-created tasks. On first run, backfill the
snapshot for tasks created before this change.

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

* 🐛 fix(task-runner): fall back to inbox agent when task has no assignee

`TaskRunnerService.runTask` previously threw `BAD_REQUEST` for any task
without `assigneeAgentId`, which broke runs created without `--agent`.
Resolve and persist the user's built-in inbox agent instead, surfacing
an `INTERNAL_SERVER_ERROR` only if that resolution itself fails.

Picked from #14671 (closes once landed).

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

* ♻️ refactor(task): collapse router orchestration into TaskService

Move multi-step task verbs out of the TRPC router into `TaskService`:
`createTask`, `cancelTopic`, `deleteTopic`, `runReview`, `updateStatus`,
`previewSubtaskLayers`, `runReadySubtasks`. The router keeps only input
validation + error wrapping; the tool runtime now shares the same
`createTask` path (was duplicating the model snapshot + parent
resolution).

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

* 🚨 ci: fix tsgo errors from TaskService extraction

`runReadySubtasks` router was rebuilding the `data` payload via a
conditional spread, which forced TS to infer a discriminated union that
broke `result.data.skipped` access in the integration test. Pass the
service result straight through so `skipped` stays a single optional
field. Also cast the stubbed `taskService` in the tool runtime unit
tests to bypass strict structural typing — same pattern the other
dep stubs already use.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 20:21:40 +08:00
YuTengjing 6a66901b12 🔥 chore: drop task template tracking (#14666)
* 🔥 chore: drop task template tracking

The recommendation surface is about to be redesigned, so the analytics
funnel added in #14517 is being removed up front. A fresh tracking
schema will land alongside the redesigned UI.

- Delete `analytics.ts` plus its test and the tracking-focused
  `TaskTemplateCard.test.tsx`.
- Drop `RecommendedTaskTemplate` / `TaskTemplateRecommendationSource` /
  `TaskTemplateFallbackPool` and revert the service to plain
  `TaskTemplate[]`.
- Strip impression, dismiss, create-clicked/result and
  skill-connect-clicked/result calls from `TaskTemplateCard.tsx`, while
  keeping the createTask + navigate-to-task flow from #14540.
- Remove `recommendationBatchId` / `userInterestCount` / `onCreated`
  plumbing from `useDailyBriefRecommendationsUI`,
  `DailyBriefRecommendationsView`, and the card props.
- Revert `useSkillConnection` to the pre-tracking variant (no
  onConnectResult / SkillConnectionResult).

* 🐛 fix: remove created template from recommendation cache

After #14540 changed the create-task flow to auto-navigate to
`/task/{id}`, removing the `onCreated` plumbing from #14517 in the same
sweep meant the SWR recommendation cache was never mutated on success.
Combined with the server-side `recordCreated` being a no-op and
`listDailyRecommend` not excluding created IDs, returning to Home
showed the same recommendation as actionable again — letting users
trigger duplicate scheduled tasks from the same template.

Re-add the minimal cache-eviction plumbing (no analytics):

- TaskTemplateCard exposes `onCreated` and calls it on success
- useDailyBriefRecommendationsUI shares `removeTemplateFromList` for
  both dismiss and created flows
- DailyBriefRecommendationsView passes `onCreated` through
2026-05-11 18:47:45 +08:00
YuTengjing 63c2e251ce 🐛 fix: drop unreachable aihubmix empty-apiKey test (#14669)
* 🐛 fix: drop unreachable aihubmix empty-apiKey test

The `should return empty array when API key is missing` test asserts a
contract that doesn't hold: RouterRuntime.models() constructs the
underlying runtime via the OpenAI-compatible factory before calling
modelsOption, and the factory throws InvalidProviderAPIKey on empty
apiKey at construction time — so aihubmix's own `if (!apiKey) return []`
short-circuit can never actually fire.

Just delete the dead test. The defensive guard in aihubmix's modelsOption
stays as intent documentation. Also tighten an implicit-any in the
adjacent `should normalize model_id field to id` test.

* 🔥 chore: drop dead empty-apiKey guard in aihubmix modelsOption

* 💄 style: tighten aihubmix apiKey assertion to string
2026-05-11 18:44:07 +08:00
Zhijie He dee254c197 💄 style: add reasoning_effort support for Grok 4.3 (#14642)
* style: add reasoning_effort for Grok 4.3

* style: remove grok 4.1 series & grok-imagine-image-pro (Model retirement)

style: remove grok 4.1 series & grok-imagine-image-pro (Model retirement)

style: remove grok 4.1 series & grok-imagine-image-pro (Model retirement)
2026-05-11 17:20:35 +08:00
Arvin Xu 28bf990c88 💄 style: increase chat topic title length (#14659)
* 💄 style: increase chat topic title length

- bump initial topic title slice from 20 to 40 chars
- bump dev fallback slice from 30 to 40 chars
- bump thread title slice from 20 to 40 chars
- raise LLM summary title prompt limit from 50/10w to 80/15w

* 💄 style: bump topic/thread title slice from 40 to 80 chars

Align slice limits with the LLM summary prompt cap (80 chars) so the
initial visible title is no shorter than what the summarizer can return.
2026-05-11 16:32:22 +08:00
Bianzinan f3a785970e fix(aihubmix): use full models endpoint to return complete model list (#14511)
* fix(aihubmix): use full models endpoint to return complete model list

The /v1/models endpoint at api.aihubmix.com returns only per-user-group
models (~256). The new endpoint at aihubmix.com/api/v1/models returns
the complete catalog (800+). Fetch from the full endpoint directly.

* fix(aihubmix): normalize model_id to id from full models endpoint

The https://aihubmix.com/api/v1/models endpoint uses `model_id` instead
of `id`. Map it to `id` before passing to processMultiProviderModelList
to prevent toLowerCase() errors and empty model list.

* fix(aihubmix): add apiKey guard, AbortController timeout, and better error messages

- Extract apiKey with runtime guard to fail fast when key is missing
- Add AbortController with 10s timeout to prevent indefinite hanging
- Include response body in error message for easier debugging
- Add APP-Code header comment pointing to docs
- Expand tests: mock global fetch, cover missing key / HTTP error / network error / AbortError cases

* fix(aihubmix): add field mapping adapter and fix timeout scope

Address review feedback from #14511:

- Update AiHubMixModelCard interface to reflect the new endpoint schema
  with full JSDoc (model_id, desc, types, features, input_modalities,
  context_length, max_output, pricing.cache_read/cache_write)
- Add mapAiHubMixModel() to adapt API response fields to LobeHub model
  card fields before passing to processMultiProviderModelList:
    desc             -> description
    model_name       -> displayName
    context_length   -> contextWindowTokens
    max_output       -> maxOutput
    types            -> type  (llm/t2t->chat, image_generation/t2i->image,
                               video/t2v->video, tts, stt, embedding,
                               rerank/reranking->rerank)
    pricing.cache_read  -> pricing.cachedInput
    pricing.cache_write -> pricing.writeCacheInput
    features(tools/function_calling) -> functionCall
    features(thinking)               -> reasoning
    features(web)                    -> search
    input_modalities(image)          -> vision
- Fix timeout scope: move clearTimeout into the finally block so the
  AbortController stays active during response.json() body read, not
  just during the initial fetch() call
- Update baseURL from https://api.aihubmix.com to https://aihubmix.com
  to match official integration docs (https://docs.aihubmix.com/cn/api/Aihubmix-Integration)
- Strengthen normalize test: assert list.some(m => m.id === 'some-model')
  instead of just Array.isArray to detect normalization failures
- Add field-mapping test using vi.spyOn on processMultiProviderModelList
  to assert that all adapted fields are passed correctly

* fix(aihubmix): filter out unsupported rerank types to prevent chat fallback

- Remove rerank/reranking from TYPE_MAP; they have no LobeHub AiModelType
  equivalent and would silently fall back to 'chat' in processModelCard
- Add UNSUPPORTED_AIHUBMIX_TYPES set and filter before mapAiHubMixModel()
- Add regression test asserting rerank/reranking models are excluded and
  llm models still pass through

---------

Co-authored-by: Bianzinan <bianzinan@users.noreply.github.com>
2026-05-11 16:24:54 +08:00
Innei a238838fea feat(activator): require activation reason (#14597) 2026-05-11 16:23:56 +08:00
Innei 831c2585f1 🐛 fix(onboarding): skip marketplace on early exit, drop CJK in prompts (#14598)
* 🐛 fix(onboarding): skip marketplace on early exit, drop CJK examples in prompts

Honor the user's wish to leave: when the onboarding agent detects a true
early-exit signal in any phase, persist what is known, send a brief
farewell, and call finishOnboarding directly. The marketplace handoff is
mandatory only on normal Phase 4 / Summary completion. Previously the
spec forced the agent to invent categoryHints from environment cues
when discovery was thin, producing noisy recommendations for users who
explicitly asked to stop.

- Replace systemRole §Early Exit with a 4-step flow (no marketplace, no
  summary), and remove the trailing "respect their time" rationale that
  contradicted the new policy.
- Update toolSystemRole turn-protocol exception accordingly; mark
  persistence as best-effort (do not retry on failure) since the
  Pre-Finish Checklist is overridden on early exit.
- Update OnboardingActionHintInjector L101/L127 hints to match the new
  flow, and append an EXCEPTION clause to the Summary not-opened hint
  so a true exit signal in Summary skips the marketplace too.
- Strip CJK example phrases from prompt text; rely on the LLM's
  multilingual recognition with "equivalents in any language" hints.

* 🔨 refactor(FollowUpChips): remove unused consume function and reset editor state on chip click
🔨 style(InterventionBar): remove overflow hidden from container style

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

* 🐛 fix(ci): align FollowUpChips test with removed consume and increase timeout for PGlite cold-start

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-05-11 15:45:54 +08:00
Neko 79ed4b5faf feat(agent-signal,server,prompts): consolidate in self-review implemented (#14657) 2026-05-11 15:14:02 +08:00
Arvin Xu d4a33d4434 💄 style(hetero-agent): read-only SubAgent threads with breadcrumb header and thread switcher (#14658)
*  feat(hetero-agent): read-only SubAgent threads with breadcrumb header and thread switcher

- Hide chat input on SubAgent threads (execution is driven by the parent agent) and replace it with an inline read-only hint
- Render the hint as the last item inside the virtual list so it scrolls with messages instead of being pinned to the viewport bottom
- ChatList exposes a new `footerSlot` prop that VirtualizedList injects as a synthetic trailing data item
- Header now shows `topic / thread` breadcrumb; thread title is a popover trigger that lists sibling threads in the same topic for one-click switching
- Hide the working-directory tag while inside a thread — directory switching doesn't belong in this read-only view
- Unify user-facing strings to "SubAgent" (badge, hint, open/close labels)

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

* 💄 style(chat-input): soften queue tray preview borders

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

* 🐛 fix(conversation): scrollToBottom lands on the true last VList item

scrollToBottom targeted displayMessages.length - 1, which leaves any
trailing synthetic items (spacer, SubAgent footer hint) below the
viewport. In SubAgent threads this kept atBottom = false after the
BackBottom click or auto-scroll, so the button appeared stuck.

VirtuaScrollMethods now exposes getTotalCount, which VirtualizedList
fills from the live data length (messages + spacer + optional
footerSlot) via a ref. scrollToBottom uses that to scroll to the real
last index.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:42:31 +08:00
Arvin Xu db22573a88 💄 style(chat-input): show skeleton in action bar while config is loading (#14656)
* 💄 style(chat-input): show skeleton in action bar while config is loading

Before agent / group config hydrates, action buttons read DEFAULT_*
fallbacks and the send button would dispatch against a not-yet-ready
target. Add an `isConfigLoading` prop on DesktopChatInput that swaps the
action bar + send area for skeleton placeholders. The chat page passes
`agentSelectors.isAgentConfigLoading`, group chat passes
`agentGroupSelectors.isGroupsInit`. The editor itself stays usable so
users can start typing immediately.

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

* 💄 style(home,i18n): use 已阅 for brief confirm/confirmDone in zh-CN

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

* 🐛 fix(home): use 确认完成 for brief.action.confirmDone in zh-CN

confirmDone signals the terminal transition (task marked complete),
not just dismissing the brief, so 已阅 loses the semantic distinction
from `confirm`. Use 确认完成 to match the EN intent ("Confirm complete").

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

* 🐛 fix(home): use "Confirm complete" for brief.action.confirmDone in en-US

Match the semantic distinction the call site relies on:
`confirm` is dismiss-only for recurring scheduled runs, while
`confirmDone` marks the terminal completion transition. The test
mock already used "Confirm complete" — align the source defaults.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:56:45 +08:00
Arvin Xu 399db9963a 💄 style(home): add Recommendations module with hetero agent action library (#14645)
*  feat(home): add Recommendations module with hetero agent action library

Introduce a `Recommendations` section that renders above the existing daily-brief
task templates. The module is driven by an extensible action registry with per-action
eligibility checks; the first registered actions surface "Add Claude Code agent" and
"Add Codex agent" cards on desktop when the matching local CLI is detected and the
user hasn't added that hetero agent yet.

- New `src/features/Recommendations/` with action types, registry, hetero-agent
  factory, eligibility hook, parallel CLI detection (SWR-cached) and card UI.
- Extract `createHeterogeneousAgent` from `useCreateMenuItems` into a shared
  `useCreateHeteroAgent` hook so the sidebar menu and Recommendations card share
  one creation path (create + refresh sidebar + navigate to chat).
- `DailyBrief` now renders `<Recommendations />` in place of the standalone
  template-only section; visibility is driven by the new
  `useRecommendationsVisible` hook.
- Add `recommendations.*` i18n keys to the `home` namespace (default + zh-CN +
  en-US dev preview).

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

* 💄 style(home): polish Recommendations card with brand avatar and tighter copy

Use brand Avatar icons with rounded square shape, drop the duplicate title, and tighten copy (Coding Agent tag, Add Agent CTA).

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:18:55 +08:00
Rdmclin2 d5562f9933 🔨 chore: optimize system bot (#14649)
* feat: add already consumed alert

* feat: support slack send slack commends  emphemeral in channel

* chore: handle parse commands imperial

* fix: slack messenger callback ok

* feat: add messager connectionId per user

* fix: add userId to webhookbody

* fix: test case
2026-05-11 02:02:33 +07:00
Arvin Xu 5f24d179d4 feat(hetero-agent): support AskUserQuestion tools for claude code (#14639)
*  feat(hetero-agent): AskUserQuestion MCP server + bridge skeleton (LOBE-8725 step 1+2)

Foundation for LOBE-8725 — interactive AskUserQuestion via local MCP. CC's
built-in tool short-circuits in `-p` mode, so we host an in-process MCP
server that exposes an equivalent `ask_user_question` tool. The handler
blocks until the consumer submits an answer (or the 5min deadline / op
shutdown fires), surfacing a structured `agent_intervention_request` /
`agent_intervention_response` round-trip on the existing event stream.

Added in this commit:

- `packages/heterogeneous-agents/src/askUser/`
  - `AskUserBridge` — per-op pending map with timeout / cancel / progress
    keepalive support; emits an async-iterable of outbound events
  - `AskUserMcpServer` — process-wide HTTP/Streamable MCP server,
    `?op=<id>` query routes via `AsyncLocalStorage` →
    `onsessioninitialized` → sessionId↔opId map; tool handler hands off
    to the matching bridge and pumps `notifications/progress` back to CC
    every 30s as wire-level keepalive (required for >5min waits, see
    spike notes)
  - `constants.ts` — shared tool/server names + the stable `apiName`
    the adapter rewrites to
  - Unit tests cover bridge lifecycle (resolve / cancel / timeout /
    progress / event stream) and an end-to-end MCP probe via
    `StreamableHTTPClientTransport`

- `packages/agent-gateway-client/src/types.ts` — wire-level
  `agent_intervention_request` / `agent_intervention_response` event
  variants + payload interfaces. Re-exported through the package barrel.

- `packages/heterogeneous-agents/src/adapters/claudeCode.ts` — when CC's
  `tool_use` carries `mcp__lobe_cc__ask_user_question`, the adapter
  rewrites `apiName` to `askUserQuestion` so the renderer routes on a
  clean domain key. Identifier stays `claude-code`. Applied to both the
  main-agent and subagent paths for symmetry (subagent ask isn't
  expected today, but doesn't hurt).

- `src/server/routers/lambda/aiAgent.ts` — Zod input schema for
  `aiAgent.heteroIngest` extended with the two new event types so the
  CLI sandbox can forward them through the server.

No producer wiring yet — Steps 3-5 plug this into Electron main, the
renderer executor, and the new UI.

*  feat(hetero-agent): wire AskUserQuestion MCP into Electron CC driver (LOBE-8725 step 3)

Plug the Step 1 skeleton (`AskUserMcpServer` + `AskUserBridge`) into the
desktop Claude Code spawn path. CC's local MCP `ask_user_question` tool now
goes live during real prompts; renderer-submitted answers route back via
new IPC.

Changes
- `apps/desktop/src/main/modules/heterogeneousAgent/types.ts` — add
  optional `mcpConfigPath` to `HeterogeneousAgentBuildPlanParams` so
  controller-managed temp configs flow into the driver.
- `apps/desktop/src/main/modules/heterogeneousAgent/drivers/claudeCode.ts`
  — append `--mcp-config <path>` when provided. Disallowed-tools pin
  stays so CC's built-in AskUserQuestion remains off (avoids double-
  registration of the same tool name).
- `apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts`
  - Lazy-singleton `AskUserMcpServer` started on first claude-code prompt
    (de-duped concurrent first-callers via in-flight promise).
  - Per-op `setupInterventionForOp(opId, sessionId)`: registers an
    `AskUserBridge`, writes `os.tmpdir()/lobe-cc-mcp-<opId>.json` with
    `alwaysLoad: true` so CC eager-loads the tool (1-hop call, no
    ToolSearch detour — see LOBE-8725 spike), pumps `bridge.events()`
    into the existing `heteroAgentEvent` broadcast.
  - Cleanup paths: exit handler `await intervention.cleanup()` settles
    pending MCP handlers + unlinks the temp config; pre-spawn errors
    short-circuit the same cleanup so we don't leak bridges on
    `buildSpawnPlan` / trace-session failures.
  - `before-quit` stops the MCP server (in addition to killing CC
    processes).
  - New `@IpcMethod() submitIntervention({ operationId, toolCallId,
    result?, cancelled?, cancelReason? })` — renderer side will dispatch
    answers / cancellations through this in Step 4/5.
  - codex unchanged — bridge setup is gated on `agentType === 'claude-code'`.
- `src/services/electron/heterogeneousAgent.ts` — renderer-side proxy
  for `submitIntervention`.
- New `claudeCode.test.ts` covers the four driver-arg paths
  (`--mcp-config` presence, ordering vs `--resume`, AskUserQuestion stay
  disallowed). Existing 28 controller tests still pass.

What still doesn't run end-to-end
- The renderer `heteroExecutor` doesn't consume `agent_intervention_request`
  yet — events go through the broadcast but the chat store ignores them.
- No UI to render the intervention card or to call `submitIntervention`.
Both lands in Steps 4/5 next.

*  feat(hetero-agent): correlate intervention with tool message + renderer handler (LOBE-8725 step 3.5+4)

Bridge now uses the caller-supplied toolCallId (CC's `claudecode/toolUseId`
from MCP `_meta`) instead of a random UUID, so the
`agent_intervention_request` event references the same id as the existing
tool message on the renderer side.

Renderer-side `heteroExecutor` learns the new event:

- Added `persistInterventionRequest(...)` next to `persistToolResult` —
  stamps `pluginState.askUserQuestion` (apiName + identifier + questions
  parsed from `arguments` + deadline + status='pending' + toolCallId)
  onto the matching tool message via `messageService.updateToolMessage`.
- New branch in `handleStreamEvent` for `'agent_intervention_request'`:
  defers behind `persistQueue` (so it lands AFTER `persistToolBatch`
  populates `toolMsgIdByCallId`), then mirrors the same pluginState onto
  the in-memory message via `internal_dispatchMessage` so the UI lights
  up immediately — no fetchAndReplaceMessages round-trip needed.
- The eventual `tool_result` for the same toolCallId hits the existing
  `tool_result` branch unchanged: it overwrites `pluginState` with
  whatever the result carries (typically undefined for our MCP tool, so
  `pluginState.askUserQuestion` clears and the intervention UI yields to
  the regular Render).

Bridge tests cover the new contract:
- caller-supplied toolCallId becomes the wire correlation key
- duplicate-toolCallId pendings reject loudly so two-handler clobbers
  surface immediately

153 package tests + 1167 desktop main tests + 51 hetero executor tests
still green; type-check clean.

*  feat(claude-code): AskUserQuestion intervention render component (LOBE-8725 step 5)

Dedicated Render for the synthetic `askUserQuestion` apiName the adapter
rewrites the local MCP `mcp__lobe_cc__ask_user_question` tool to. Lives
under CC's render registry so the existing chat tool-detail flow picks
it up automatically — no changes to the conversation framework.

- New `AskUserQuestionItem` / `AskUserQuestionArgs` /
  `AskUserQuestionPluginState` types (mirrors CC's own
  AskUserQuestion schema verbatim).
- `ClaudeCodeApiName` gains an `AskUserQuestion = 'askUserQuestion'`
  member so the renders / inspectors / streamings registries can key
  off the same enum value.
- `client/Render/AskUserQuestion/index.tsx` is the component:
  - `pluginState.askUserQuestion?.status === 'pending'` → renders the
    questions form (Select for single-select, CheckboxGroup for
    multi-select), a 5-min countdown ticking once a second, Submit /
    Skip buttons. Reads `operationId` via `messageOperationMap` so we
    can route through `heterogeneousAgentService.submitIntervention`.
  - Otherwise → renders the questions as muted captions plus the
    final answer text from `content`. Surfaces a warning when the
    tool_result was an error (timeout / cancelled / session ended).
  - Submit button stays disabled until every question has a
    selection; Skip always enabled (sends `cancelled: true`).
- `ClaudeCodeRenders[ClaudeCodeApiName.AskUserQuestion]` registers
  the new component.

What this does NOT do
- Doesn't touch `BuiltinToolInterventions` — the form is rendered
  inside the regular tool body (Render slot), not the canonical
  intervention slot. Cleanest for now: the framework intervention
  flow assumes `submitToolInteraction` store actions, which would
  fight our IPC path. We can refactor onto that surface later if
  CC grows additional interactions (approval, file picker).
- Doesn't translate strings — i18n in a follow-up.

Type-check clean. Step 6 (real desktop e2e via CC) is next.

*  feat(claude-code): render AskUserQuestion form during pending state (LOBE-8725 step 5 follow-up)

Step 5 registered the Render component but stopped at the registry — the
chat tool-detail still returned the loading placeholder while
`isToolCalling` was true, so users only ever saw a spinner during the 5
min intervention window.

Detect `pluginState.askUserQuestion?.status === 'pending'` (only set on
CC + apiName=askUserQuestion tool messages) and route to the registered
builtin Render inline before the placeholder branch. Once the
intervention resolves, the eventual `tool_result` clears
`pluginState.askUserQuestion` and the regular Render takes over.

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

*  feat(hetero-agent): wire regenerate / continue for hetero runtime (LOBE-8519 follow-up)

LOBE-8519 left two TODOs in `generationSlice` where hetero runtime
silently fell through to client mode — regenerate would secretly hit the
agent's underlying LLM, and continue would synthesize a fake "please
continue" turn that confuses CC / Codex.

- regenerateMessage: re-create the assistant row branched off the same
  user message, resolve resume sessionId (drop on cwd mismatch), then
  spawn a child `execHeterogeneousAgent` op so Stop only kills the
  executor, not the parent regenerate op. Mirrors sendMessage's hetero
  branch.
- continueGenerationMessage: hetero CLIs have no continue primitive —
  each prompt is a fresh user turn — so bail out instead of polluting
  the session.
- continueGenerationMessage: gateway mode now branches a server-side
  resume run instead of falling through to client.

Surfaced while testing CC AskUserQuestion end-to-end on the
LOBE-8725 branch (regenerating after an answered question went through
the wrong runtime).

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

* 🐛 fix(local-testing): electron-dev.sh boots on macOS bash 3.2

Two bugs surfaced when invoking the local-testing helper from a fresh
session on macOS:

- `find_project_pids` / `do_stop` end with `grep -v '^$'` whose exit
  code propagates through `pipefail`. With `set -e`, an empty pid set
  silently kills the whole script — `do_start` reported success, no
  Electron, no error. Trail with `|| true`.
- `setsid` is GNU coreutils, not on macOS. Fall back to plain `bash -c`;
  process-tree teardown still works because `expand_descendants` walks
  the tree directly.

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

* 🐛 fix(hetero-agent): per-session MCP transport for sequential ops (LOBE-8725)

`AskUserMcpServer` shared a single `StreamableHTTPServerTransport` across
every CC subprocess. The SDK transport latches `_initialized=true`
after the first `initialize`, so the second op's CC subprocess sees
`Invalid Request: Server already initialized` (400) and reports the
`lobe_cc` server as `failed`. From the model's POV the MCP tool is
absent — it falls back to ToolSearch, can't find anything, and
verbalizes the question instead.

Refactor to the canonical multi-tenant pattern: one transport + one
`McpServer` per session, looked up by the SDK-managed `mcp-session-id`
header. New transports are minted on the first POST without a session
id (must be an `initialize` request); subsequent requests route via
the stored map; `onsessionclosed` cleans up.

The first run of any process still works as before — this only matters
once a second op spins up. Added a 3-op sequential regression test
that fails on the old single-transport implementation and passes now.

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

* ♻️ refactor(claude-code): move AskUserQuestion onto canonical Intervention surface (LOBE-8725)

Step 5's first cut shoehorned the pending form into the Render slot and
drove submit/skip with a custom `pluginState.askUserQuestion.status`
field, which forced three layers of glue:

- `Tool/Detail` had to bypass the loading placeholder via an
  identifier+apiName hardcode so the form would surface during
  `isToolCalling`
- The executor had to `messageService.getMessages → replaceMessages`
  after `agent_intervention_request` to drag the freshly-created tool
  row into in-memory state (the framework's own `tool_end →
  fetchAndReplaceMessages` only fires after the user answers)
- The executor also had to `associateMessageWithOperation` for the tool
  row so the form could look up the running CC op for IPC

All three were patches around skipping the canonical surface. This
commit moves AskUserQuestion onto `pluginIntervention.status='pending'`
and the `BuiltinToolInterventions` registry, which the framework
already drives end-to-end:

- `packages/builtin-tool-claude-code/src/client/Intervention/AskUserQuestion.tsx`
  — pure form, no IPC, no store reads. Resolves through the standard
  `onInteractionAction({type:'submit'|'skip'|'cancel'})` callback.
- `Render/AskUserQuestion` shrinks to the answered/aborted view only;
  the framework hides Render while pending, so no status switching.
- New `Inspector/AskUserQuestion` shows a compact "askUserQuestion · {header}"
  chip in the inline tool body, matching the rest of CC's tools.
- Registries: `ClaudeCodeInspectors`, `ClaudeCodeRenders`, and the new
  `ClaudeCodeInterventions` all key off `ClaudeCodeApiName.AskUserQuestion`;
  `BuiltinToolInterventions` gains a `[ClaudeCodeIdentifier]` entry.

Hetero needs a different action handler than `submitToolInteraction`
(which spawns `executeClientAgent` — wrong for a CC subprocess that's
already blocked on an MCP call). Two thin pieces wire that:

- `submitHeteroIntervention` (chat store) — sets
  `pluginIntervention` via `optimisticUpdateMessagePlugin` (which
  already syncs DB + in-memory + parent-assistant `tools[].intervention`
  in one shot), then forwards the answer through
  `heterogeneousAgentService.submitIntervention` IPC. Operation lookup
  walks the tool message's `parentId` to hit the assistant's
  `messageOperationMap` entry — drops the explicit
  `associateMessageWithOperation` call from the executor.
- `customInteractionHandlers.isHeteroInteractionIdentifier` flags
  `ClaudeCodeIdentifier`; `Tool/Detail/Intervention` short-circuits
  there before reaching the existing `submitToolInteraction` path.

Executor change collapses to one line:
`optimisticUpdateMessagePlugin(toolMsgId, { intervention: { status: 'pending' } })`.
The post-intervention refresh, the associate call, and the
`persistInterventionRequest` helper all go away.

Removed:
- `AskUserQuestionPluginState` type (custom field is gone)
- `Tool/Detail` `askUserPending` inline-render branch
- Executor `messageService.getMessages + replaceMessages` round-trip
- Executor `associateMessageWithOperation` for tool rows
- `persistInterventionRequest` helper

Verified end-to-end against a real CC subprocess on desktop:
- Inline body shows the new Inspector chip; pending form lives in the
  bottom InterventionBar (canonical surface)
- Submit ships answer through MCP, CC continues with structured result
- Skip flips status to `rejected`, framework's RejectedResponse
  shows "User skipped"; CC receives isError and falls back to text
- `mcp_servers.lobe_cc.status === 'connected'` on a 3rd sequential op
  (the per-session transport fix from the previous commit)
- `alwaysLoad: true` still produces 1-hop calls (no ToolSearch hop)

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

* 💄 style(claude-code): inline numbered option cards for AskUserQuestion intervention (LOBE-8725)

Select dropdown was the wrong primitive — it hides options behind an extra
click and doesn't read like a question to answer. CC's underlying tool is
1-4 questions × 2-4 options, so the whole option set always fits inline.

- Each option renders as a clickable card: numbered chip (1/2/3/4) +
  bold label + secondary description on a single row. Hover tints the
  background; selected state lights up `colorPrimary` on both the chip
  and the card outline so the pick is unmistakable at a glance.
- Multi-select (`q.multiSelect`) toggles instead of replacing, with a
  "(multi-select)" hint in the question header.
- Multi-question support gets a proper visual hierarchy: each question
  past the first sits below a dashed divider, headed by a `Q1/N` tag
  + the original `q.header` chip. The `Q*/N` lets the user track
  progress without counting.
- Inspector picks up the question count too: now shows
  "askUserQuestion · {first header} +N" when multiple are queued.

Verified end-to-end on desktop with a CC-driven 2-question prompt
(4-option + 3-option). Both selections feed back to CC as a single
"User answers" payload, CC echoes both picks in its continuation.

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

*  feat(claude-code): tabbed multi-question + draft + timeout fallback for AskUserQuestion (LOBE-8725)

- Multi-question forms now use a top tab strip; single question renders inline.
- Picking a single-select option auto-advances to the next unanswered question.
- Drafts persist to tool message `pluginState.askUserDraft` so picks survive
  remount / HMR; new `setInterventionDraft` action on the chat store dispatches
  the pluginState patch.
- Timeout fallback: when the 5-min countdown expires, auto-submit option 1 for
  every unanswered question instead of letting the bridge time out into a
  cancelled isError — model gets a structured answer it can act on.
- Visual: selected option now uses filled `colorPrimaryBg` + right-aligned
  check icon; index chip stays neutral.

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

* 🐛 fix(hetero-agent): synchronously unlink temp mcp.json on app quit (LOBE-8725)

The async exit-handler cleanup raced Electron's main-process teardown and
left `lobe-cc-mcp-<opId>.json` files in `os.tmpdir()` after every quit. Sync
unlink in the quit hook is the only reliable guarantee.

Also handle SIGTERM / SIGINT — `before-quit` only fires on user-driven Cmd+Q
or `app.quit()`, not on external kills (test harness, OS shutdown).

Verified by manual test: pending askUserQuestion forms now leave zero
residue after both Cmd+Q and SIGTERM paths.

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

*  feat(claude-code): persist structured AskUserQuestion answers + Q&A render (LOBE-8725)

Submit now writes the structured `{ questionText: pickedLabel(s) }` payload
to the tool message's `pluginState.askUserAnswers` (in-memory + DB merge), so
Render no longer has to scrape the bridge's prose `User answers:` content.

Render shows one Q&A block per question — header + question + a checkmark
card per picked option (multi-select fans out into multiple rows). Falls
back to a `—` placeholder when answers are missing (older messages or
skipped flows), and keeps the existing `pluginError` warning for cancel /
no-answer paths.

Also surfaces the answers in the Skill state inspector tab, which was
previously empty for completed askUserQuestion messages.

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

*  test(hetero-agent): cover synchronous quit cleanup of AskUserQuestion temp configs (LOBE-8725)

Locks down the regression fixed in c0de0cdb7c — async exit-handler cleanup
losing to Electron's main-process teardown. Four cases: `before-quit`
(Cmd+Q / `app.quit()` path), `SIGTERM` (test harness / OS shutdown),
`SIGINT` (Ctrl-C), and idempotency (already-deleted temp file must not
throw on the second pass).

`process.on` and `process.exit` are stubbed in the signal-path tests so the
controller's listener attaches to a spy, not the test runner's process —
otherwise we'd leak a real SIGTERM listener every test.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:16:24 +08:00
Neko ccc8ee1315 ️ perf(agent-signal,prompts,types,database,server): fixed many minor self-review issues, harden the structure, verified with eval (#14647) 2026-05-11 00:46:30 +08:00
Arvin Xu 07eef8e7d9 💄 style(copyable-label): wrap long tool-call params instead of truncating (#14640)
* 💄 style(copyable-label): wrap long values instead of truncating

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

* ♻️ refactor(copyable-label): make wrap an opt-in via Descriptions prop

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

* 🐛 fix(descriptions): omit GridProps wrap to avoid type collision

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:29:47 +08:00
Arvin Xu ca59baa814 💄 style: format tool execution time as Xmin Ys instead of X.Y min (#14641)
🐛 fix: format tool execution time as `Xmin Ys` instead of `X.Y min`

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:28:44 +08:00
Arvin Xu 0f9b6904fd 🐛 fix(model-runtime): enrich stream parse errors with provider/model context (#14636)
*  feat(model-runtime): enrich stream parse errors with provider/model context

When the OpenAI / Anthropic SDK iterator throws (most often a JSON
SyntaxError on a malformed SSE chunk — e.g. an upstream response with an
illegal backslash escape), `convertIterableToStream` previously only
surfaced `message`/`name`/`stack`. Downstream error logs (agent-gateway
errors table) end up with just "Bad escaped character in JSON at
position 160050" and no way to correlate which provider/model produced
it or whether the same offset keeps recurring.

This change threads optional `{ provider, model }` context through
`convertIterableToStream` / `readableFromAsyncIterable` and enriches the
FIRST_CHUNK_ERROR payload with:

- `provider` / `model` so triage can group identical upstream failures
- `parsePosition` extracted from V8 JSON SyntaxError messages
- `causeName` / `causeMessage` when `error.cause` is set (many wrapped
  errors carry the actionable detail in `cause` and the bare triplet
  drops it)

Threaded through OpenAI/Responses/Anthropic stream handlers, which all
already receive `payload` containing provider/model.

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

* 🐛 fix(model-runtime): walk error.cause for parsePosition + JSON-safe payload

Two review findings on #14636:

1. Wrapped SyntaxErrors lost their parsePosition. Provider SDKs commonly
   rethrow `JSON.parse` failures wrapped in their own error class
   (e.g. `APIError(cause: SyntaxError)`), so the outer `error.name` is
   no longer `'SyntaxError'` and the previous check skipped extraction
   for the exact case this enrichment was meant to diagnose. Now
   `extractParsePosition` walks both the outer error and any `Error`
   cause, and accepts any error whose message still carries the
   `"JSON at position N"` signature even if the SyntaxError name was
   lost in wrapping.

2. Cause cloning could blow up the entire diagnostic path.
   `structuredClone` succeeds on values that `JSON.stringify` later
   throws on (BigInt, circular refs), so a non-Error cause carrying
   either would surface as `payload.cause = clonedObject`, then the
   outer `JSON.stringify(payload)` would throw inside the catch handler,
   and the FIRST_CHUNK_ERROR chunk never gets emitted. Replaced with
   `safeJsonStringify` (BigInt → string, cycles → `[Circular]`) and
   route the cause object through `toJsonSafe` so the returned shape is
   always plain JSON.

Added tests for both: a wrapped APIError(cause: SyntaxError) yields
parsePosition, and a cause containing both BigInt and a circular ref
still emits a parseable error chunk.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:09:23 +08:00
Arvin Xu a9f41c2217 🐛 fix(home): strip markdown links from daily-brief input placeholder (#14635)
The daily-brief hint will start carrying `[name](url)` markdown links so
the AI can resolve referenced entities when the user submits via the
hint. The placeholder layer is the only consumer that wants the visible
label without the link syntax — extract a small `stripMarkdownLinks`
util and apply it at `InputArea/index.tsx` only. `useSend` continues to
forward the raw hint, so the agent still receives the link in the
outgoing message.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:28:10 +08:00
YuTengjing 80916c05d9 🐛 fix: consume visual content parts in server runtime (#14637) 2026-05-10 18:33:30 +08:00
Arvin Xu 2615c00480 feat(bot): gate device tools by sender identity (#14634)
*  feat(bot): gate device tools by sender identity (LOBE-8715)

External users who @-mentioned a bot ran the agent as the bot owner and
could call LocalSystem / RemoteDevice tools — a confused-deputy hole that
let any group member indirectly read/write the owner's machine.

- `ChatTopicBotContext` carries `senderExternalUserId` + `isOwner`
- `BotMessageRouter` / `MessengerRouter` compute `isOwner` at the entry
  point (fail-closed when `settings.userId` is missing)
- `resolveDeviceAccessPolicy` maps sender identity to
  `{ canUseDevice, reason }`; trusted-list branch is reserved for future
  work without engine changes
- `AgentToolsEngine` gates `LocalSystem` + `RemoteDevice` on `canUseDevice`
- `RemoteDeviceManifest.systemRole` is no longer injected on
  external-sender turns — closes the device-list information leak
- Per-call audit log (`lobe-server:agent-device-tool-audit`) at the
  dispatch site records sender, isOwner, reason, identifier, apiName

Fixes LOBE-8715

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

* 🚨 chore(bot): replace `any` on botContext / botPlatformContext with concrete types

Picks up the existing `BotPlatformContext` (`@lobechat/context-engine`)
and `ChatTopicBotContext` (`@lobechat/types`) — both already exported —
instead of the inherited `any` placeholders on:

- `OperationCreationParams.{botContext, botPlatformContext, deviceAccessPolicy}`
- `InternalExecAgentParams.botPlatformContext`
- `RuntimeExecutorContext.botPlatformContext`

`deviceAccessPolicy.reason` is now `DeviceAccessReason` instead of `string`.

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

* 🔒 fix(bot): clear activeDeviceId when canUseDevice=false (LOBE-8715)

The previous patch gated `LocalSystemManifest` in the engine's enabledToolIds,
but `buildStepToolDelta` re-injects local-system from `state.metadata.activeDeviceId`
on every step regardless of whether the engine excluded it. Auto-activation
in `aiAgent.execAgent` populated `activeDeviceId` whenever
`(discordContext || botContext) && onlineDevices.length === 1`, so an
external bot sender with one device online could still get local-system
tools against the owner's device.

- `aiAgent/index.ts`: skip `activeDeviceId` derivation entirely when
  `canUseDevice` is false. `deviceSystemInfo` short-circuits naturally on
  `if (activeDeviceId) {...}`, so no extra change needed there.
- `RuntimeExecutors.ts`: belt-and-suspenders — if
  `state.metadata.deviceAccessPolicy.canUseDevice` is false, swallow
  `activeDeviceId` before passing to `buildStepToolDelta`, so a future
  plumbing bug at the source can't reopen the bypass.

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

* 🔒 feat(bot): allow device tools on personal-scope platforms (WeChat) (LOBE-8715)

Not every bot platform can identify an owner. WeChat's LobeHub integration
encodes every inbound thread as 1:1 (`packages/chat-adapter-wechat/src/adapter.ts:465`)
and its settings schema has no `userId` field, so `isOwner` is structurally
false on every WeChat turn. The previous policy denied every WeChat call
with `bot-owner-not-configured` — fail-closed but unusable.

This commit treats platforms whose integration is structurally personal-
scope as trusted. WeChat is the only member today; LINE is intentionally
excluded because its adapter handles group/room threads even though its
schema also lacks `userId` — those must be fixed at the schema layer
before being whitelisted.

- New `bot-personal-platform` reason in `DeviceAccessReason`
- `PERSONAL_SCOPE_BOT_PLATFORMS = new Set(['wechat'])`
- Personal-scope check sits AFTER `isOwner` so a future WeChat schema
  with a `userId` field still resolves as the more specific `bot-owner`
- Tests: WeChat without isOwner → allow; WeChat with isOwner=true → still
  `bot-owner` (more specific wins); regression guard ensuring Discord /
  Slack / Telegram / Feishu / Lark / QQ / LINE keep going through the
  standard isOwner gate

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

*  test(engine): opt existing device gate tests into canUseDevice=true (LOBE-8715)

The `LocalSystem` / `RemoteDevice` enable rules now short-circuit on
`canUseDevice` (default `false`), so tests that exercise the
engine-internal gates (`runtimeMode`, `deviceContext`, `clientRuntime`)
must explicitly pass `canUseDevice: true` — otherwise they assert the
right behavior for the wrong reason or fail outright (e.g. the desktop
RemoteDevice-suppression case the reviewer flagged).

- All `LocalSystem` / `RemoteDevice` / `LocalSystem + RemoteDevice` /
  `clientRuntime === "desktop" (Phase 6.4)` blocks now set
  `canUseDevice: true`.
- The "disable RemoteDevice in bot conversations" test was repurposed:
  the dropped `!isBotConversation` clause is now subsumed by `canUseDevice`,
  so for a trusted bot caller (canUseDevice=true) RemoteDevice DOES surface.
  The original intent — block when caller is untrusted — is captured in
  the new `canUseDevice gate` block.
- New `canUseDevice gate` describe block asserts:
    1. `canUseDevice=false` blocks LocalSystem even on a desktop caller
    2. `canUseDevice=false` blocks RemoteDevice with proxy configured
    3. Omitting `canUseDevice` → fail-closed default (deny)

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

*  test(execAgent): set isOwner=true on device auto-activation tests (LOBE-8715)

These pre-existing tests model an owner using the bot through Discord and
assert that `activeDeviceId` auto-populates when one device is online.
After LOBE-8715, `activeDeviceId` is gated on `canUseDevice` from
`resolveDeviceAccessPolicy`, so a `botContext` without `isOwner: true`
resolves to `bot-external-sender` → `canUseDevice=false` →
`activeDeviceId=undefined`.

Filling out the `botContext` mocks with `isOwner: true` (plus the other
required fields the type now demands) preserves the tests' original
intent while exercising the new gate.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:44:56 +08:00
YuTengjing 58318e97df 🐛 fix: store onboarding interests as keys (#14624) 2026-05-10 16:44:22 +08:00
Arvin Xu 4b8105b8b2 🔥 chore(web-crawler): remove WeChat URL rules (#14633)
Drop the `weixin.sogou.com` and `mp.weixin.qq.com` rules from the crawler
URL ruleset since they are no longer needed.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:28:53 +08:00
LobeHub Bot 2a65f81f0d 🌐 chore: translate non-English strings to English in apps/cli, apps/device-gateway, and apps/desktop scripts (#14626)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 16:04:17 +08:00
LiJian 1d2f0dcdb9 🐛 fix(hetero-agent): sync new-step assistant across replicas (#14631)
* 🐛 fix(hetero-agent): sync new-step assistant across replicas

* 🐛 fix(hetero-agent): tighten new-step assistant fallback

* fix: slove the test
2026-05-10 14:05:20 +08:00
LiJian 2098ac8374 🐛 fix: remove the old cron job from lobehub (#14630)
* fix: remove the old cron job from lobehub

* fix: add some ts back
2026-05-10 13:49:32 +08:00
LiJian cfe618fb50 🐛 fix: refresh content baseline from DB on every ingest call (#14603)
* 🐛 fix: refresh content baseline from DB on every ingest call

Vercel serverless routes consecutive batches to different Lambda
instances. A warm replica's in-memory `accumulatedContent` only
reflects batches it processed; it has no visibility into batches
handled by other replicas.

The failure pattern (worst when a repo is selected, since CC makes
tool calls early):

1. Lambda A — batch 1 (text "你好!...") → flushBatchContent writes
2. Lambda B — batch 2 (text "...任务。") → restores from DB, appends,
   writes longer text to DB
3. Lambda A — batch 3 (tools_calling only, warm state) → its stale
   `accumulatedContent` = batch-1 text → persistMainToolBatch Phase 1
   writes `{ tools, content: stale-short-text }` → OVERWRITES the
   correct longer DB value → content truncated at "你"

Fix: re-read the current assistant message from DB at the start of
every `ingest()` call. Since `flushBatchContent` writes at the end of
every batch, DB is authoritative. The refresh gives each Lambda the
latest flushed baseline, so new text in the current batch extends
the correct full string.

Cost: one extra `findById` round-trip per warm ingest call.

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

*  feat: auto-inject GitHub OAuth token into CC sandbox

Previously the GitHub token was only resolved when repos were selected
AND GITHUB_CRED_KEY was explicitly configured in the agent config —
so CC running without pre-selected repos had no GitHub access and had
to ask the user for a PAT manually.

Changes:
- aiAgent/index.ts: always try to resolve the token using key 'github'
  (standard LobeHub OAuth connector default); GITHUB_CRED_KEY still
  overrides. No longer guarded behind topicRepos.length > 0.
- sandboxRunner.ts: new buildCredsSetupScript() runs before CC starts:
    mkdir -p ~/.creds
    printf 'GITHUB_ACCESS_TOKEN=%s\n' <token> > ~/.creds/env
    gh auth login --hostname github.com --with-token
  Writes ~/.creds/env in the same format as injectCredsToSandbox(["github"])
  so CC can source it in sub-shells. Creds step runs before repo clone step.
- cloudHeteroContext.ts: system prompt now tells CC that GITHUB_TOKEN is
  set, gh CLI is pre-authenticated, and ~/.creds/env has GITHUB_ACCESS_TOKEN
  with the source/auth recipe for sub-shell usage.

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

* 🐛 fix: adopt max-length content on DB refresh to guard flushBatch retry

The unconditional DB overwrite in ingest() broke the retry contract:
if flushBatchContent threw after events were already marked in
processedKeys, a retry on the same warm instance would read the stale
(shorter) DB value and wipe the in-memory chunks — which processedKeys
would then skip, losing them permanently.

Fix: only adopt the DB value when it is LONGER than in-memory.
This preserves both behaviours:
- Multi-replica stale (the original fix): DB has more content from
  another replica → dbContent.length > in-memory → adopt DB. ✓
- flushBatchContent retry on same Lambda: DB still has the old shorter
  value, in-memory has the correct accumulation → keep in-memory. ✓

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 12:22:37 +08:00
Arvin Xu e3cace359b 🐛 fix(hetero-agent): disable Claude Code AskUserQuestion to avoid auto-decline (#14629)
* 🐛 fix(hetero-agent): disable Claude Code AskUserQuestion to avoid auto-decline

CC's built-in AskUserQuestion self-injects an `is_error: "Answer questions?"`
tool_result inside the CLI in `-p` non-interactive mode before the host can
surface the questions, so the model falls back to plain-text prompting after
a wasted round-trip. Add `--disallowedTools AskUserQuestion` to both spawn
sites (desktop driver + lh hetero exec) so the model goes straight to text.

To be revisited once a local MCP-backed replacement is wired to LobeHub's
intervention UI.

* ♻️ refactor(hetero-agent): share CC base args, opt-in partial deltas

- Promote CLAUDE_CODE_BASE_ARGS in `@lobechat/heterogeneous-agents/spawn` to
  the canonical source of truth for invariant CC CLI flags (`-p`, stream-json
  IO, `--verbose`, `--disallowedTools AskUserQuestion`); export it so the
  desktop driver can compose on top instead of duplicating.
- Pull `--include-partial-messages` out of the base. It's now a
  `SpawnAgentOptions.includePartialMessages` flag, off by default so
  `lh hetero exec` standalone/sandbox runs don't pay for delta noise they
  don't render. The desktop driver opts in (chat bubble streams live).
- Permission mode stays caller-specific: desktop hardcodes bypassPermissions
  (always user-mode), the package keeps its root-vs-user branch for cloud
  sandbox.

* 🎨 style(hetero-agent): pass spawn-args builders an options object

Positional list grew to four args with mixed types — switch to a single
`BuildSpawnArgsParams` object so call sites read by field name and adding
future per-agent flags doesn't push every other caller around.
2026-05-10 12:15:04 +08:00
Arvin Xu ca6c9ad7a2 🐛 fix(local-system): guard readFile against binary blobs and oversized output (#14602)
* 🐛 fix(local-system): guard readFile against binary blobs and oversized output

Previously `lobe-local-system.readFile` would happily decode any extension
as UTF-8 and return the entire content. Reading a 27KB base64-encoded git
bundle blew up the next LLM call to 3.28M tokens / 416s and triggered a
DB rollback. The default 200-line cap was bypassed because base64 was a
single very long line.

Add four layers of protection in `readLocalFile`:
- Hard-reject extensions outside the text-readable + special-parser
  whitelist with a structured error pointing the agent at runCommand.
- Sniff the first 8KB and refuse files that look binary (null bytes or
  >30% non-printable chars).
- 10MB hard size cap before the file is read into memory.
- Cap each returned line at 8K chars and total output at 500K chars,
  with `truncated` / `linesTruncated` flags surfaced in the result.

Refs LOBE-8703.

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

* 🐛 fix(file-loaders): preserve UTF-16 text files without a BOM in binary sniffer

The binary sniffer rejected UTF-16LE/BE files that lacked a BOM because
their alternating 0x00 bytes tripped the null-byte heuristic. `TextLoader`
already has a `detectUtf16NoBom` heuristic for these Windows-style exports;
extract it to a shared `detectUtf16` util and run it in the sniffer before
the null-byte check, decoding with the matching variant for the printable
ratio test instead of declaring the file binary.

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

* 💄 style(local-system): render WriteFile new files as a unified diff

Switch the WriteFile render from a syntax-highlighted preview to a
synthesized "new file" unified diff via PatchDiff, matching the
EditLocalFile visual. Markdown files keep their rendered preview.

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

*  test(local-system): exercise readFile / readFiles end-to-end

The previous LocalFileCtr.readFile / readFiles tests deep-mocked
node:fs/promises and @lobechat/file-loaders. Since the controller is a
thin pass-through to readLocalFile, the assertions ended up testing
shell internals (already covered in packages/local-file-shell), and
broke as soon as readLocalFile gained new pre-flight checks.

Move them into a sibling LocalFileCtr.readFile.test.ts that runs
against a real tmpdir + real file-loaders, so adding more upstream
guards no longer requires touching this suite.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:01:24 +08:00
YuTengjing ecaec1bf9d feat: add user activity business hook (#14601) 2026-05-10 11:18:39 +08:00
Hardy 23dced5de9 ♻️ refactor(siliconcloud): sync models with API, fix duplicates, adjust reasoning params (#14464)
* ♻️ refactor(siliconcloud): sync models with API, fix duplicates, adjust reasoning params

* 🐛 fix(siliconcloud): fix GLM-4.7 checkModel casing to match model ID
2026-05-10 10:40:52 +08:00
AmAzing- b5c4abcaef 🌐 i18n: update banner copy translations (#14623) 2026-05-10 10:28:50 +08:00
AmAzing- e72f30e53e 💬 i18n: remove trailing punctuation from banner titles (#14622) 2026-05-10 10:23:55 +08:00
YuTengjing 7bd7baf6b6 feat: add Gemini 3.1 Flash-Lite provider cards (#14604) 2026-05-10 10:04:27 +08:00
YuTengjing 78fc0931b0 ♻️ refactor: remove model extend param options (#14607) 2026-05-10 10:02:35 +08:00
René Wang b15c9e43d4 📝 docs: add intro and screenshot to task scheduler changelog (#14585) 2026-05-10 09:53:02 +08:00
Neko 25ee8221a7 🐛 fix(database,utils,userMemories): should perfer to use paradedb.match(...) instead of hardcoded normalizer (#14590) 2026-05-10 01:39:16 +08:00
Arvin Xu 8fa7607747 🐛 fix(database): attach error listeners to Neon/Node pools to prevent Lambda crash (#14606)
* 🐛 fix(database): attach error listeners to Neon/Node pools to prevent Lambda crash

NeonPool (and NodePool) inherit pg.Pool semantics: when a backend connection
drops on an idle client the pool emits 'error'. With no listener Node
escalates that into uncaughtException — on Vercel this killed the entire
Lambda process (exit 129) and produced a 1805-crash avalanche in 5 minutes,
spiking Neon connection count from 30 to 330+ as half-closed sockets
accumulated (LOBE-8704).

Primary fix: attach `.on('error', ...)` to both pool variants in
`packages/database/src/core/web-server.ts` so the error is logged but
swallowed; the pool recovers on its own per pg docs.

Defense in depth: register `uncaughtException` / `unhandledRejection`
handlers in `instrumentation.ts` (gated to nodejs runtime) so any future
unhandled error doesn't take down the process either.

Refs: https://node-postgres.com/apis/pool#error

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

* 🔧 chore: drop process-wide uncaughtException handler

Per review on #14606: the catch-all listener in instrumentation.ts swallowed
every uncaughtException / unhandledRejection — not just NeonPool errors —
leaving the process in an undefined state instead of letting the platform
restart it, and would mask future production bugs.

LOBE-8704 is fully addressed by the targeted pool listeners in
packages/database/src/core/web-server.ts; the broad backstop is unnecessary
and unsafe.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:30:16 +08:00
sxjeru d3159436e8 💄 style: Add new DeepSeek-V4 models (#14110)
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: YuTengjing <ytj2713151713@gmail.com>
2026-05-10 01:05:24 +08:00
Arvin Xu ca3879a23c 🐛 fix: gateway client-tool pluginState + drop redundant Exit code: 0 tail (#14596)
* 🐛 fix(agent-runtime): forward pluginState through gateway client tool result

Gateway-mode client tool results lost the `state` field at three points:
the toolResult Zod schema didn't declare it (silently stripped by safeParse),
the ToolResultPayload interface didn't carry it, and projectToExecutionResult
didn't return it. As a result the "技能状态" tab was always empty for tools
dispatched via Agent Gateway, even though clients send `state` correctly and
non-gateway paths persist it as `pluginState`.

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

* 🐛 fix(prompts): suppress redundant `Exit code: 0` tail in command result

For successful runs, "Command completed successfully." already conveys
the same signal — appending "Exit code: 0" was just noise the LLM had
to skim past. Non-zero exit codes (130 SIGINT, 137 OOM, etc.) keep the
line so the diagnostic information remains available.

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

* 🐛 fix(prompts): treat non-zero exit code as command failure in result header

`success` is the envelope ("the service responded") and `exitCode` is the
command's own status — they're independent. With `success: true` +
`exitCode: 137` the prior format rendered "Command completed successfully."
on top of a SIGKILL/OOM, lying to the LLM.

Now the header is derived from both: any non-zero exit folds the message
into the failure branch as "Command failed with exit code N[: error]".
The trailing "Exit code: N" line is gone — the same info now lives in the
header, so success rendering is also free of the redundant zero tail.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:53:31 +08:00
sxjeru 7a3de98348 🐛 fix(gemini): handle zero cachedContentTokenCount in usage conversion (#14567)
Co-authored-by: YuTengjing <ytj2713151713@gmail.com>
2026-05-10 00:36:26 +08:00
Arvin Xu 56ddccdc1c 💄 style(topic): add copy session ID to topic dropdown menu (#14595)
 feat(topic): add copy session ID to topic dropdown menu

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:26:39 +08:00
Arvin Xu cd2c074843 feat: home daily brief with linkable welcome + paired input hint (#14589)
*  feat: home daily brief with linkable welcome + paired input hint

Add a per-user "daily brief" surface to the home page. A cron-driven
backend (in the cloud repo) writes paired { welcome, hint } entries
into Redis under `aiGeneration:home_brief:{userId}`. This change exposes
that data through:

- `RedisKeys.aiGeneration.homeBrief` key builder
- `home.getDailyBrief` lambda router query that reads the cached payload
- `homeService.getDailyBrief` client and `useHomeDailyBrief` hook with
  shared rotating index via `useSyncExternalStore`
- `WelcomeText` runs a custom typewriter (supports real `\n` line breaks
  and parses inline `[label](url)` markdown links so cached entity
  references become clickable; falls back to the i18n welcome list)
- `InputArea` shows the matching hint as the chat input placeholder

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

* ♻️ refactor: extract daily-brief Redis read into HomeService

Mirrors the AgentService pattern: the lambda home router was reaching
into Redis directly, which mixed I/O concerns with the routing layer.
Move the read into a dedicated `HomeService` so future home-page reads
have a clear home and the router stays thin.

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

* 🐛 fix: keep WelcomeText typewriter index in sync with shared store

Before: DailyTypewriter held its own `sentenceIndex` state, separate
from the module-level `currentIndex` in `useHomeDailyBrief`. After
the home page rotated past the first pair, navigating away and back
remounted the typewriter and reset its local index to 0 — but the
external index stayed where it was. InputArea read the hint at the
stale external index while WelcomeText restarted at pair 0, breaking
the welcome / hint pairing.

Make the typewriter fully controlled: drop the local `sentenceIndex`,
expose `currentIndex` from `useHomeDailyBrief`, and pass it as a prop.
On `pause`, the typewriter just calls `onSentenceComplete` — the
parent flips the shared index, the new prop flows back, the reset
effect re-arms typing for the new sentence. Single source of truth,
remount-safe.

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

* ♻️ refactor(redis): factor JSON cache reads into getJSONFromRedis util

Three call sites were inlining the same "fetch + null-check + JSON.parse
+ try/catch" recipe against a scoped Redis client:

- AgentService.getAgentWelcomeFromRedis
- HomeService.readDailyBriefFromRedis (new)

Move the recipe into a small `getJSONFromRedis<T>` helper next to the
other Redis utilities and have both services delegate to it. Caller
keeps responsibility for resolving the right scoped client (we don't
want to hide the prefix selection inside the helper).

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

* 🐛 fix(home): use live editor content for Enter-to-send guard

When typing into the home input and pressing Enter immediately, the
empty-message guard sometimes wrongly bailed out. The cause: the guard
read the cached `inputMessage` in `useChatStore`, which is populated by
the editor's async `onMarkdownContentChange`. Lexical commits its
update on a microtask after each keystroke, so a fast type-then-Enter
fires the send path before the cache catches up.

`SendButtonHandler` already passes `getMarkdownContent` through — read
it instead, falling back to the cached value if the handler is invoked
without it. Also propagate the live message into all `inputActiveMode`
branches.

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

*  feat(home): accept daily-brief hint as the message on empty Enter

Press Enter on the empty home input → send the currently displayed
daily-brief hint as the message (smart-compose / Tab-to-accept style).
Trims the cosmetic trailing ellipsis and rotates the carousel so the
next press picks up a different pair.

Falls through to the previous "no content, skip" path when there's
neither a typed message nor a hint to use.

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

* 🐛 fix(home): scope daily-brief SWR key + rotation index by userId

The SWR key was a constant string, so an account switch within the same
SPA session — sign out + sign in as another user, or a multi-account
swap that keeps `isSignedIn` true — could surface the previous user's
cached pairs from the same slot. The keyspace in Redis is per-user,
so the served data leaks personalization.

Include the resolved userId in the SWR key, and reset the module-level
rotation index on user change so the new account starts from pair 0
rather than inheriting a stale offset (which could also point past the
end of a smaller pairs list).

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:52:13 +08:00
LiJian f35e2d843a 🐛 fix: first inject the cloudecc runtime session should use the existingStatus (#14592)
* 🐛 fix: skip reconnect when gateway action already established a connection

Race condition on new-topic first message:
1. switchTopic loads runningOperation → useGatewayReconnect fires
2. executeGatewayAgent calls connectToGateway (status: connecting)
3. reconnectToGatewayOperation overwrites with resumeOnConnect:true
4. Gateway sees resume on a brand-new session → no events → stuck

Second message works because the client store's runningOperation is
stale (from the first op), so SWR deduplications and no reconnect fires.

Fix: bail out of reconnectToGatewayOperation if gatewayConnections
already shows connecting/connected for that operationId.

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

* 🐛 fix: always pass --cwd /workspace for cloud CC to ensure session resume

CC stores session files at ~/.claude/projects/<encoded-cwd>/.
Without an explicit --cwd the actual working directory can differ
between sandbox invocations, so --resume <heteroSessionId> fails
to locate the previous session files even though the container is
persistent and the ID is correctly stored in topic.metadata.

Default cwd to /workspace for cloud runs (desktop keeps its own
explicit path), guaranteeing a stable session-file location across
page reloads within the same sandbox lifecycle.

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

* 🐛 fix: extend reconnect guard to cover all in-flight connection statuses

The previous guard only skipped reconnect for 'connecting'/'connected'
but the connection can already be in 'authenticating' or 'reconnecting'
by the time useGatewayReconnect fires, leaving the race window open.

Flip the condition: skip for any status that is not 'disconnected'.

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

* 🐛 fix: restore cold replica state in HeterogeneousPersistenceHandler

Vercel serverless functions are stateless per-request, so `operationStates`
is empty on every `heteroIngest` call. loadOrCreateState always cold-creates.

#14539 fixed `toolMsgIdByCallId` restoration but left `accumulatedContent`,
`toolState.payloads`, and `toolState.persistedIds` empty on cold load,
causing two bugs:

- Content truncation: cold instance starts with `accumulatedContent=''`,
  accumulates only the current batch's text, then writes that shorter string
  on the next step boundary or terminal — overwriting the longer content the
  previous write had already stored in DB.

- Tool duplication / tools[] overwrite: `persistedIds={}` on cold load
  means every `tools_calling` event re-creates already-persisted tool
  messages, and `payloads=[]` means phase 1/3 writes only the current
  batch's tools, wiping previous tools from `assistant.tools[]`.

Fix: in `loadOrCreateState`, fetch the current assistant message and restore
`accumulatedContent`, `accumulatedReasoning`, `toolState.payloads`, and
`toolState.persistedIds` from it. Cold load is now equivalent to warm load.

Also adds two regression tests covering the cold-replica scenarios.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:44:09 +08:00
Arvin Xu 53f6fe43b4 💄 style: use visible divider between queued messages (#14593)
💄 style(QueueTray): use visible divider color between queued messages

The previous `colorBorderSecondary` rendered the divider effectively
invisible on the elevated dark surface. Switch to `colorFillTertiary`
so stacked queued messages have a perceptible separator.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:06:24 +08:00
Rdmclin2 69b1d9503e 🐛 fix: slack connect error & slash commands (#14591)
* feat: displayToolCalls default undefined

* chore: restrict billboard to home page

* fix: add slack bot scope

* fix: show billboard in home nav
2026-05-09 21:43:13 +07:00
Neko 395eb8598c feat(agent-signal,prompts,database): self-review now proposal actions to briefs, and automatically execute actions (#14583) 2026-05-09 22:34:19 +08:00
lobehubbot 0516184b45 🔖 chore(release): release version v2.1.57 [skip ci] 2026-05-09 13:36:15 +00:00
2633 changed files with 181898 additions and 49918 deletions
+3 -1
View File
@@ -1,6 +1,8 @@
---
name: add-provider-doc
description: Guide for adding new AI provider documentation. Use when adding documentation for a new AI provider (like OpenAI, Anthropic, etc.), including usage docs, environment variables, Docker config, and image resources. Triggers on provider documentation tasks.
description: Add documentation for a new AI provider — usage docs, env vars, Docker config, image resources.
disable-model-invocation: true
argument-hint: '[provider-name]'
---
# Adding New AI Provider Documentation
+3 -1
View File
@@ -1,6 +1,8 @@
---
name: add-setting-env
description: Guide for adding environment variables to configure user settings. Use when implementing server-side environment variables that control default values for user settings. Triggers on env var configuration or setting default value tasks.
description: Add server-side environment variables that control default values for user settings.
disable-model-invocation: true
argument-hint: '[setting-name]'
---
# Adding Environment Variable for User Settings
+6 -5
View File
@@ -14,7 +14,7 @@ In `NODE_ENV=development`, `AgentRuntimeService.executeStep()` automatically rec
**Data flow**: executeStep loop -> build `StepPresentationData` -> write partial snapshot to disk -> on completion, finalize to `.agent-tracing/{timestamp}_{traceId}.json`
**Context engine capture**: In `RuntimeExecutors.ts`, the `call_llm` executor emits a `context_engine_result` event after `serverMessagesEngine()` processes messages. This event carries the full `contextEngineInput` (DB messages, systemRole, model, knowledge, tools, userMemory, etc.) and the processed `output` messages (the final LLM payload).
**Context engine capture**: In `RuntimeExecutors.ts`, the `call_llm` executor calls `ctx.tracingContextEngine(input, output)` after `serverMessagesEngine()` processes messages. `AgentRuntimeService.executeStep` buffers the call per step and forwards it to `OperationTraceRecorder.appendStep` as the typed `contextEngine` field. CE flows through this side channel rather than the `events` array so its heavy payload (agentDocuments, systemRole, …) never enters the Redis state pipeline (LOBE-9110).
## Package Location
@@ -199,9 +199,10 @@ interface StepSnapshot {
messages?: any[]; // DB messages before step
context?: { phase: string; payload?: unknown; stepContext?: unknown };
events?: Array<{ type: string; [key: string]: unknown }>;
// context_engine_result event contains:
// input: full contextEngineInput (messages, systemRole, model, knowledge, tools, userMemory, ...)
// output: processed messages array (final LLM payload)
contextEngine?: {
input?: unknown; // contextEngineInput minus messages + toolsConfig (reconstructible from baseline)
output?: unknown; // processed messages array (final LLM payload)
};
}
```
@@ -216,5 +217,5 @@ When using `--messages`, the output shows three sections (if context engine data
## Integration Points
- **Recording**: `src/server/services/agentRuntime/AgentRuntimeService.ts` — in the `executeStep()` method, after building `stepPresentationData`, writes partial snapshot in dev mode
- **Context engine event**: `src/server/modules/AgentRuntime/RuntimeExecutors.ts` — in `call_llm` executor, after `serverMessagesEngine()` returns, emits `context_engine_result` event
- **Context engine capture**: `src/server/modules/AgentRuntime/RuntimeExecutors.ts` — in `call_llm` executor, after `serverMessagesEngine()` returns, calls `ctx.tracingContextEngine(input, output)`. `AgentRuntimeService.executeStep` buffers it per step and passes it to `traceRecorder.appendStep` as the typed `contextEngine` field (kept off the `events` array to stay out of Redis state).
- **Store**: `FileSnapshotStore` reads/writes to `.agent-tracing/` relative to `process.cwd()`
+6 -6
View File
@@ -19,11 +19,11 @@ A builtin tool is a package the agent runtime can call. It ships **five faces**:
## Read These First
| Question | Doc |
| ------------------------------------------------------------------------------------ | ---------------------------------- |
| Where do files live? What does each face do? Wiring? | [architecture.md](architecture.md) |
| How do I name the tool, design APIs, write the manifest, executor, ExecutionRuntime? | [tool-design.md](tool-design.md) |
| How do I build Inspector / Render / Placeholder / Streaming / Intervention / Portal? | [ui.md](ui.md) |
| Question | Doc |
| ------------------------------------------------------------------------------------ | --------------------------------------------- |
| Where do files live? What does each face do? Wiring? | [architecture.md](references/architecture.md) |
| How do I name the tool, design APIs, write the manifest, executor, ExecutionRuntime? | [tool-design.md](references/tool-design.md) |
| How do I build Inspector / Render / Placeholder / Streaming / Intervention / Portal? | [ui.md](references/ui.md) |
---
@@ -109,7 +109,7 @@ Before opening the PR:
- [ ] Placeholder added if the API has a perceivable execution lag (search, list, crawl).
- [ ] Streaming added for APIs that emit incremental output (run command, write file, code execution).
- [ ] Intervention added if `humanIntervention` is set in the manifest.
- [ ] All registry files updated (see [architecture.md → Registry wiring](architecture.md#registry-wiring)).
- [ ] All registry files updated (see [architecture.md → Registry wiring](references/architecture.md#registry-wiring)).
- [ ] i18n keys in `src/locales/default/plugin.ts` plus dev seeds in `en-US`/`zh-CN`.
- [ ] `bunx vitest run --silent='passed-only' 'packages/builtin-tool-<name>'` passes.
- [ ] `bun run type-check` passes.
@@ -213,7 +213,7 @@ The runtime hands every executor method an optional `BuiltinToolContext` as the
| `operationId` | Operation lineage (use for cancellation, tracing) |
| `scope` | `'task' \| 'agent' \| …` — toggles default behaviors |
| `signal: AbortSignal` | Honor for long-running ops |
| `stepContext` | Cross-message runtime state (GTD todos, etc.) |
| `stepContext` | Cross-message runtime state (lobe-agent todos, etc.) |
| `registerAfterCompletion(cb)` | Defer side-effects past message-update race |
| `groupOrchestration` | Group orchestration callbacks |
@@ -18,6 +18,28 @@ The two reference tools to read end-to-end:
---
## Tool Render 设计原则(中文草案)
这些原则用于判断一个 builtin tool 的 Inspector / Render / Placeholder / Streaming / Intervention / Portal 应该做什么,以及做到什么程度。
1. **先保证折叠态可读。** 每个 API 都必须有 Inspector;用户不展开也应该能看懂 “正在做什么 / 对什么做 / 当前结果是什么”。Inspector 不应该只展示函数名和原始参数。
2. **Inspector 是一句话,不是详情页。** 优先表达动作、关键对象、数量、状态,例如 “分析图片 3 张”“搜索 12 个结果”“读取 config.json”。长文本、列表和结构化结果放到 Render 或 Portal。
3. **Inspector 要覆盖执行生命周期。** `args` 还在 streaming、工具执行中、执行完成、执行失败时都应该有稳定展示;必要时同时读取 `args``partialArgs``pluginState`,避免出现空白、跳变或只显示半截参数。
4. **文案要随状态切换时态。** 同一个动作在 loading 与 completed 两个阶段必须用不同的措辞:执行中用现在进行时(“正在创建任务 / Creating task / 正在搜索”),执行完成后切到完成态(“已创建任务 / Task created / 已找到 N 条”)。Inspector chip 会一直留在聊天记录里 —— 如果一直挂着 “正在 xxx”,几小时后回看历史时会读起来像还在跑。约定的 i18n 形式是 `<api>.loading` / `<api>.completed` 一对键(见 `lobe-agent.apiName.callSubAgent.{loading,completed}``lobe-claude-code.task.{create,list,update,get}.{loading,completed}`),渲染时按 `isArgumentsStreaming || isLoading` 决定取哪一个。只读 / 查询类(“查看任务” 这种本来就是名词性的)可以共用一个键。
5. **只有结构化结果才需要 Render。** 如果工具结果只是自然语言总结,通常不需要 Render;如果结果包含列表、媒体、文件、表格、代码、diff、地图、时间线、权限请求等结构,就应该提供 Render。
6. **Render 要帮助用户检查结果,而不是复述参数。** Render 的主体应该围绕工具产物组织:可预览、可比较、可筛选、可定位。参数只作为上下文辅助出现,不要把 Render 做成一块更大的 args dump。
7. **参数和结果要一起参与渲染。** 好的 Tool UI 通常同时用 `args` 解释意图,用 `pluginState` 展示真实执行结果;但 `pluginState` 只放结果域数据,不要反向塞入可以从 `args` 推导出的内容。
8. **慢操作要有 Placeholder。** 如果工具通常需要等待网络、文件系统、模型或外部进程,Placeholder 应该先占住最终 Render 的版式,让用户知道即将看到什么,而不是只显示一个泛化 loading。
9. **Streaming 只用于连续产物。** 搜索列表、日志、长文本、文件分析、分阶段计划适合 Streaming;一次性小结果不需要强行做 Streaming。Streaming UI 要能渐进追加,并且完成后自然过渡到最终 Render。
10. **有风险的动作必须 Intervention。** 写文件、删除、发送、安装、执行命令、外部可见操作、权限敏感操作,都应该在执行前给出可理解的确认界面;确认文案要说明影响范围,而不是只问 “是否继续”。
11. **错误、空态和截断都是正式状态。** Render 不能在失败、无结果、超长结果时退化成空白。错误要说明发生在哪一步;空态要告诉用户没有产物;超长内容要明确 “展示前 N 项 / 还有 N 项”。
12. **信息密度要克制。** 默认展示最有判断价值的部分:标题、来源、状态、摘要、少量关键字段。大对象、长列表、原文、调试数据放进可展开区域或 Portal,避免把聊天流撑成后台管理页。
13. **视觉上融入聊天流。** Tool UI 应该使用 `@lobehub/ui` / base-ui、`Flexbox``createStaticStyles``cssVar.*`,遵循现有间距、圆角、颜色、字号;不要为单个工具发明一套独立视觉语言。
14. **Devtools fixture 是验收入口。** 新增或修改 Tool UI 时,应在 `/devtools` 里准备覆盖典型态、loading/streaming、空态、错误态、长内容态的 fixture;一个 API 如果在真实聊天里会出现,就不应该在 devtools 中缺席。
15. **先做用户会看的 UI,再做调试 UI。** Raw JSON、trace、schema、内部 id 可以存在,但应默认收起或放到调试区;主界面先回答用户最关心的问题:工具做了什么,结果值不值得信任,下一步能做什么。
---
## 0. Shared Style Rules
These apply across every surface.
@@ -179,6 +201,7 @@ export default SearchInspector;
- Read both `args?.X` and `partialArgs?.X` together — `args` is final, `partialArgs` is in-stream.
- Use chips/tags for distinct facets (identifier, name, parent, status, count). Each chip should clip with `text-overflow: ellipsis` and have a `max-width` so long values don't blow out the chat bubble.
- Append `pluginState`-derived suffixes only **after** loading finishes — count or "(no results)" should not appear while still searching.
- **Switch copy by phase.** If the verb implies an ongoing action ("Creating", "Searching", "Listing"), define `<api>.loading` and `<api>.completed` keys and select via `isArgumentsStreaming || isLoading ? loadingKey : completedKey`. Inspector chips persist in chat history — leaving "Creating task" frozen on a finished call reads as if the tool is still running. Read-only labels that are already noun-form ("View task") can keep a single key. See `CallSubAgentInspector` for the canonical two-key pattern.
### Inspector registry — `client/Inspector/index.ts`
+2 -8
View File
@@ -1,13 +1,7 @@
---
name: chat-sdk
description: >
Build multi-platform chat bots with Chat SDK (`chat` npm package). Use when developers want to
(1) Build a Slack, Teams, Google Chat, Discord, GitHub, or Linear bot,
(2) Use the Chat SDK to handle mentions, messages, reactions, slash commands, cards, modals, or streaming,
(3) Set up webhook handlers for chat platforms,
(4) Send interactive cards or stream AI responses to chat platforms.
Triggers on "chat sdk", "chat bot", "slack bot", "teams bot", "discord bot", "@chat-adapter",
building bots that work across multiple chat platforms.
description: "Build multi-platform chat bots with the Chat SDK (`chat` npm package) — Slack, Teams, Google Chat, Discord, GitHub, Linear. Use when building a chat bot, handling mentions / messages / reactions / slash commands / cards / modals / streaming, setting up a webhook handler, or sending interactive cards / streaming AI responses to a chat platform. Triggers on `@chat-adapter`, 'chat sdk', 'chat bot', 'slack bot', 'teams bot', 'discord bot', 'webhook handler', 'cross-platform bot'."
user-invocable: false
---
# Chat SDK
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: cli
description: LobeHub CLI (@lobehub/cli) development guide. Use when working on CLI commands, adding new subcommands, fixing CLI bugs, or understanding CLI architecture. Triggers on CLI development, command implementation, or `lh` command questions.
description: LobeHub CLI (@lobehub/cli) development guide — commands, subcommands, architecture.
disable-model-invocation: true
---
@@ -0,0 +1,614 @@
---
name: data-fetching-architecture
description: Standardized data-fetching pipeline guide — Service layer + Zustand Store + SWR. Use when implementing a data-fetching feature, creating a `xxxService`, adding a `useFetchXxx` hook, wiring `useClientDataSWR`, or migrating ad-hoc `useEffect + fetch` to the standard pipeline. Triggers on `lambdaClient`, `useClientDataSWR`, `xxxService`, `useFetchXxx`, 'data fetching', 'fetch architecture', 'service layer', 'SWR hook', 'migrate useEffect'.
user-invocable: false
---
# LobeHub Data Fetching Architecture
> **Related:** `store-data-structures` covers List vs Detail data shape rationale (Map vs Array).
## Architecture Overview
```text
┌─────────────┐
│ Component │
└──────┬──────┘
│ 1. Call useFetchXxx hook from store
┌──────────────────┐
│ Zustand Store │
│ (State + Hook) │
└──────┬───────────┘
│ 2. useClientDataSWR calls service
┌──────────────────┐
│ Service Layer │
│ (xxxService) │
└──────┬───────────┘
│ 3. Call lambdaClient
┌──────────────────┐
│ lambdaClient │
│ (TRPC Client) │
└──────────────────┘
```
## Core Principles
### ✅ DO
1. **Use Service Layer** for all API calls
2. **Use Store SWR Hooks** for data fetching (not useEffect)
3. **Use proper data structures** — see `store-data-structures` skill for List vs Detail patterns
4. **Use lambdaClient.mutate** for write operations (create/update/delete)
5. **Use lambdaClient.query** only inside service methods
6. **Naming convention** — read hooks are `useFetchXxx`, cache invalidation helpers are `refreshXxx` (e.g. `useFetchBenchmarks` / `refreshBenchmarks`). Mutations then chain `refreshXxx()` after the service call.
### ❌ DON'T
1. **Never use useEffect** for data fetching
2. **Never call lambdaClient** directly in components or stores
3. **Never use useState** for server data
4. **Never mix data structure patterns** — follow `store-data-structures` skill
---
## Layer 1: Service Layer
### Purpose
- Encapsulate all API calls to lambdaClient
- Provide clean, typed interfaces
- Single source of truth for API operations
### Service Structure
```typescript
// src/services/agentEval.ts
class AgentEvalService {
// Query methods - READ operations
async listBenchmarks() {
return lambdaClient.agentEval.listBenchmarks.query();
}
async getBenchmark(id: string) {
return lambdaClient.agentEval.getBenchmark.query({ id });
}
// Mutation methods - WRITE operations
async createBenchmark(params: CreateBenchmarkParams) {
return lambdaClient.agentEval.createBenchmark.mutate(params);
}
async updateBenchmark(params: UpdateBenchmarkParams) {
return lambdaClient.agentEval.updateBenchmark.mutate(params);
}
async deleteBenchmark(id: string) {
return lambdaClient.agentEval.deleteBenchmark.mutate({ id });
}
}
export const agentEvalService = new AgentEvalService();
```
### Service Guidelines
1. **One service per domain** (e.g., agentEval, ragEval, aiAgent)
2. **Export singleton instance** (`export const xxxService = new XxxService()`)
3. **Method names match operations** (list, get, create, update, delete)
4. **Clear parameter types** (use interfaces for complex params)
---
## Layer 2: Store with SWR Hooks
### Purpose
- Manage client-side state
- Provide SWR hooks for data fetching
- Handle cache invalidation
### State Structure
```typescript
// src/store/eval/slices/benchmark/initialState.ts
export interface BenchmarkSliceState {
// List data - simple array
benchmarkList: AgentEvalBenchmarkListItem[];
benchmarkListInit: boolean;
// Detail data - map for caching
benchmarkDetailMap: Record<string, AgentEvalBenchmark>;
loadingBenchmarkDetailIds: string[];
// Mutation states
isCreatingBenchmark: boolean;
isUpdatingBenchmark: boolean;
isDeletingBenchmark: boolean;
}
```
> For complete initialState, reducer, and internal dispatch patterns, see the `store-data-structures` skill.
### Actions
```typescript
// src/store/eval/slices/benchmark/action.ts
const FETCH_BENCHMARKS_KEY = 'FETCH_BENCHMARKS';
const FETCH_BENCHMARK_DETAIL_KEY = 'FETCH_BENCHMARK_DETAIL';
export interface BenchmarkAction {
// SWR Hooks - for data fetching
useFetchBenchmarks: () => SWRResponse;
useFetchBenchmarkDetail: (id?: string) => SWRResponse;
// Refresh methods - for cache invalidation
refreshBenchmarks: () => Promise<void>;
refreshBenchmarkDetail: (id: string) => Promise<void>;
// Mutation actions
createBenchmark: (params: CreateParams) => Promise<any>;
updateBenchmark: (params: UpdateParams) => Promise<void>;
deleteBenchmark: (id: string) => Promise<void>;
// Internal methods - not for direct UI use
internal_dispatchBenchmarkDetail: (payload: BenchmarkDetailDispatch) => void;
internal_updateBenchmarkDetailLoading: (id: string, loading: boolean) => void;
}
export const createBenchmarkSlice: StateCreator<EvalStore, any, [], BenchmarkAction> = (
set,
get,
) => ({
// Fetch list — simple array stored in benchmarkList
useFetchBenchmarks: () =>
useClientDataSWR(FETCH_BENCHMARKS_KEY, () => agentEvalService.listBenchmarks(), {
onSuccess: (data) => {
set({ benchmarkList: data, benchmarkListInit: true }, false, 'useFetchBenchmarks/success');
},
}),
// Fetch detail — null key disables the request when id is missing
useFetchBenchmarkDetail: (id) =>
useClientDataSWR(
id ? [FETCH_BENCHMARK_DETAIL_KEY, id] : null,
() => agentEvalService.getBenchmark(id!),
{
onSuccess: (data) => {
get().internal_dispatchBenchmarkDetail({
type: 'setBenchmarkDetail',
id: id!,
value: data,
});
get().internal_updateBenchmarkDetailLoading(id!, false);
},
},
),
// Refresh methods
refreshBenchmarks: () => mutate(FETCH_BENCHMARKS_KEY),
refreshBenchmarkDetail: (id) => mutate([FETCH_BENCHMARK_DETAIL_KEY, id]),
// CREATE — refresh list after creation
createBenchmark: async (params) => {
set({ isCreatingBenchmark: true }, false, 'createBenchmark/start');
try {
const result = await agentEvalService.createBenchmark(params);
await get().refreshBenchmarks();
return result;
} finally {
set({ isCreatingBenchmark: false }, false, 'createBenchmark/end');
}
},
// UPDATE — optimistic update + refresh
updateBenchmark: async (params) => {
const { id } = params;
// 1. Optimistic update
get().internal_dispatchBenchmarkDetail({
type: 'updateBenchmarkDetail',
id,
value: params,
});
// 2. Set loading
get().internal_updateBenchmarkDetailLoading(id, true);
try {
// 3. Call service
await agentEvalService.updateBenchmark(params);
// 4. Refresh from server
await get().refreshBenchmarks();
await get().refreshBenchmarkDetail(id);
} finally {
get().internal_updateBenchmarkDetailLoading(id, false);
}
},
// DELETE — optimistic update + refresh
deleteBenchmark: async (id) => {
get().internal_dispatchBenchmarkDetail({ type: 'deleteBenchmarkDetail', id });
get().internal_updateBenchmarkDetailLoading(id, true);
try {
await agentEvalService.deleteBenchmark(id);
await get().refreshBenchmarks();
} finally {
get().internal_updateBenchmarkDetailLoading(id, false);
}
},
// Internal — dispatch to reducer (for detail map)
internal_dispatchBenchmarkDetail: (payload) => {
const currentMap = get().benchmarkDetailMap;
const nextMap = benchmarkDetailReducer(currentMap, payload);
// Skip set when nothing changed — avoids unnecessary re-renders
if (isEqual(nextMap, currentMap)) return;
set({ benchmarkDetailMap: nextMap }, false, `dispatchBenchmarkDetail/${payload.type}`);
},
// Internal — update loading state for specific detail
internal_updateBenchmarkDetailLoading: (id, loading) => {
set(
(state) => ({
loadingBenchmarkDetailIds: loading
? [...state.loadingBenchmarkDetailIds, id]
: state.loadingBenchmarkDetailIds.filter((i) => i !== id),
}),
false,
'updateBenchmarkDetailLoading',
);
},
});
```
### Store Guidelines
1. **SWR keys as constants** at top of file
2. **useClientDataSWR** for all data fetching (never useEffect)
3. **onSuccess callback** updates store state
4. **Refresh methods** use `mutate()` to invalidate cache
5. **Loading states** in initialState, updated in onSuccess
6. **Mutations** call service, then refresh relevant cache
---
## Layer 3: Component Usage
### Fetching List Data
```tsx
// ✅ CORRECT
const BenchmarkList = () => {
// 1. Get the hook from store
const useFetchBenchmarks = useEvalStore((s) => s.useFetchBenchmarks);
// 2. Get list data
const benchmarks = useEvalStore((s) => s.benchmarkList);
const isInit = useEvalStore((s) => s.benchmarkListInit);
// 3. Call the hook (SWR handles the data fetching)
useFetchBenchmarks();
// 4. Use the data
if (!isInit) return <Loading />;
return (
<div>
<h2>Total: {benchmarks.length}</h2>
{benchmarks.map((b) => (
<BenchmarkCard key={b.id} {...b} />
))}
</div>
);
};
```
### Fetching Detail Data
```tsx
// ✅ CORRECT
const BenchmarkDetail = () => {
const { benchmarkId } = useParams<{ benchmarkId: string }>();
const useFetchBenchmarkDetail = useEvalStore((s) => s.useFetchBenchmarkDetail);
// Detail from map
const benchmark = useEvalStore((s) =>
benchmarkId ? s.benchmarkDetailMap[benchmarkId] : undefined,
);
// Per-item loading
const isLoading = useEvalStore((s) =>
benchmarkId ? s.loadingBenchmarkDetailIds.includes(benchmarkId) : false,
);
useFetchBenchmarkDetail(benchmarkId);
if (!benchmark) return <Loading />;
return (
<div>
<h1>{benchmark.name}</h1>
<p>{benchmark.description}</p>
{isLoading && <Spinner />}
</div>
);
};
```
### Using Selectors (Recommended)
```typescript
// src/store/eval/slices/benchmark/selectors.ts
export const benchmarkSelectors = {
getBenchmarkDetail: (id: string) => (s: EvalStore) => s.benchmarkDetailMap[id],
isLoadingBenchmarkDetail: (id: string) => (s: EvalStore) =>
s.loadingBenchmarkDetailIds.includes(id),
};
// Component with selectors
const BenchmarkDetail = () => {
const { benchmarkId } = useParams();
const useFetchBenchmarkDetail = useEvalStore((s) => s.useFetchBenchmarkDetail);
const benchmark = useEvalStore(benchmarkSelectors.getBenchmarkDetail(benchmarkId!));
useFetchBenchmarkDetail(benchmarkId);
return <div>{benchmark && <h1>{benchmark.name}</h1>}</div>;
};
```
### Anti-pattern
```tsx
// ❌ WRONG — Don't use useEffect for data fetching
const BenchmarkList = () => {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true);
lambdaClient.agentEval.listBenchmarks
.query()
.then(setData)
.finally(() => setLoading(false));
}, []);
return <div>...</div>;
};
```
### Mutations in Components
```tsx
// Create — global mutation flag drives form loading
const CreateBenchmarkModal = () => {
const createBenchmark = useEvalStore((s) => s.createBenchmark);
const isCreating = useEvalStore((s) => s.isCreatingBenchmark);
const handleSubmit = async (values) => {
try {
// Optimistic update + refresh happen inside createBenchmark
await createBenchmark(values);
message.success('Created successfully');
onClose();
} catch (error) {
message.error('Failed to create');
}
};
return (
<Form onSubmit={handleSubmit} loading={isCreating}>
...
</Form>
);
};
// Update / delete — per-item loading so only the row being mutated spins
const BenchmarkItem = ({ id }: { id: string }) => {
const updateBenchmark = useEvalStore((s) => s.updateBenchmark);
const deleteBenchmark = useEvalStore((s) => s.deleteBenchmark);
const isLoading = useEvalStore(benchmarkSelectors.isLoadingBenchmarkDetail(id));
const handleUpdate = async (data) => {
await updateBenchmark({ id, ...data });
};
const handleDelete = async () => {
await deleteBenchmark(id);
};
return (
<div>
{isLoading && <Spinner />}
<button onClick={handleUpdate}>Update</button>
<button onClick={handleDelete}>Delete</button>
</div>
);
};
```
**Why two patterns:** create has no id yet, so a single `isCreatingXxx` flag is enough. Update/delete target a specific row, so global flags would freeze unrelated rows — keep per-item state in `loadingXxxIds`.
---
## Need a fuller worked example?
The canonical `Benchmark` example above is the one to copy for a flat list + detail map. If you need to maintain a list **keyed by a parent id** (e.g. `datasetMap[benchmarkId]` because the same shape appears under multiple parents), read [`references/walkthrough.md`](./references/walkthrough.md) — it walks through the full 6 steps (service → reducer → slice → store wiring → selectors → component) for that variant.
---
## Common Patterns
### Pattern 1: Pagination
Cache key array must include every parameter that should trigger a refetch.
```typescript
useFetchTestCases: (params: { datasetId: string; limit: number; offset: number }) =>
useClientDataSWR(
params.datasetId ? [FETCH_TEST_CASES_KEY, params.datasetId, params.limit, params.offset] : null,
() => agentEvalService.listTestCases(params),
{
onSuccess: (data) =>
set({
testCaseList: data.data,
testCaseTotal: data.total,
isLoadingTestCases: false,
}),
},
);
```
### Pattern 2: Dependent Fetching
Both hooks run in parallel — SWR dedupes, no manual sequencing needed.
```tsx
const BenchmarkDetail = () => {
const { benchmarkId } = useParams();
const useFetchBenchmarkDetail = useEvalStore((s) => s.useFetchBenchmarkDetail);
const useFetchDatasets = useEvalStore((s) => s.useFetchDatasets);
useFetchBenchmarkDetail(benchmarkId);
useFetchDatasets(benchmarkId);
return <div>...</div>;
};
```
### Pattern 3: Conditional Fetching
Pass `undefined` to disable the hook entirely.
```tsx
// only fetch when modal is open AND id present
useFetchDatasetDetail(open && datasetId ? datasetId : undefined);
```
### Pattern 4: Cross-domain Refresh
```typescript
deleteBenchmark: async (id) => {
await agentEvalService.deleteBenchmark(id);
await get().refreshBenchmarks();
await get().refreshDatasets(id); // related cache invalidated too
};
```
---
## Migration Guide: useEffect → Store SWR
### Before (❌ Wrong)
```tsx
const TestCaseList = ({ datasetId }: Props) => {
const [data, setData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true);
lambdaClient.agentEval.listTestCases
.query({ datasetId })
.then((r) => setData(r.data))
.finally(() => setLoading(false));
}, [datasetId]);
return <Table data={data} loading={loading} />;
};
```
### After (✅ Correct)
```typescript
// 1. Add service method
class AgentEvalService {
async listTestCases(params: { datasetId: string }) {
return lambdaClient.agentEval.listTestCases.query(params);
}
}
// 2. Add store slice hook
export const createTestCaseSlice: StateCreator<...> = (set) => ({
useFetchTestCases: (params) =>
useClientDataSWR(
params.datasetId ? [FETCH_TEST_CASES_KEY, params.datasetId] : null,
() => agentEvalService.listTestCases(params),
{
onSuccess: (data) =>
set({ testCaseList: data.data, isLoadingTestCases: false }),
},
),
});
// 3. Component reads from store
const TestCaseList = ({ datasetId }: Props) => {
const useFetchTestCases = useEvalStore((s) => s.useFetchTestCases);
const data = useEvalStore((s) => s.testCaseList);
const loading = useEvalStore((s) => s.isLoadingTestCases);
useFetchTestCases({ datasetId });
return <Table data={data} loading={loading} />;
};
```
---
## Troubleshooting
| Symptom | Check |
| --------------------------- | ------------------------------------------------------------------- |
| Data never loads | Hook called? Key not `null`/`undefined`? Network tab shows request? |
| Stale data after mutation | Did `refreshXxx` run? Cache key matches what the hook uses? |
| Loading state stuck `true` | `onSuccess` writes loading=false? Promise rejected silently? |
| Detail map missing an entry | Reducer dispatch ran? `isEqual` short-circuited on stale data? |
---
## Summary Checklist
When adding new data fetching:
### Step 1: Types & State
See `store-data-structures` for details.
- [ ] Define types in `@lobechat/types`: Detail type + List item type
- [ ] State structure: `xxxList: XxxListItem[]`, `xxxDetailMap: Record<string, Xxx>`, `loadingXxxDetailIds: string[]`
- [ ] Reducer if optimistic updates are needed
### Step 2: Service Layer
- [ ] Create service in `src/services/xxxService.ts`
- [ ] Methods: `listXxx()`, `getXxx(id)`, `createXxx()`, `updateXxx()`, `deleteXxx()`
### Step 3: Store Actions
- [ ] `initialState.ts` with state structure
- [ ] `action.ts` with:
- [ ] `useFetchXxxList()`, `useFetchXxxDetail(id)` — SWR hooks
- [ ] `refreshXxxList()`, `refreshXxxDetail(id)` — cache invalidation
- [ ] CRUD methods calling service
- [ ] `internal_dispatch`, `internal_updateLoading` if using reducer
- [ ] `selectors.ts` (optional but recommended)
- [ ] Integrate slice into main store + initialState
### Step 4: Component Usage
- [ ] Use store hooks (NOT useEffect)
- [ ] List pages: access `xxxList` array
- [ ] Detail pages: access `xxxDetailMap[id]`
- [ ] Use loading states for UI feedback
**Mental model:** Types → Service → Reducer → Slice → Component 🎯
---
## Related Skills
- **`store-data-structures`** — How to structure List and Detail data in stores
- **`zustand`** — General Zustand patterns and best practices
@@ -0,0 +1,244 @@
# Walkthrough: Adding a New Feature End-to-End
This is a worked example of the canonical 6-step recipe applied to a new entity (`Dataset`), showing a variant of the main skill's pattern: **a list keyed by a parent id** (`datasetMap[benchmarkId]`), useful when the same shape appears under different parents.
If you only need the canonical (single-array) pattern, the main `SKILL.md` already shows it for `Benchmark`. Read this file when you need the parent-keyed Map variant, or when you want a checklist-style walkthrough.
## Step 1: Add Service methods
```typescript
class AgentEvalService {
async listDatasets(benchmarkId: string) {
return lambdaClient.agentEval.listDatasets.query({ benchmarkId });
}
async getDataset(id: string) {
return lambdaClient.agentEval.getDataset.query({ id });
}
async createDataset(params: CreateDatasetParams) {
return lambdaClient.agentEval.createDataset.mutate(params);
}
// updateDataset / deleteDataset follow the same shape
}
```
## Step 2: Reducer (optimistic updates)
```typescript
// src/store/eval/slices/dataset/reducer.ts
export type DatasetDispatch =
| { type: 'addDataset'; value: Dataset }
| { type: 'updateDataset'; id: string; value: Partial<Dataset> }
| { type: 'deleteDataset'; id: string };
export const datasetReducer = (state: Dataset[] = [], payload: DatasetDispatch): Dataset[] =>
produce(state, (draft) => {
switch (payload.type) {
case 'addDataset':
draft.unshift(payload.value);
break;
case 'updateDataset': {
const i = draft.findIndex((item) => item.id === payload.id);
if (i !== -1) draft[i] = { ...draft[i], ...payload.value };
break;
}
case 'deleteDataset': {
const i = draft.findIndex((item) => item.id === payload.id);
if (i !== -1) draft.splice(i, 1);
break;
}
}
});
```
## Step 3: Store slice
```typescript
// src/store/eval/slices/dataset/initialState.ts
export interface DatasetData {
currentPage: number;
hasMore: boolean;
isLoading: boolean;
items: Dataset[];
pageSize: number;
total: number;
}
export interface DatasetSliceState {
// Map keyed by benchmarkId — multiple parent contexts share the slice
datasetMap: Record<string, DatasetData>;
// Single item for modal display
datasetDetail: Dataset | null;
isLoadingDatasetDetail: boolean;
loadingDatasetIds: string[];
}
export const datasetInitialState: DatasetSliceState = {
datasetMap: {},
datasetDetail: null,
isLoadingDatasetDetail: false,
loadingDatasetIds: [],
};
```
```typescript
// src/store/eval/slices/dataset/action.ts
const FETCH_DATASETS_KEY = 'FETCH_DATASETS';
const FETCH_DATASET_DETAIL_KEY = 'FETCH_DATASET_DETAIL';
export const createDatasetSlice: StateCreator<EvalStore, any, [], DatasetAction> = (set, get) => ({
// Cache key includes benchmarkId so each parent has its own SWR entry
useFetchDatasets: (benchmarkId) =>
useClientDataSWR(
benchmarkId ? [FETCH_DATASETS_KEY, benchmarkId] : null,
() => agentEvalService.listDatasets(benchmarkId!),
{
onSuccess: (data) => {
set({
datasetMap: {
...get().datasetMap,
[benchmarkId!]: {
currentPage: 1,
hasMore: false,
isLoading: false,
items: data,
pageSize: data.length,
total: data.length,
},
},
});
},
},
),
useFetchDatasetDetail: (id) =>
useClientDataSWR(
id ? [FETCH_DATASET_DETAIL_KEY, id] : null,
() => agentEvalService.getDataset(id!),
{
onSuccess: (data) => set({ datasetDetail: data, isLoadingDatasetDetail: false }),
},
),
refreshDatasets: (benchmarkId) => mutate([FETCH_DATASETS_KEY, benchmarkId]),
refreshDatasetDetail: (id) => mutate([FETCH_DATASET_DETAIL_KEY, id]),
// CREATE with optimistic update — note the temp id pattern
createDataset: async (params) => {
const tmpId = Date.now().toString();
const { benchmarkId } = params;
get().internal_dispatchDataset(
{ type: 'addDataset', value: { ...params, id: tmpId, createdAt: Date.now() } as any },
benchmarkId,
);
get().internal_updateDatasetLoading(tmpId, true);
try {
const result = await agentEvalService.createDataset(params);
await get().refreshDatasets(benchmarkId);
return result;
} finally {
get().internal_updateDatasetLoading(tmpId, false);
}
},
// UPDATE / DELETE follow the same optimistic + refresh pattern as BenchmarkSlice
// (see the main SKILL.md)
// Internal — dispatch reducer scoped to a parent
internal_dispatchDataset: (payload, benchmarkId) => {
const currentData = get().datasetMap[benchmarkId];
const nextItems = datasetReducer(currentData?.items, payload);
// Skip set when nothing changed — avoids unnecessary re-renders
if (isEqual(nextItems, currentData?.items)) return;
set({
datasetMap: {
...get().datasetMap,
[benchmarkId]: {
...currentData,
currentPage: currentData?.currentPage ?? 1,
hasMore: currentData?.hasMore ?? false,
isLoading: false,
items: nextItems,
pageSize: currentData?.pageSize ?? nextItems.length,
total: currentData?.total ?? nextItems.length,
},
},
});
},
internal_updateDatasetLoading: (id, loading) => {
set((state) => ({
loadingDatasetIds: loading
? [...state.loadingDatasetIds, id]
: state.loadingDatasetIds.filter((i) => i !== id),
}));
},
});
```
## Step 4: Wire into the store
```typescript
// src/store/eval/store.ts
export type EvalStore = EvalStoreState & BenchmarkAction & DatasetAction & RunAction;
const createStore: StateCreator<EvalStore, [['zustand/devtools', never]]> = (set, get, store) => ({
...initialState,
...createBenchmarkSlice(set, get, store),
...createDatasetSlice(set, get, store),
...createRunSlice(set, get, store),
});
// src/store/eval/initialState.ts
export const initialState: EvalStoreState = {
...benchmarkInitialState,
...datasetInitialState,
...runInitialState,
};
```
## Step 5: Selectors (optional but recommended)
```typescript
export const datasetSelectors = {
getDatasetData: (benchmarkId: string) => (s: EvalStore) => s.datasetMap[benchmarkId],
getDatasets: (benchmarkId: string) => (s: EvalStore) => s.datasetMap[benchmarkId]?.items ?? [],
isLoadingDataset: (id: string) => (s: EvalStore) => s.loadingDatasetIds.includes(id),
};
```
## Step 6: Use in component
```tsx
// List scoped to a parent
const DatasetList = ({ benchmarkId }: { benchmarkId: string }) => {
const useFetchDatasets = useEvalStore((s) => s.useFetchDatasets);
const datasets = useEvalStore(datasetSelectors.getDatasets(benchmarkId));
const datasetData = useEvalStore(datasetSelectors.getDatasetData(benchmarkId));
useFetchDatasets(benchmarkId);
if (datasetData?.isLoading) return <Loading />;
return (
<div>
<h2>Total: {datasetData?.total ?? 0}</h2>
<List data={datasets} />
</div>
);
};
// Single item for modal — conditional fetching pattern
const DatasetImportModal = ({ open, datasetId }: Props) => {
const useFetchDatasetDetail = useEvalStore((s) => s.useFetchDatasetDetail);
const dataset = useEvalStore((s) => s.datasetDetail);
const isLoading = useEvalStore((s) => s.isLoadingDatasetDetail);
// Only fetch when modal is open AND id present
useFetchDatasetDetail(open && datasetId ? datasetId : undefined);
return <Modal open={open}>{isLoading ? <Loading /> : <div>{dataset?.name}</div>}</Modal>;
};
```
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,6 +1,7 @@
---
name: db-migrations
description: 'Use when generating or regenerating Drizzle migration files, changing database schema tables or columns, resolving migration sequence conflicts after rebase, reviewing migration SQL for idempotent patterns, or renaming migration files.'
user-invocable: false
---
# Database Migrations Guide
@@ -1,6 +1,6 @@
---
name: debug
description: Debug package usage guide. Use when adding debug logging, understanding log namespaces, or implementing debugging features. Triggers on debug logging requests or logging implementation.
name: debug-package
description: "Guide for the `debug` npm package and LobeHub log namespaces (lobe-server:*, lobe-desktop:*, lobe-client:*, lobe-*-router:*). Use whenever adding a `debug(...)` logger, picking a namespace for new server/desktop/client/router code, troubleshooting why DEBUG=lobe-* logs don't show up, or when the user asks to 'add logging', 'add a logger', 'instrument this', 'trace this call', 'why isn't my log printing', or mentions `debug(`, `DEBUG=`, `localStorage.debug`, or log format specifiers like %O / %o / %s / %d in a LobeHub codebase."
user-invocable: false
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: desktop
description: Electron desktop development guide. Use when implementing desktop features, IPC handlers, controllers, preload scripts, window management, menu configuration, or Electron-specific functionality. Triggers on desktop app development, Electron IPC, or desktop local tools implementation.
description: Electron desktop development guide IPC handlers, controllers, preload scripts, window/menu management.
disable-model-invocation: true
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: docs-changelog
description: 'Writing guide for website changelog pages under docs/changelog/*.mdx. Use when creating or editing product update posts in EN/ZH. Not for GitHub Release notes.'
description: "Writing guide for website changelog pages under `docs/changelog/*.mdx` (NOT GitHub Release notes — those live in the `version-release` skill). Use when creating or editing a product update post in EN/ZH. Triggers on `docs/changelog/*.mdx`, 'changelog post', 'product update post', 'add a changelog', '更新日志', 'changelog 文案'."
---
# Docs Changelog Writing Guide
+3 -6
View File
@@ -1,6 +1,7 @@
---
name: drizzle
description: Drizzle ORM schema and database guide. Use when working with database schemas (src/database/schemas/*), defining tables, creating migrations, or database model code. Triggers on Drizzle schema definition, database migrations, or ORM usage questions.
description: "Drizzle ORM schema authoring and query style for LobeHub (postgres, strict mode). Use when editing anything under `src/database/schemas/`, defining `pgTable` columns/indexes/junction tables, spreading `...timestamps`, generating `createInsertSchema`/`$inferSelect`/`$inferInsert` types, writing `db.select().from(...).leftJoin(...)` queries, or deciding when to split a relational `with:` into two queries. Triggers on `pgTable`, `db.select`, `db.query`, `eq()`/`and()`/`inArray()`, `uniqueIndex`, `primaryKey`, `references({ onDelete })`, 'add a column', 'new table', 'foreign key', 'junction table', 'schema field'. For migration files specifically, see the `db-migrations` skill."
user-invocable: false
---
# Drizzle ORM Schema Style Guide
@@ -125,11 +126,7 @@ The relational API generates complex lateral joins with `json_build_array` that
```typescript
// ✅ Good
const [result] = await this.db
.select()
.from(agents)
.where(eq(agents.id, id))
.limit(1);
const [result] = await this.db.select().from(agents).where(eq(agents.id, id)).limit(1);
return result;
// ❌ Bad: relational API
+2 -1
View File
@@ -1,6 +1,7 @@
---
name: hotkey
description: Guide for adding keyboard shortcuts. Use when implementing new hotkeys, registering shortcuts, or working with keyboard interactions. Triggers on hotkey implementation or keyboard shortcut tasks.
description: "Adding or editing keyboard shortcuts in LobeHub. Use when registering a new hotkey, changing a key combo, scoping a shortcut to chat vs global, or wiring a hotkey hook + tooltip. Covers the 5-step flow: add to `HotkeyEnum` in `src/types/hotkey.ts`, register in `HOTKEYS_REGISTRATION` (`src/const/hotkeys.ts`) with `combineKeys([Key.Mod, …])`, add i18n in `src/locales/default/hotkey.ts`, expose via `useHotkeyById` in `src/hooks/useHotkeys/`, and render `<Tooltip hotkey={…}>`. Triggers on `HotkeyEnum`, `HOTKEYS_REGISTRATION`, `useHotkeyById`, `combineKeys`, `Key.Mod`/`Key.Shift`, 'add a hotkey', 'add a shortcut', '加快捷键', '快捷键', 'Cmd+K', 'keyboard shortcut', 'hotkey scope', 'hotkey conflict'."
user-invocable: false
---
# Adding Keyboard Shortcuts Guide
+2 -1
View File
@@ -1,6 +1,7 @@
---
name: i18n
description: Internationalization guide using react-i18next. Use when adding translations, creating i18n keys, or working with localized text in React components (.tsx files). Triggers on translation tasks, locale management, or i18n implementation.
description: "LobeHub internationalization with react-i18next. Use when adding any user-facing string in `.tsx`/`.ts` files, creating or renaming a key under `src/locales/default/{namespace}.ts`, deciding the `{feature}.{context}.{action}` flat-key pattern, wiring a new namespace into `src/locales/default/index.ts`, or translating zh-CN/en-US JSON for dev preview. Triggers on `useTranslation`, `t('foo.bar')`, `i18next.t`, `{{variable}}` interpolation, hardcoded UI strings (zh or en) that should be extracted, 'add i18n', '加 i18n key', '翻译', 'locale key', 'namespace', 'pnpm i18n'."
user-invocable: false
---
# LobeHub Internationalization Guide
+33 -39
View File
@@ -1,55 +1,55 @@
---
name: linear
description: "Linear issue management. MUST USE when: (1) user mentions LOBE-xxx issue IDs (e.g. LOBE-4540), (2) user says 'linear', 'linear issue', 'link linear', (3) creating PRs that reference Linear issues. Provides workflows for retrieving issues, updating status, and adding comments."
description: "Linear issue management. Use when the user mentions LOBE-xxx issue IDs (e.g. LOBE-4540), says 'linear' / 'linear issue' / 'link linear', or when creating PRs that reference Linear issues. Covers retrieving issues, updating status, adding completion comments, and creating sub-issue trees."
user-invocable: false
---
# Linear Issue Management
Before using Linear workflows, search for `linear` MCP tools. If not found, treat as not installed.
## ⚠️ CRITICAL: PR Creation with Linear Issues
## PR Creation with Linear Issues
**When creating a PR that references Linear issues (LOBE-xxx), you MUST:**
A PR that fixes a Linear issue has **two separate jobs to do**, and both matter:
1. Create the PR with magic keywords (`Fixes LOBE-xxx`)
2. **IMMEDIATELY after PR creation**, add completion comments to ALL referenced Linear issues
3. Do NOT consider the task complete until Linear comments are added
1. **`Fixes LOBE-xxx` in the PR body** — Linear watches GitHub for these magic keywords and auto-links the PR and auto-closes the issue on merge. This is the machine-readable side.
2. **A completion comment on the Linear issue** — gives the reviewer/PM/teammate landing in Linear a human-readable summary of what changed and why, without forcing them to click through to GitHub and read a diff.
This is NON-NEGOTIABLE. Skipping Linear comments is a workflow violation.
If you only do step 1, Linear watchers (often non-engineers) hit the issue and see no context. So pair PR creation with the Linear comment as part of the same task — finish both before considering the work done.
## Workflow
1. **Retrieve issue details** before starting: `mcp__linear-server__get_issue`
2. **Read images**: If the issue description contains images, MUST use `mcp__linear-server__extract_images` to read image content for full context
3. **Check for sub-issues**: Use `mcp__linear-server__list_issues` with `parentId` filter
4. **Mark as In Progress**: When starting to plan or implement an issue, immediately update status to **"In Progress"** via `mcp__linear-server__update_issue`
2. **Read images** issue descriptions often contain screenshots with critical context (mockups, error states, before/after). Use `mcp__linear-server__extract_images` so you actually see them; reading raw markdown alone misses what the reporter was looking at.
3. **Check for sub-issues**: `mcp__linear-server__list_issues` with `parentId` filter
4. **Mark as In Progress** at the moment you start planning or implementing — this signals to teammates the issue is owned, so they don't double-pick it up.
5. **Update issue status** when completing: `mcp__linear-server__update_issue`
6. **Add completion comment** (REQUIRED): `mcp__linear-server__create_comment`
6. **Add completion comment** (see [format below](#completion-comment-format))
## Creating Issues
When creating issues with `mcp__linear-server__create_issue`, **MUST add the `claude code` label**.
When creating issues with `mcp__linear-server__create_issue`, add the `claude code` label. Reason: the label is how the team filters/audits AI-generated issues; without it those issues vanish into the general backlog and the team loses visibility into AI contribution patterns.
## Language
Issue titles, descriptions, and comments **MUST follow the language of the current conversation**, not default to English.
Match the issue language to the conversation that produced it — if you're discussing in 中文,write the issue in 中文;if discussing in English, write it in English. Reason: the issue is a continuation of the conversation, and forcing a language switch creates translation friction for the collaborator who started the thread.
- Conversation in 中文 → issue body in 中文;technical terms (file paths, identifiers, library names, commands, error messages) stay in English.
- Conversation in English → issue body in English.
Specifics:
- 中文 conversation → 中文 body; technical terms (file paths, identifiers, library names, commands, error messages) stay in English.
- English conversation → English body.
- Code blocks, file paths, and quoted strings always stay in their original form regardless of surrounding language.
- This applies equally to **updates** — when editing an existing issue (description **and titles**), preserve the language of the conversation that triggered the edit; do not switch the issue language during a refactor (Chinese → English or vice versa).
Rationale: the issue is a continuation of the conversation. Forcing English when the discussion is in Chinese creates translation friction for the collaborator who came from that thread.
- This applies equally to **updates** — when editing an existing issue (description **and titles**), preserve the language of the conversation that triggered the edit; don't switch the issue language mid-refactor.
## Creating Sub-issue Trees
When breaking a parent issue into a tree of sub-issues (e.g., task decomposition for LOBE-xxx), follow these rules — they work around real limitations of the Linear MCP tools.
### 1. ALWAYS prefix titles with an ordering index
### 1. Prefix titles with an ordering index
The Linear Sub-issues panel displays children by `sortOrder`, which **defaults to newest-first** (most recently created appears on top). Neither parallel nor serial creation will produce the intended top-to-bottom reading order, and the MCP `save_issue` tool does **not expose a `sortOrder` parameter** — you cannot set order at create time.
The Linear Sub-issues panel orders children by `sortOrder`, which **defaults to newest-first** (most recently created appears on top). Neither parallel nor serial creation produces the intended top-to-bottom reading order, and the MCP `save_issue` tool does **not expose a `sortOrder` parameter** — you can't set order at create time.
**Workaround**: encode execution order in the title itself:
Workaround: encode execution order in the title itself:
```plaintext
[1] [db] add schema fields
@@ -100,7 +100,7 @@ The implementer may open only the sub-issue, not the parent — don't rely on co
## Completion Comment Format
Every completed issue MUST have a comment summarizing work done:
Each completed issue gets a comment summarizing the work, so reviewers and future readers don't have to reconstruct it from the PR diff:
```markdown
## Changes Summary
@@ -116,34 +116,28 @@ Every completed issue MUST have a comment summarizing work done:
- ...
```
This is critical for:
This gives team visibility, code-review context, and a paper trail for future reference.
- Team visibility
- Code review context
- Future reference
## PR Association
## PR Association (REQUIRED)
When creating PRs for Linear issues, include magic keywords in PR body:
When creating PRs for Linear issues, include magic keywords in the PR body:
- `Fixes LOBE-123`
- `Closes LOBE-123`
- `Resolves LOBE-123`
These trigger Linear's auto-link + auto-close on merge.
## Per-Issue Completion Rule
When working on multiple issues, update EACH issue IMMEDIATELY after completing it:
When working on multiple issues, close out **each one before starting the next** — don't batch all the Linear updates to the end. Batching is where comments get forgotten and issues stay stuck in "In Progress" days after the PR shipped.
For each issue:
1. Complete implementation
2. Run `bun run type-check`
3. Run related tests
4. Create PR if needed
5. Update status to **"In Review"** (NOT "Done")
6. **Add completion comment immediately**
7. Move to next issue
**Note:** Status → "In Review" when PR created. "Done" only after PR merged.
**❌ Wrong:** Complete all → Create PR → Forget Linear comments
**✅ Correct:** Complete → Create PR → Add Linear comments → Task done
5. Update status to **"In Review"** (not "Done" — "Done" is for after the PR merges)
6. Add the completion comment
7. Move to the next issue
@@ -0,0 +1,93 @@
# LobeHub gateway streaming + tab-switch test harness
Captures store + DOM state at 200ms intervals so we can prove or disprove
claims like "切回 tab 后消息回到了很早以前". Built for gateway-mode chat but
works for any LobeHub streaming session.
## Files
`scripts/agent-gateway/`
| File | Role |
| --------------- | ---------------------------------------------------------------- |
| `probe.js` | Injects a 200ms sampler + `__PROBE_EVENT` marker + `__switchTab` |
| `probe-dump.js` | Stops the sampler and returns `{events, samples}` as JSON string |
| `tab-switch.js` | Runs N round-trip switches between two tabs, marks each step |
| `analyze.mjs` | Node post-processor: timeline + regression detection |
## Standard workflow
```bash
# 1. Start Electron with CDP
./.agents/skills/local-testing/scripts/electron-dev.sh start
# 2. Navigate to a chat, switch runtime to Cloud Sandbox (gateway mode)
# 3. Install the probe + helpers
agent-browser --cdp 9222 eval --stdin \
< .agents/skills/local-testing/scripts/agent-gateway/probe.js
# 4. Send a tool-call message — manually or via type+press
agent-browser --cdp 9222 eval "window.__PROBE_EVENT('SENT')"
# 5. Run the multi-switch driver (auto-picks active tab as BACK and the
# rightmost inactive tab as AWAY — edit ROUND_TRIPS / DWELL_MS in the
# file if you want different timing)
agent-browser --cdp 9222 eval --stdin \
< .agents/skills/local-testing/scripts/agent-gateway/tab-switch.js
# 6. Wait for streaming to finish, then dump
agent-browser --cdp 9222 eval --stdin \
< .agents/skills/local-testing/scripts/agent-gateway/probe-dump.js \
> /tmp/probe.json
# 7. Analyze
node .agents/skills/local-testing/scripts/agent-gateway/analyze.mjs /tmp/probe.json
```
The analyzer prints three sections: EVENTS, TIMELINE, REGRESSIONS. If
REGRESSIONS is non-empty it means content/reasoning/childN dropped on the
same topic — the symptom users describe.
## What the probe tracks (and why)
`chat.messagesMap` only stores the top-level `assistantGroup` shell. The
actual streamed content, reasoning, and tool calls live in
`assistantGroup.children: AssistantContentBlock[]`. Any probe that only
reads `m.content` / `m.reasoning` will see zeros throughout streaming and
miss everything that matters. probe.js walks both levels and sums:
- `cT` total content length
- `rT` total reasoning length
- `toolT` total tool-call count
- `childN` number of content blocks
Plus DOM-side signals (`domLen`, search/crawl indicator counts) so you can
tell store-side regressions apart from render-side regressions.
## Gotchas
- **Optimistic new-topic state.** Before the first chunk lands, messages
live under the `<scope>_new` key with `tmp_*` ids and no `topicId` field.
probe.js falls back to those when `activeTopicId` is null.
- **Reasoning resets to 0 are not bugs.** When the assistant finishes
thinking and starts tool-use or text, the streaming reasoning buffer
empties and the finalised reasoning gets sealed into a completed block.
Filter these out manually if needed.
- **DOM length jitters by a handful of chars** because counters like "(10)"
in tool-call labels change as results arrive. analyze.mjs only flags
`domLen` drops greater than 100 chars to ignore that noise.
- **Never identify tabs by innerText.** The active tab's text embeds a
` · <agent name>` suffix, so a search like `'LobeHub Growth'` matches the
active tab when the active agent happens to be LobeHub Growth — and you
end up clicking the tab you're already on. probe.js uses the stable
`data-contextmenu-trigger` attribute (a React `useId()` value that's set
per-tab and survives focus changes) plus `data-active="true"` to mark
the active one. Helpers exposed:
`__listTabs()` / `__clickTabByKey(key)` / `__clickTabByIndex(i)` /
`__activeTabKey()`.
- **`tab-switch.js` fires-and-forgets.** The IIFE kicks off an async loop
and returns immediately so the agent-browser CLI eval doesn't blow past
its default 25 s timeout. Wait on the `SWITCH_LOOP_DONE` event marker
before dumping. Re-running while a loop is in flight is refused — the
chaotic data from overlapping runs is not worth debugging.
@@ -0,0 +1,243 @@
// Analyzer for probe-events dumps. Reads a JSON file produced by `run.ts dump`
// and prints a layered breakdown:
//
// 1. STREAM EVENTS — every non-chunk WS/SSE event in receipt order
// 2. CHUNKS SUMMARY — collapsed per-step chunk counts (otherwise floods)
// 3. ACTION CALLS — replaceMessages / refreshMessages / MARK:* with stack
// 4. CORRELATION — calls ↔ nearest stream event within ±300ms
// 5. PER-KEY ASSISTANT GROWTH — for each messagesMap key, when the leading
// assistant message's cLen / rLen actually moves (this is what reveals
// "chunks arrived but the message never grew" regressions)
// 6. ROLLBACKS — msgN / childN / role drops in the active-topic timeline
//
// Usage:
// bun run .agents/skills/local-testing/scripts/agent-gateway/analyze-events.ts <dump.json>
import { readFileSync } from 'node:fs';
import type {
ProbeActionCall,
ProbeDump,
ProbeMessageSummary,
ProbeStreamEvent,
ProbeTimelineSample,
} from './types';
const file = process.argv[2];
if (!file) {
console.error('usage: bun run analyze-events.ts <dump.json>');
process.exit(1);
}
const raw = readFileSync(file, 'utf8');
// agent-browser eval --stdin wraps return values in quotes when the value is
// a string — so the JSON file may be double-encoded depending on how it was
// captured. Handle both.
const parsedOnce = JSON.parse(raw) as ProbeDump | string;
const dump: ProbeDump = typeof parsedOnce === 'string' ? JSON.parse(parsedOnce) : parsedOnce;
const { streamEvents = [], actionCalls = [], timeline = [] } = dump;
const pad = (v: unknown, n: number) => String(v).padStart(n);
// ── META ───────────────────────────────────────────────────────────
console.log('=== META ===');
console.log(` events: ${streamEvents.length}`);
console.log(` calls: ${actionCalls.length}`);
console.log(` timeline: ${timeline.length}`);
// ── 1. STREAM EVENTS (non-chunk) ───────────────────────────────────
const nonChunkEvents = streamEvents.filter((e) => e.type !== 'stream_chunk');
const chunkEvents = streamEvents.filter((e) => e.type === 'stream_chunk');
console.log(
`\n=== STREAM EVENTS (${nonChunkEvents.length} non-chunk + ${chunkEvents.length} chunks elided) ===`,
);
for (const e of nonChunkEvents) {
const dataStr = e.dataKeys?.length ? ` [${e.dataKeys.join(',')}]` : '';
const data = e.data as Record<string, unknown> | undefined;
const uiHint = data?.uiMessagesPreview
? ` uiPreview=${JSON.stringify(data.uiMessagesPreview)}`
: data?.uiMessagesTotal
? ` uiTotal=${data.uiMessagesTotal}`
: '';
const phaseHint = data?.phase ? ` phase=${data.phase}` : '';
const extra = e.serverType ? ` serverType=${e.serverType}` : '';
console.log(
` t=${pad(e.t, 7)} [${(e.transport ?? '?').padEnd(3)}] step=${pad(e.stepIndex ?? '-', 2)} ` +
`type=${(e.type ?? '').padEnd(22)} op=${e.opIdTail ?? '-'}${phaseHint}${uiHint}${extra}${dataStr}`,
);
}
// ── 2. CHUNK SUMMARY ───────────────────────────────────────────────
console.log('\n=== CHUNKS SUMMARY (per step / chunkType) ===');
const chunkBuckets = new Map<string, { count: number; firstT: number; lastT: number }>();
for (const c of chunkEvents) {
const data = c.data as Record<string, unknown> | undefined;
const ct = (data?.chunkType as string | undefined) ?? '?';
const key = `step=${c.stepIndex ?? '-'} chunkType=${ct.padEnd(8)} op=${c.opIdTail}`;
const slot = chunkBuckets.get(key);
if (slot) {
slot.count += 1;
slot.lastT = c.t;
} else {
chunkBuckets.set(key, { count: 1, firstT: c.t, lastT: c.t });
}
}
for (const [k, v] of chunkBuckets) {
console.log(` ${k} count=${pad(v.count, 4)} t=${pad(v.firstT, 7)}..${pad(v.lastT, 7)}`);
}
// ── 3. ACTION CALLS ───────────────────────────────────────────────
console.log('\n=== ACTION CALLS (replace/refresh/MARK) ===');
for (const c of actionCalls) {
if (c.name?.startsWith('MARK:')) {
console.log(` t=${pad(c.t, 7)} ${c.name}`);
continue;
}
const snapshot = (c.args as any)?.snapshot as
| Array<{ id: string; role: string; cLen: number; rLen: number }>
| undefined;
const snapStr = snapshot?.length
? ' snapshot=' + snapshot.map((m) => `${m.id}:${m.role}/c${m.cLen}/r${m.rLen}`).join(' | ')
: '';
const summary =
c.name === 'replaceMessages'
? `count=${c.args?.count} action=${(c.args?.params as any)?.action ?? '-'}${snapStr}`
: c.name === 'refreshMessages'
? `ctx=${JSON.stringify(c.args?.context)}`
: c.error
? `error=${c.error}`
: '';
console.log(` t=${pad(c.t, 7)} ${c.name.padEnd(20)} ${summary}`);
if (c.stack) {
const frames = c.stack
.split(' ← ')
.filter((f) => !!f && !f.includes('Object.<anonymous>'))
.slice(0, 3);
for (const f of frames) console.log(`${f}`);
}
}
// ── 4. CORRELATION ────────────────────────────────────────────────
function nearestEventForCall(
call: ProbeActionCall,
windowMs = 300,
): { event: ProbeStreamEvent; delta: number } | null {
let best: ProbeStreamEvent | null = null;
let bestDelta = Infinity;
for (const e of streamEvents) {
const d = Math.abs(e.t - call.t);
if (d < bestDelta && d <= windowMs) {
bestDelta = d;
best = e;
}
}
return best ? { event: best, delta: bestDelta } : null;
}
console.log('\n=== CORRELATION (replace/refresh ↔ nearest event within ±300ms) ===');
for (const c of actionCalls) {
if (c.name !== 'refreshMessages' && c.name !== 'replaceMessages') continue;
const hit = nearestEventForCall(c);
if (hit) {
const phase = (hit.event.data as Record<string, unknown> | undefined)?.phase;
console.log(
` t=${pad(c.t, 7)} ${c.name.padEnd(16)} ← Δ${pad(hit.delta, 4)}ms ${hit.event.type}` +
(phase ? ` phase=${phase}` : ''),
);
} else {
console.log(` t=${pad(c.t, 7)} ${c.name.padEnd(16)} ← (no event nearby — external trigger)`);
}
}
// ── 5. PER-KEY ASSISTANT GROWTH ───────────────────────────────────
// For each messagesMap key, find the trailing assistant message and report
// the points in time where its cLen / rLen actually changed. If the timeline
// shows chunks arriving but the assistant cLen never moves, that's the
// signature of "dispatch queue blocked / messageId mismatch".
console.log('\n=== PER-KEY ASSISTANT GROWTH ===');
const keysEverSeen = new Set<string>();
for (const s of timeline) for (const k of Object.keys(s.byKey ?? {})) keysEverSeen.add(k);
for (const key of keysEverSeen) {
console.log(`\n key=${key}`);
let lastSig: string | null = null;
for (const s of timeline) {
const slot = s.byKey?.[key];
if (!slot) continue;
const last = slot.msgs.at(-1) as ProbeMessageSummary | undefined;
if (!last) continue;
const sig = `${last.id}|c${last.cLen}|r${last.rLen}|n${slot.n}`;
if (sig === lastSig) continue;
lastSig = sig;
console.log(
` t=${pad(s.t, 7)} msgN=${pad(slot.n, 3)} ` +
`lastAssistant=${last.id} cLen=${pad(last.cLen, 5)} rLen=${pad(last.rLen, 5)}` +
` runOps=${s.runOps}`,
);
}
}
// ── 6. ROLLBACKS (active-topic msgN / childN / role drops) ─────────
console.log('\n=== ROLLBACKS (active-topic msgN / childN / role drops) ===');
let prev: ProbeTimelineSample | null = null;
const rollbacks: Array<{ t: number; topic: string | null; drops: string[] }> = [];
const flatten = (s: ProbeTimelineSample) => {
if (!s.activeTopic) return [];
return Object.entries(s.byKey ?? {})
.filter(([k]) => k.includes(s.activeTopic!))
.flatMap(([, v]) => v.msgs);
};
for (const s of timeline) {
if (s.err) {
prev = null;
continue;
}
if (!prev || prev.activeTopic !== s.activeTopic) {
prev = s;
continue;
}
const prevMsgs = flatten(prev);
const curMsgs = flatten(s);
const drops: string[] = [];
if (curMsgs.length < prevMsgs.length) drops.push(`msgN ${prevMsgs.length}${curMsgs.length}`);
let prevChild = 0;
let curChild = 0;
for (const m of prevMsgs) prevChild += m.chN ?? 0;
for (const m of curMsgs) curChild += m.chN ?? 0;
if (curChild < prevChild) drops.push(`childN ${prevChild}${curChild}`);
const prevById = new Map(prevMsgs.map((m) => [m.id, m]));
for (const m of curMsgs) {
const pr = prevById.get(m.id);
if (!pr) continue;
if (m.cLen < pr.cLen) drops.push(`cLen[${m.id}] ${pr.cLen}${m.cLen}`);
if (m.rLen < pr.rLen) drops.push(`rLen[${m.id}] ${pr.rLen}${m.rLen}`);
}
if (drops.length) rollbacks.push({ t: s.t, topic: s.activeTopic, drops });
prev = s;
}
if (rollbacks.length === 0) {
console.log(' (none)');
} else {
for (const r of rollbacks) {
const nearEvent = streamEvents
.filter((e) => Math.abs(e.t - r.t) <= 300)
.map((e) => `${e.type}${(e.data as any)?.phase ? ':' + (e.data as any).phase : ''}`);
const nearCall = actionCalls
.filter((c) => Math.abs(c.t - r.t) <= 300 && !c.name?.startsWith('MARK:'))
.map((c) => c.name);
console.log(
` t=${pad(r.t, 7)} topic=${r.topic} ${r.drops.join(' | ')}` +
(nearEvent.length ? ` near-event:[${nearEvent.join(',')}]` : '') +
(nearCall.length ? ` near-call:[${nearCall.join(',')}]` : ''),
);
}
}
@@ -0,0 +1,119 @@
#!/usr/bin/env node
// Analyze a probe dump captured by probe.js + probe-dump.js.
//
// node analyze.mjs /tmp/probe.json
//
// Prints:
// 1. EVENTS — user-action markers with their relative timestamps
// 2. TIMELINE — periodic samples (~1 per second + event-adjacent samples)
// showing every interesting field; columns:
// t(ms) | runOps | msgN | childN | content | reasoning | tools | domLen | search | crawl | topic | event
// 3. REGRESSIONS — every place a tracked counter *dropped* on the same
// topic between adjacent samples. A "true" UI rollback shows up as a
// drop in content/reasoning/tools/childN/domLen without a topic change.
//
// Whitelisted transitions (not flagged):
// - topic change → all drops expected (focus moved away)
// - reasoning length 0 after content starts → reasoning gets sealed into a
// completed sub-block; the parent's running reasoning resets to ''.
// - msgN drop when topic transitions from `_new` placeholder to a real id.
import fs from 'node:fs';
const file = process.argv[2];
if (!file) {
console.error('usage: node analyze.mjs <probe.json>');
process.exit(1);
}
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
// probe-dump.js wraps the payload in JSON.stringify so agent-browser returns
// it as a single quoted string. Unwrap.
const data = typeof raw === 'string' ? JSON.parse(raw) : raw;
const { events, samples } = data;
const fmt = {
pad(v, n) {
return String(v).padStart(n);
},
};
console.log('=== EVENTS ===');
for (const e of events) console.log(` t=${fmt.pad(e.t, 7)} ${e.name}`);
console.log(
'\n=== TIMELINE (~1s cadence, plus event-adjacent samples) ===\n' +
' t(ms) runOps msgN childN content reasoning tools domLen search crawl topic event',
);
let lastSampledAt = -1e9;
const eventBuckets = events.map((e) => e.t);
for (let i = 0; i < samples.length; i++) {
const s = samples[i];
const nearEvent = eventBuckets.some((et) => Math.abs(et - s.t) < 110);
if (!nearEvent && s.t - lastSampledAt < 1000) continue;
lastSampledAt = s.t;
const ev = events.find((e) => Math.abs(e.t - s.t) < 110);
const evMarker = ev ? `${ev.name}` : '';
const topicSuffix = s.topicId ? s.topicId.slice(-6) : '(none)';
const search = s.ind?.search ?? 0;
const crawl = s.ind?.crawl ?? 0;
console.log(
` ${fmt.pad(s.t, 6)} ` +
`${fmt.pad(s.runOps, 6)} ` +
`${fmt.pad(s.msgN, 4)} ` +
`${fmt.pad(s.childN ?? 0, 5)} ` +
`${fmt.pad(s.cT ?? 0, 8)} ` +
`${fmt.pad(s.rT ?? 0, 9)} ` +
`${fmt.pad(s.toolT ?? 0, 5)} ` +
`${fmt.pad(s.domLen ?? 0, 7)} ` +
`${fmt.pad(search, 6)} ` +
`${fmt.pad(crawl, 5)} ` +
`${topicSuffix.padEnd(8)}${evMarker}`,
);
}
console.log('\n=== REGRESSIONS (same topic, value dropped) ===');
const regressions = [];
for (let i = 1; i < samples.length; i++) {
const prev = samples[i - 1];
const cur = samples[i];
if (!cur.topicId || prev.topicId !== cur.topicId) continue;
const drops = [];
if (cur.msgN < prev.msgN) drops.push(`msgN: ${prev.msgN}${cur.msgN}`);
if ((cur.childN ?? 0) < (prev.childN ?? 0)) drops.push(`childN: ${prev.childN}${cur.childN}`);
if ((cur.cT ?? 0) < (prev.cT ?? 0)) drops.push(`content: ${prev.cT}${cur.cT}`);
if ((cur.rT ?? 0) < (prev.rT ?? 0)) drops.push(`reasoning: ${prev.rT}${cur.rT}`);
if ((cur.toolT ?? 0) < (prev.toolT ?? 0)) drops.push(`tools: ${prev.toolT}${cur.toolT}`);
// domLen jitters by a few chars from counter labels — only flag big drops.
if ((cur.domLen ?? 0) < (prev.domLen ?? 0) - 100) {
drops.push(`domLen: ${prev.domLen}${cur.domLen}`);
}
if (drops.length === 0) continue;
const nearbyEv = events.filter((e) => Math.abs(e.t - cur.t) < 600).map((e) => e.name);
regressions.push({ t: cur.t, topic: cur.topicId.slice(-6), drops, nearbyEv });
}
if (regressions.length === 0) {
console.log(' (none)');
} else {
for (const r of regressions) {
const evStr = r.nearbyEv.length ? ` near:[${r.nearbyEv.join(',')}]` : '';
console.log(` t=${fmt.pad(r.t, 7)} topic=${r.topic} ${r.drops.join(' | ')}${evStr}`);
}
}
console.log(`\n=== SUMMARY ===`);
console.log(` samples: ${samples.length}`);
console.log(` events: ${events.length}`);
console.log(` regressions: ${regressions.length}`);
if (samples.length) {
const last = samples.at(-1);
console.log(
` final: msgN=${last.msgN} childN=${last.childN ?? 0} content=${last.cT ?? 0} ` +
`reasoning=${last.rT ?? 0} tools=${last.toolT ?? 0} runOps=${last.runOps}`,
);
}
@@ -0,0 +1,17 @@
// Stop the probe and serialize collected data.
//
// agent-browser --cdp 9222 eval --stdin < probe-dump.js > /tmp/probe.json
//
// The whole thing is wrapped in a JSON.stringify so agent-browser returns it
// as a single quoted string — the analyzer double-parses to handle that.
(function () {
if (window.__PROBE_TIMER) {
clearInterval(window.__PROBE_TIMER);
window.__PROBE_TIMER = null;
}
return JSON.stringify({
events: window.__PROBE_EVENTS || [],
samples: window.__PROBE_SAMPLES || [],
});
})();
@@ -0,0 +1,37 @@
// Stops the events-probe timeline timer and stashes the full capture as a
// JSON string on `window.__PROBE_LAST_DUMP_JSON`. `run.ts` wraps the bundle
// in an IIFE that returns that global, which `agent-browser eval` prints to
// stdout — the runner then persists it under `.agent-gateway/`.
import type { ProbeDump } from './types';
declare global {
interface Window {
__PROBE_LAST_DUMP_JSON?: string;
}
}
const w = window;
if (w.__PROBE_TIMELINE_TIMER) {
clearInterval(w.__PROBE_TIMELINE_TIMER);
w.__PROBE_TIMELINE_TIMER = null;
}
const mutations = w.__PROBE_MUTATIONS ?? [];
const dump: ProbeDump & { mutations: typeof mutations } = {
meta: {
t0: w.__PROBE_T0 ?? 0,
collectedAt: Date.now(),
sampleCount: (w.__PROBE_MSG_TIMELINE ?? []).length,
eventCount: (w.__PROBE_STREAM_EVENTS ?? []).length,
callCount: (w.__PROBE_ACTION_CALLS ?? []).length,
},
streamEvents: w.__PROBE_STREAM_EVENTS ?? [],
actionCalls: w.__PROBE_ACTION_CALLS ?? [],
timeline: w.__PROBE_MSG_TIMELINE ?? [],
mutations,
};
w.__PROBE_LAST_DUMP_JSON = JSON.stringify(dump);
@@ -0,0 +1,637 @@
// LobeHub gateway raw-event-stream probe.
//
// Gateway-mode chats subscribe via WebSocket — NOT via the `/api/agent/stream`
// SSE endpoint (that one belongs to the direct/client durable-agent runtime).
// `AgentStreamClient` (`packages/agent-gateway-client/src/client.ts`) opens
// `new WebSocket('wss://.../ws?operationId=...')`, then parses JSON frames in
// its `onmessage` handler and re-emits `agent_event.event` objects to the
// chat store.
//
// To capture the RAW gateway events before the store touches them, we wrap
// `window.WebSocket` so that for any socket whose URL contains `operationId=`
// we intercept the `onmessage` handler / `addEventListener('message')` and
// log every `agent_event` frame.
//
// We *also* keep the `window.fetch` hook for `/api/agent/stream` so this
// probe still works for direct-mode runs — but gateway-mode events come
// through the WebSocket path.
//
// Buffers (read via `dump`):
// __PROBE_STREAM_EVENTS — raw events parsed off the wire
// __PROBE_ACTION_CALLS — replaceMessages / refreshMessages calls (best-effort)
// __PROBE_MSG_TIMELINE — 200ms snapshots of every messagesMap key
import type {
ProbeActionCall,
ProbeMessageSummary,
ProbeStreamEvent,
ProbeTimelineSample,
} from './types';
// Bundled by esbuild as an IIFE. Top-level code runs once on injection.
const w = window;
// ── Buffers ─────────────────────────────────────────────────────────
declare global {
interface Window {
__PROBE_MUTATIONS?: Array<{
t: number;
key: string;
n: number;
last?: { id: string; role: string; cLen: number; rLen: number; updatedAt?: unknown };
prevLast?: { id: string; role: string; cLen: number; rLen: number };
delta?: string;
}>;
__PROBE_STORE_UNSUB?: () => void;
}
}
const events: ProbeStreamEvent[] = (w.__PROBE_STREAM_EVENTS ??= []);
const calls: ProbeActionCall[] = (w.__PROBE_ACTION_CALLS ??= []);
const timeline: ProbeTimelineSample[] = (w.__PROBE_MSG_TIMELINE ??= []);
const mutations = (w.__PROBE_MUTATIONS ??= []);
events.length = 0;
calls.length = 0;
timeline.length = 0;
mutations.length = 0;
const t0 = Date.now();
w.__PROBE_T0 = t0;
const now = (): number => Date.now() - t0;
// ── Helpers ─────────────────────────────────────────────────────────
function summarizeData(data: unknown): Record<string, unknown> | unknown {
if (!data || typeof data !== 'object') return data;
const src = data as Record<string, unknown>;
const out: Record<string, unknown> = {};
for (const k of Object.keys(src)) {
const v = src[k];
if (v == null) {
out[k] = v;
} else if (Array.isArray(v)) {
out[k] = `Array(${v.length})`;
if (k === 'uiMessages') {
out.uiMessagesPreview = v.slice(0, 5).map((m: any) => ({
id: (m.id ?? '').slice(-8),
role: m.role,
cLen: (m.content ?? '').length,
children: (m.children ?? []).length,
tools: (m.tools ?? []).length,
reasoning: (m.reasoning?.content ?? '').length,
}));
out.uiMessagesTotal = v.length;
}
} else if (typeof v === 'object') {
const obj = v as Record<string, unknown>;
out[k] =
'Object{' +
Object.keys(obj)
.slice(0, 6)
.map((kk) => kk + (typeof obj[kk] === 'string' ? `=${(obj[kk] as string).length}ch` : ''))
.join(',') +
'}';
} else if (typeof v === 'string') {
out[k] = v.length > 100 ? v.slice(0, 100) + `…(${v.length})` : v;
} else {
out[k] = v;
}
}
return out;
}
function summarizeMessages(msgs: any[]): ProbeMessageSummary[] {
return (msgs ?? []).slice(0, 80).map((m) => ({
id: (m.id ?? '').slice(-8),
role: m.role,
cLen: (m.content ?? '').length,
rLen: (m.reasoning?.content ?? '').length,
tools: (m.tools ?? []).length,
chN: (m.children ?? []).length,
}));
}
function shortStack(): string {
const raw = new Error('probe-stack').stack ?? '';
return raw
.split('\n')
.slice(3)
.filter((l) => !l.includes('probe-events') && !l.includes('node_modules'))
.map((l) => l.trim().replace(/^at\s+/, ''))
.slice(0, 6)
.join(' ← ');
}
function recordAgentEvent(args: {
transport: 'ws' | 'sse';
opId: string | null;
agentEvent: any;
eventId?: string | null;
rawLen?: number;
}): void {
const { transport, opId, agentEvent, eventId, rawLen } = args;
if (!agentEvent || typeof agentEvent !== 'object') return;
events.push({
t: now(),
transport,
opIdTail: (opId ?? '').slice(-10),
eventId: eventId ?? null,
type: agentEvent.type,
stepIndex: agentEvent.stepIndex,
dataKeys: agentEvent.data ? Object.keys(agentEvent.data) : [],
data: summarizeData(agentEvent.data) as Record<string, unknown>,
rawLen,
});
}
// ── 1. Patch window.WebSocket for gateway WS events ────────────────
if (!w.__PROBE_ORIG_WEBSOCKET) w.__PROBE_ORIG_WEBSOCKET = w.WebSocket;
const OrigWS = w.__PROBE_ORIG_WEBSOCKET;
function extractOpIdFromWsUrl(url: string | URL): string | null {
const m = String(url ?? '').match(/operationId=([^&]+)/);
return m ? decodeURIComponent(m[1]) : null;
}
function isGatewayWs(url: string | URL): boolean {
return String(url ?? '').includes('operationId=');
}
function handleWsFrame(rawData: unknown, opId: string | null): void {
const rawLen = typeof rawData === 'string' ? rawData.length : -1;
let parsed: any;
try {
parsed = typeof rawData === 'string' ? JSON.parse(rawData) : null;
} catch {
events.push({
t: now(),
transport: 'ws',
opIdTail: (opId ?? '').slice(-10),
type: '_PARSE_ERROR_',
raw: typeof rawData === 'string' && rawData.length < 400 ? rawData : '(non-string or large)',
});
return;
}
if (!parsed) return;
if (parsed.type === 'agent_event') {
recordAgentEvent({
transport: 'ws',
opId,
agentEvent: parsed.event,
eventId: parsed.id,
rawLen,
});
} else {
events.push({
t: now(),
transport: 'ws',
opIdTail: (opId ?? '').slice(-10),
type: '_SERVER_MSG_',
serverType: parsed.type,
rawLen,
});
}
}
// Wrap the constructor. Instance `constructor` will still reflect OrigWS
// (we share prototypes), so use the `_WS_OPEN_` sentinel events to confirm
// the patch is firing.
function PatchedWebSocket(this: WebSocket, url: string | URL, protocols?: string | string[]) {
const ws: WebSocket = protocols == null ? new OrigWS(url) : new OrigWS(url, protocols);
const opId = extractOpIdFromWsUrl(url);
if (!isGatewayWs(url)) return ws;
events.push({
t: now(),
transport: 'ws',
opIdTail: (opId ?? '').slice(-10),
type: '_WS_OPEN_',
url: String(url),
});
// One observer listener that always fires, regardless of how the consumer
// (AgentStreamClient uses `ws.onmessage = …`) subscribes.
ws.addEventListener('message', (e) => {
try {
handleWsFrame((e as MessageEvent).data, opId);
} catch {
/* swallow */
}
});
ws.addEventListener('close', () => {
events.push({
t: now(),
transport: 'ws',
opIdTail: (opId ?? '').slice(-10),
type: '_WS_CLOSE_',
});
});
return ws;
}
// Preserve prototype + static fields so `instanceof WebSocket` and
// `WebSocket.OPEN` constants still work.
(PatchedWebSocket as unknown as { prototype: WebSocket }).prototype = OrigWS.prototype;
for (const k of Object.keys(OrigWS) as Array<keyof typeof OrigWS>) {
try {
(PatchedWebSocket as any)[k] = (OrigWS as any)[k];
} catch {
/* readonly */
}
}
(['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'] as const).forEach((k) => {
(PatchedWebSocket as any)[k] = (OrigWS as any)[k];
});
w.WebSocket = PatchedWebSocket as unknown as typeof WebSocket;
// ── 2. Patch window.fetch for `/api/agent/stream` (direct-mode SSE) ─
if (!w.__PROBE_ORIG_FETCH) w.__PROBE_ORIG_FETCH = w.fetch.bind(w);
const origFetch = w.__PROBE_ORIG_FETCH;
function isAgentStreamUrl(input: RequestInfo | URL): boolean {
let url = '';
if (typeof input === 'string') url = input;
else if (input instanceof URL) url = input.toString();
else if (input && typeof (input as Request).url === 'string') url = (input as Request).url;
return url.includes('/api/agent/stream');
}
function extractOpIdFromHttpUrl(input: RequestInfo | URL): string | null {
const url = typeof input === 'string' ? input : (input as Request | URL).toString();
const m = url.match(/operationId=([^&]+)/);
return m ? decodeURIComponent(m[1]) : null;
}
function pushFromSSEFrame(rawFrame: string, opId: string | null): void {
const lines = rawFrame.split('\n');
let dataJson = '';
let evtName = 'message';
for (const line of lines) {
if (line.startsWith('event:')) evtName = line.slice(6).trim();
else if (line.startsWith('data:')) dataJson += line.slice(5).trim();
}
if (!dataJson) return;
let parsed: any;
try {
parsed = JSON.parse(dataJson);
} catch {
events.push({
t: now(),
transport: 'sse',
opIdTail: (opId ?? '').slice(-10),
type: '_PARSE_ERROR_',
sseEvent: evtName,
raw: dataJson.length > 400 ? dataJson.slice(0, 400) + '…' : dataJson,
});
return;
}
recordAgentEvent({
transport: 'sse',
opId,
agentEvent: parsed,
eventId: null,
rawLen: dataJson.length,
});
}
async function teeAndDrain(response: Response, opId: string | null): Promise<Response> {
if (!response.body) return response;
const [a, b] = response.body.tee();
void (async () => {
const reader = b.getReader();
const decoder = new TextDecoder();
let buf = '';
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let idx: number;
while ((idx = buf.indexOf('\n\n')) !== -1) {
const frame = buf.slice(0, idx);
buf = buf.slice(idx + 2);
if (frame.trim()) pushFromSSEFrame(frame, opId);
}
}
if (buf.trim()) pushFromSSEFrame(buf, opId);
} catch (e: any) {
events.push({
t: now(),
transport: 'sse',
opIdTail: (opId ?? '').slice(-10),
type: '_TEE_ERROR_',
message: String(e?.message ?? e),
});
}
})();
return new Response(a, {
headers: response.headers,
status: response.status,
statusText: response.statusText,
});
}
w.fetch = async function patchedFetch(input: RequestInfo | URL, init?: RequestInit) {
const response = await origFetch(input as any, init);
if (!isAgentStreamUrl(input)) return response;
const opId = extractOpIdFromHttpUrl(input);
const url =
typeof input === 'string'
? input.split('?')[0]
: (input as Request | URL).toString().split('?')[0];
events.push({
t: now(),
transport: 'sse',
opIdTail: (opId ?? '').slice(-10),
type: '_CONNECTED_',
url,
status: response.status,
});
return teeAndDrain(response, opId);
} as typeof fetch;
// ── 3. Wrap store actions (best-effort for "who called replace") ────
// Side-global stash for the original chat-store actions. Re-installs ALWAYS
// rewrap from the originals so updates to the probe body take effect
// without a page reload — using only a `__probeWrapped` flag on the chat
// state object would freeze the first-installed wrapper across re-installs.
declare global {
interface Window {
__PROBE_ORIG_REFRESH_MESSAGES?: any;
__PROBE_ORIG_REPLACE_MESSAGES?: any;
}
}
try {
const chat = w.__LOBE_STORES?.chat?.();
if (chat) {
// First-time install: cache the originals. Re-install: restore from
// the cached originals before wrapping again.
if (!w.__PROBE_ORIG_REFRESH_MESSAGES) w.__PROBE_ORIG_REFRESH_MESSAGES = chat.refreshMessages;
if (!w.__PROBE_ORIG_REPLACE_MESSAGES) w.__PROBE_ORIG_REPLACE_MESSAGES = chat.replaceMessages;
const origRefresh = w.__PROBE_ORIG_REFRESH_MESSAGES;
const origReplace = w.__PROBE_ORIG_REPLACE_MESSAGES;
chat.refreshMessages = origRefresh;
chat.replaceMessages = origReplace;
chat.refreshMessages = async function probeRefresh(this: unknown, ...args: any[]) {
calls.push({
t: now(),
name: 'refreshMessages',
args: { context: args[0] ?? null },
stack: shortStack(),
});
return origRefresh.apply(this, args);
};
chat.replaceMessages = function probeReplace(this: unknown, ...args: any[]) {
const msgs = (args[0] as any[]) ?? [];
const snapshot = msgs.slice(-2).map((m) => ({
id: (m.id ?? '').slice(-8),
role: m.role,
cLen: (m.content ?? '').length,
rLen: (m.reasoning?.content ?? '').length,
updatedAt: m.updatedAt,
}));
calls.push({
t: now(),
name: 'replaceMessages',
args: { count: msgs.length, params: args[1] ?? null, snapshot } as any,
stack: shortStack(),
});
// Pair the call with a mutation row so the analyzer can build a
// single ordered timeline across replaceMessages + dispatchMessage.
const stackTop = shortStack().split(' ← ')[0]?.slice(0, 80);
const last = msgs.at(-1);
const lastSum = last
? {
id: (last.id ?? '').slice(-8),
role: last.role,
cLen: (last.content ?? '').length,
rLen: (last.reasoning?.content ?? '').length,
updatedAt: last.updatedAt,
}
: undefined;
const params: any = args[1] ?? {};
const ctxKey = params.context
? `main_${params.context.agentId ?? '?'}_${
params.context.topicId ? 'tpc_' + params.context.topicId : 'new'
}`.replace('main_tpc_', 'main_') // crude key inference
: '(no-ctx)';
mutations.push({
t: now(),
key: ctxKey,
n: msgs.length,
last: lastSum,
delta: `replaceMessages(action=${params.action ?? '-'}) src=${stackTop ?? '-'}`,
});
return origReplace.apply(this, args);
};
}
} catch (e: any) {
calls.push({ t: now(), name: '_WRAP_ERROR_', error: String(e?.message ?? e) });
}
// ── 3.5. Mutation log — wrap the TWO ChatStore writers (replaceMessages,
// internal_dispatchMessage) to record EVERY dbMessagesMap[key] reference
// change with a one-line "before/after last assistant message" delta. This
// reveals dispatchMessage-driven collapses that the replaceMessages wrap
// alone cannot see.
declare global {
interface Window {
__PROBE_ORIG_DISPATCH_MESSAGE?: any;
}
}
try {
const chat = w.__LOBE_STORES?.chat?.();
if (chat?.internal_dispatchMessage) {
if (!w.__PROBE_ORIG_DISPATCH_MESSAGE)
w.__PROBE_ORIG_DISPATCH_MESSAGE = chat.internal_dispatchMessage;
const origDispatch = w.__PROBE_ORIG_DISPATCH_MESSAGE;
chat.internal_dispatchMessage = origDispatch;
chat.internal_dispatchMessage = function probeDispatch(this: unknown, payload: any, ctx?: any) {
// Snapshot BEFORE — read the would-be target key + last message.
const before = (() => {
try {
const state = w.__LOBE_STORES?.chat?.();
if (!state) return null;
// Replicate state.internal_getConversationContext logic enough to
// resolve a key — but most callers pass operationId on ctx, and
// operationId-keyed lookup needs store internals. Easiest: snapshot
// ALL keys' last-assistant cLen and compare BEFORE vs AFTER below.
const map = state.dbMessagesMap ?? {};
const out: Record<string, any> = {};
for (const k of Object.keys(map)) {
const last = (map[k] ?? []).at(-1);
out[k] = last
? {
id: (last.id ?? '').slice(-8),
cLen: (last.content ?? '').length,
rLen: (last.reasoning?.content ?? '').length,
n: map[k].length,
}
: { n: 0 };
}
return out;
} catch {
return null;
}
})();
const result = origDispatch.apply(this, [payload, ctx]);
// Snapshot AFTER — find which key(s) actually changed.
try {
const state = w.__LOBE_STORES?.chat?.();
if (state && before) {
const map = state.dbMessagesMap ?? {};
for (const k of Object.keys(map)) {
const last = (map[k] ?? []).at(-1);
const beforeSnap = before[k];
const afterSnap = last
? {
id: (last.id ?? '').slice(-8),
cLen: (last.content ?? '').length,
rLen: (last.reasoning?.content ?? '').length,
n: map[k].length,
}
: { n: 0 };
const changed =
!beforeSnap ||
beforeSnap.n !== afterSnap.n ||
beforeSnap.id !== (afterSnap as any).id ||
beforeSnap.cLen !== (afterSnap as any).cLen ||
beforeSnap.rLen !== (afterSnap as any).rLen;
if (!changed) continue;
let delta = '';
if (beforeSnap?.id !== undefined && beforeSnap.id !== (afterSnap as any).id)
delta += `id:${beforeSnap.id}${(afterSnap as any).id};`;
if (
beforeSnap?.cLen !== undefined &&
(afterSnap as any).cLen !== undefined &&
(afterSnap as any).cLen < beforeSnap.cLen
)
delta += `cLen↓${beforeSnap.cLen}${(afterSnap as any).cLen};`;
if (
beforeSnap?.rLen !== undefined &&
(afterSnap as any).rLen !== undefined &&
(afterSnap as any).rLen < beforeSnap.rLen
)
delta += `rLen↓${beforeSnap.rLen}${(afterSnap as any).rLen};`;
if (beforeSnap?.n !== undefined && afterSnap.n < beforeSnap.n)
delta += `n↓${beforeSnap.n}${afterSnap.n};`;
mutations.push({
t: now(),
key: k,
n: afterSnap.n,
last: (afterSnap as any).id ? (afterSnap as any) : undefined,
prevLast: beforeSnap?.id ? beforeSnap : undefined,
delta: delta || `dispatch:${payload?.type}`,
});
}
}
} catch (e: any) {
mutations.push({
t: now(),
key: '_DISPATCH_PROBE_ERROR_',
n: -1,
delta: String(e?.message ?? e),
});
}
return result;
};
}
} catch (e: any) {
calls.push({ t: now(), name: '_DISPATCH_WRAP_ERROR_', error: String(e?.message ?? e) });
}
// ── 4. Periodic per-key timeline snapshots ─────────────────────────
function captureTimeline(): void {
try {
const c = w.__LOBE_STORES?.chat?.();
if (!c) return;
const msgsMap = (c.messagesMap ?? {}) as Record<string, any[]>;
const dbMap = (c.dbMessagesMap ?? {}) as Record<string, any[]>;
const byKey: ProbeTimelineSample['byKey'] = {};
for (const k of Object.keys(msgsMap)) {
const display = msgsMap[k] ?? [];
const db = dbMap[k] ?? [];
if (display.length === 0 && db.length === 0) continue;
byKey[k] = {
n: display.length,
dbN: db.length,
msgs: summarizeMessages(display),
};
}
const ops = Object.values((c.operations ?? {}) as Record<string, any>);
timeline.push({
t: now(),
activeTopic: ((c.activeTopicId as string | null) ?? '').slice(-10) || null,
keys: Object.keys(byKey),
byKey,
runOps: ops.filter((o: any) => o.status === 'running').length,
});
} catch (e: any) {
timeline.push({
t: now(),
activeTopic: null,
keys: [],
byKey: {},
runOps: 0,
err: e?.message ?? String(e),
});
}
}
captureTimeline();
if (w.__PROBE_TIMELINE_TIMER) clearInterval(w.__PROBE_TIMELINE_TIMER);
w.__PROBE_TIMELINE_TIMER = setInterval(captureTimeline, 200);
// ── 5. Tab-switch helpers ──────────────────────────────────────────
function listTopBarTabs(): HTMLElement[] {
return Array.from(
document.querySelectorAll<HTMLElement>(
'[data-insp-path*="TabItem.tsx"][data-contextmenu-trigger]',
),
).filter((t) => t.getBoundingClientRect().top < 30);
}
w.__listTabs = () =>
listTopBarTabs().map((t, i) => ({
i,
key: t.getAttribute('data-contextmenu-trigger'),
active: t.getAttribute('data-active') === 'true',
title: (t.innerText ?? '').slice(0, 60),
}));
w.__clickTabByKey = (key: string) => {
const tab = listTopBarTabs().find((t) => t.getAttribute('data-contextmenu-trigger') === key);
if (!tab) return 'not found: ' + key;
if (tab.getAttribute('data-active') === 'true') return 'already active: ' + key;
tab.click();
return 'clicked key=' + key;
};
w.__PROBE_EVENT = (name: string) => {
calls.push({ t: now(), name: 'MARK:' + name });
};
// `run.ts` wraps the bundle in an IIFE and appends a `return <confirmation>`
// after the bundle body — agent-browser then prints the confirmation back to
// the operator. Nothing to do here at the end of the module body.
@@ -0,0 +1,204 @@
// LobeHub chat streaming time-series probe.
//
// Inject into the renderer (via agent-browser eval) to record store + DOM
// snapshots every 200ms during a streaming session. Designed to surface
// "UI rolled back to an earlier state" symptoms — especially around
// gateway-mode tab switches that happen while the assistant is still writing.
//
// Usage:
// agent-browser --cdp 9222 eval --stdin < probe.js
// # ...do test interactions, call window.__PROBE_EVENT('LABEL') to mark moments...
// agent-browser --cdp 9222 eval --stdin < probe-dump.js > /tmp/probe.json
// node analyze.mjs /tmp/probe.json
//
// What it captures per sample:
// - activeTopicId
// - msgN: top-level messages in chat.messagesMap for this topic
// - childN: total assistantGroup.children blocks across all msgs (THIS is
// where streaming content actually lives — top-level assistantGroup stays empty)
// - cT / rT / toolT: totals across messages AND their children
// (content, reasoning, tool-call count)
// - perMsg: per-message breakdown so regressions can be located precisely
// - runOps: number of running operations (execServerAgentRuntime etc.)
// - domLen: total innerText length of the rendered chat list area
// - ind: visible UI indicators (Search pages, Crawled pages, Deeply Thought, Sending)
//
// Event markers: window.__PROBE_EVENT('NAME') records {t, name} into
// __PROBE_EVENTS, used by the analyzer to align state changes with
// user-driven actions (SENT, AWAY_1, BACK_1, ...).
(function () {
if (window.__PROBE_TIMER) clearInterval(window.__PROBE_TIMER);
window.__PROBE_SAMPLES = [];
window.__PROBE_EVENTS = [];
const t0 = Date.now();
function snapshot() {
try {
const chat = window.__LOBE_STORES.chat();
const topicId = chat.activeTopicId;
const idTail = topicId ? topicId.replace('tpc_', '') : null;
const keys = Object.keys(chat.messagesMap || {});
// Collect messages for the active topic. Before a topic is committed,
// optimistic messages live under the `<agentScope>_new` key — fall
// back to those when no topic is active yet.
let msgs = [];
if (idTail) {
keys.forEach((k) => {
if (k.includes(idTail)) msgs = msgs.concat(chat.messagesMap[k] || []);
});
} else {
keys
.filter((k) => k.endsWith('_new'))
.forEach((k) => {
msgs = msgs.concat(chat.messagesMap[k] || []);
});
}
// Walk top-level + assistantGroup.children. children carry the actual
// streamed content / reasoning / tool calls; the parent assistantGroup
// remains a placeholder (cLen=0, rLen=0) for its whole lifetime.
let totalContent = 0;
let totalReason = 0;
let totalTools = 0;
let childCount = 0;
const perMsg = msgs.map((m) => {
const cLen = (m.content || '').length;
const rLen = ((m.reasoning && m.reasoning.content) || '').length;
const tools = (m.tools || []).length;
totalContent += cLen;
totalReason += rLen;
totalTools += tools;
const children = m.children || [];
let chC = 0;
let chR = 0;
let chT = 0;
children.forEach((c) => {
chC += (c.content || '').length;
chR += ((c.reasoning && c.reasoning.content) || '').length;
chT += (c.tools || []).length;
});
totalContent += chC;
totalReason += chR;
totalTools += chT;
childCount += children.length;
return {
id: (m.id || '').slice(-8),
role: m.role,
cLen,
rLen,
tools,
chCount: children.length,
chC,
chR,
chT,
};
});
const ops = Object.values(chat.operations || {});
const runningOps = ops.filter((o) => o.status === 'running');
// DOM probe: total rendered text in the chat scroll area (proxy for
// "how much is actually visible to the user").
const convScroll =
document.querySelector(
'[data-chat-list], [class*="ChatList"], [class*="ConversationList"]',
) ||
document.querySelector('main [class*="scroll"]') ||
document.querySelector('main');
const domTxt = convScroll ? convScroll.innerText || '' : '';
const bodyTxt = document.body.innerText || '';
const searchMatches = (bodyTxt.match(/Search pages?:|Searched the web/g) || []).length;
const crawlMatches = (bodyTxt.match(/Crawl(ed|ing) pages?/g) || []).length;
window.__PROBE_SAMPLES.push({
t: Date.now() - t0,
topicId,
msgN: msgs.length,
childN: childCount,
cT: totalContent,
rT: totalReason,
toolT: totalTools,
perMsg,
runOps: runningOps.length,
runOpTypes: runningOps.map((o) => o.type),
domLen: domTxt.length,
ind: {
search: searchMatches,
crawl: crawlMatches,
sending: bodyTxt.includes('Sending message'),
deeplyThinking: bodyTxt.includes('Deeply Thinking'),
deeplyThought: bodyTxt.includes('Deeply Thought'),
},
});
} catch (e) {
window.__PROBE_SAMPLES.push({ t: Date.now() - t0, err: e.message });
}
}
snapshot();
window.__PROBE_TIMER = setInterval(snapshot, 200);
window.__PROBE_EVENT = function (name) {
window.__PROBE_EVENTS.push({ t: Date.now() - t0, name });
};
// Tab-switch helpers installed alongside the probe.
//
// The Electron tab bar mounts each tab as a div with data-insp-path
// ending in `TabItem.tsx:...`. The active tab is marked with
// data-active="true". DO NOT search by innerText — the active tab's text
// includes a ` · <agent name>` suffix that produces false matches when
// your search string happens to overlap with the agent name.
function listTabs() {
return Array.from(
document.querySelectorAll('[data-insp-path*="TabItem.tsx"][data-contextmenu-trigger]'),
).filter((t) => t.getBoundingClientRect().top < 30);
}
function tabKey(el) {
// Stable for the tab's lifetime; survives focus changes.
return el.getAttribute('data-contextmenu-trigger');
}
function findActiveTab() {
return listTabs().find((t) => t.getAttribute('data-active') === 'true') || null;
}
// Click by stable key captured earlier (preferred for round-trips).
window.__clickTabByKey = function (key) {
const tab = listTabs().find((t) => tabKey(t) === key);
if (!tab) return 'not found: key=' + key;
if (tab.getAttribute('data-active') === 'true') return 'already active: ' + key;
tab.click();
return 'clicked key=' + key;
};
// Click by index in the tab strip (0-based, left-to-right).
window.__clickTabByIndex = function (i) {
const tabs = listTabs();
if (i < 0 || i >= tabs.length) return 'index out of range: ' + i + '/' + tabs.length;
const t = tabs[i];
if (t.getAttribute('data-active') === 'true') return 'already active: i=' + i;
t.click();
return 'clicked i=' + i + ' key=' + tabKey(t);
};
// Snapshot all tabs in order: [{key, active, title (first 60 chars of innerText)}]
window.__listTabs = function () {
return listTabs().map((t, i) => ({
i,
key: tabKey(t),
active: t.getAttribute('data-active') === 'true',
title: (t.innerText || '').slice(0, 60),
}));
};
window.__activeTabKey = function () {
const a = findActiveTab();
return a ? tabKey(a) : null;
};
return 'probe installed';
})();
@@ -0,0 +1,211 @@
// CLI for the agent-gateway probe.
//
// Bundles the TS probes with esbuild, pipes them into `agent-browser eval`,
// and persists dumps under `.agent-gateway/` (gitignored) for later use as
// streaming-replay test fixtures.
//
// Commands:
// bun run .agents/skills/local-testing/scripts/agent-gateway/run.ts install
// Bundle probe-events.ts and inject into the CDP-attached browser.
// Re-installing clears all buffers and re-patches WebSocket / fetch.
//
// bun run .agents/skills/local-testing/scripts/agent-gateway/run.ts dump [name]
// Stop the timeline timer, fetch the capture as JSON, write it to
// `.agent-gateway/<name>-<YYYYMMDD-HHmmss>.json`. `name` defaults to
// `dump`. Prints the absolute path written.
//
// bun run .agents/skills/local-testing/scripts/agent-gateway/run.ts analyze [path]
// Run analyze-events.ts on the dump. `path` defaults to the most
// recently modified file in `.agent-gateway/`.
//
// Optional flags:
// --cdp <port> CDP port (default 9222)
// --browser <bin> agent-browser binary (default 'agent-browser')
import { spawn } from 'node:child_process';
import { mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
// .agents/skills/local-testing/scripts/agent-gateway/ → 5 levels up
const PROJECT_ROOT = path.resolve(SCRIPT_DIR, '../../../../..');
const DUMP_DIR = path.join(PROJECT_ROOT, '.agent-gateway');
interface Flags {
browser: string;
cdp: string;
positional: string[];
}
function parseFlags(argv: string[]): Flags {
const out: Flags = { cdp: '9222', browser: 'agent-browser', positional: [] };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--cdp') out.cdp = argv[++i] ?? out.cdp;
else if (a === '--browser') out.browser = argv[++i] ?? out.browser;
else out.positional.push(a);
}
return out;
}
async function bundle(entry: string): Promise<string> {
// Bun.build is built into the Bun runtime — no external dep needed.
const r = await Bun.build({
entrypoints: [path.join(SCRIPT_DIR, entry)],
target: 'browser',
format: 'esm',
minify: false,
});
if (!r.success) {
const msgs = r.logs.map((l) => `${l.level}: ${l.message}`).join('\n');
throw new Error(`bundle failed for ${entry}:\n${msgs}`);
}
return await r.outputs[0].text();
}
function wrapIife(body: string, returnExpr: string): string {
// Wrap as an IIFE that swallows the bundled top-level (top-level `const`
// declarations get scoped to the IIFE, so re-injection doesn't conflict)
// and returns the configured expression — which `agent-browser eval`
// captures and prints to stdout.
return `(() => {\n${body}\n;return ${returnExpr};\n})()`;
}
function runAgentBrowserEval(flags: Flags, script: string): Promise<string> {
return new Promise((resolveP, rejectP) => {
const child = spawn(flags.browser, ['--cdp', flags.cdp, 'eval', '--stdin'], {
stdio: ['pipe', 'pipe', 'inherit'],
});
let stdout = '';
child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf8');
});
child.on('error', rejectP);
child.on('close', (code) => {
if (code === 0) resolveP(stdout);
else rejectP(new Error(`agent-browser exited ${code}`));
});
child.stdin.write(script);
child.stdin.end();
});
}
// agent-browser prints eval results as JSON (string values are quoted).
function unquoteAgentBrowserResult(raw: string): string {
const trimmed = raw.trim();
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
try {
return JSON.parse(trimmed) as string;
} catch {
/* fall through */
}
}
return trimmed;
}
function isoStamp(): string {
const d = new Date();
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
return `${yyyy}${mm}${dd}-${hh}${mi}${ss}`;
}
function ensureDumpDir(): void {
mkdirSync(DUMP_DIR, { recursive: true });
}
function latestDump(): string | null {
ensureDumpDir();
const entries = readdirSync(DUMP_DIR)
.filter((f) => f.endsWith('.json'))
.map((f) => ({ f, mtime: statSync(path.join(DUMP_DIR, f)).mtimeMs }))
.sort((a, b) => b.mtime - a.mtime);
return entries[0] ? path.join(DUMP_DIR, entries[0].f) : null;
}
// ── Commands ────────────────────────────────────────────────────────
async function cmdInstall(flags: Flags): Promise<void> {
const body = await bundle('probe-events.ts');
const installMsg = JSON.stringify(
'events probe installed: WebSocket+fetch interception. ' +
'WS captures operationId= sockets (gateway), fetch captures /api/agent/stream (direct).',
);
const script = wrapIife(body, installMsg);
const out = await runAgentBrowserEval(flags, script);
console.log(unquoteAgentBrowserResult(out));
}
async function cmdDump(flags: Flags): Promise<void> {
const name = flags.positional[1] ?? 'dump';
const body = await bundle('probe-dump.ts');
const script = wrapIife(body, 'window.__PROBE_LAST_DUMP_JSON');
const raw = await runAgentBrowserEval(flags, script);
const json = unquoteAgentBrowserResult(raw);
ensureDumpDir();
const filename = `${name}-${isoStamp()}.json`;
const dumpPath = path.join(DUMP_DIR, filename);
writeFileSync(dumpPath, json, 'utf8');
// Validate by parsing the meta header so we error early on bad capture
try {
const parsed = JSON.parse(json) as {
meta?: { eventCount?: number; callCount?: number; sampleCount?: number };
};
const meta = parsed.meta ?? {};
console.log(
`wrote ${dumpPath} (${json.length} bytes events=${meta.eventCount ?? '?'} ` +
`calls=${meta.callCount ?? '?'} samples=${meta.sampleCount ?? '?'})`,
);
} catch {
console.log(`wrote ${dumpPath} (${json.length} bytes — JSON.parse failed; see file)`);
}
}
async function cmdAnalyze(flags: Flags): Promise<void> {
const target = flags.positional[1] ?? latestDump();
if (!target) {
console.error('no dump file found. run `dump` first or pass a path.');
process.exit(1);
}
const child = spawn('bun', ['run', path.join(SCRIPT_DIR, 'analyze-events.ts'), target], {
stdio: 'inherit',
});
await new Promise<void>((resolveP, rejectP) => {
child.on('error', rejectP);
child.on('close', (code) => (code === 0 ? resolveP() : rejectP(new Error(`exit ${code}`))));
});
}
// ── Entry point ─────────────────────────────────────────────────────
const flags = parseFlags(process.argv.slice(2));
const cmd = flags.positional[0];
const usage = `usage:
bun run run.ts install [--cdp 9222]
bun run run.ts dump [name] [--cdp 9222]
bun run run.ts analyze [path]
`;
if (!cmd) {
console.error(usage);
process.exit(1);
}
try {
if (cmd === 'install') await cmdInstall(flags);
else if (cmd === 'dump') await cmdDump(flags);
else if (cmd === 'analyze') await cmdAnalyze(flags);
else {
console.error(`unknown command: ${cmd}\n\n${usage}`);
process.exit(1);
}
} catch (e: any) {
console.error(e?.stack ?? e);
process.exit(1);
}
@@ -0,0 +1,72 @@
// Run N round-trip tab switches with event markers timed against the probe.
//
// agent-browser --cdp 9222 eval --stdin < tab-switch.js
//
// Captures the currently-active tab as the BACK target and the rightmost
// inactive tab as the AWAY target. Both are addressed by their stable
// data-contextmenu-trigger key (NOT by visible title — the active tab's
// innerText embeds a ` · <agent name>` suffix that breaks text matching).
//
// Fires the loop in the background and returns immediately so the
// agent-browser eval doesn't have to await the full ROUND_TRIPS × DWELL_MS
// duration. Wait on the `SWITCH_LOOP_DONE` event before dumping.
//
// Refuses to launch if a previous loop is still in flight.
//
// Requires probe.js to have been installed first (provides
// window.__PROBE_EVENT / __listTabs / __clickTabByKey / __activeTabKey).
(function () {
const ROUND_TRIPS = 4;
const DWELL_MS = 10_000;
if (!window.__PROBE_EVENT || !window.__listTabs || !window.__clickTabByKey) {
return 'probe not installed — eval probe.js first';
}
if (window.__SWITCH_LOOP_RUNNING) {
return 'switch loop already running — wait for SWITCH_LOOP_DONE first';
}
const tabs = window.__listTabs();
const activeTab = tabs.find((t) => t.active);
if (!activeTab) return 'no active tab — abort';
// Pick the first inactive tab as AWAY target. With multiple inactive tabs
// you'll usually want the one that's stable across the test — feel free
// to swap to tabs[tabs.length-1] if you want the rightmost.
const inactives = tabs.filter((t) => !t.active);
if (inactives.length === 0) return 'no inactive tab to switch to — abort';
const awayTab = inactives.at(-1); // rightmost inactive
const BACK_KEY = activeTab.key;
const AWAY_KEY = awayTab.key;
window.__SWITCH_LOOP_RUNNING = true;
window.__PROBE_EVENT('SWITCH_LOOP_CONFIG:back=' + BACK_KEY + ',away=' + AWAY_KEY);
(async function () {
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
try {
window.__PROBE_EVENT('SWITCH_LOOP_START');
for (let i = 1; i <= ROUND_TRIPS; i++) {
window.__PROBE_EVENT('AWAY_' + i);
const awayResult = window.__clickTabByKey(AWAY_KEY);
window.__PROBE_EVENT('AWAY_' + i + '_RES:' + awayResult.slice(0, 50));
await sleep(DWELL_MS);
window.__PROBE_EVENT('BACK_' + i);
const backResult = window.__clickTabByKey(BACK_KEY);
window.__PROBE_EVENT('BACK_' + i + '_RES:' + backResult.slice(0, 50));
await sleep(DWELL_MS);
}
window.__PROBE_EVENT('SWITCH_LOOP_DONE');
} finally {
window.__SWITCH_LOOP_RUNNING = false;
}
})();
return 'switch loop kicked off (BACK=' + BACK_KEY + ', AWAY=' + AWAY_KEY + ')';
})();
@@ -0,0 +1,113 @@
// Shared types between the in-browser probe and the Node-side analyzer.
// Kept tiny on purpose — anything the analyzer can re-derive is left off.
export interface ProbeStreamEvent {
/** Summarized payload — long strings truncated, arrays printed as Array(N) */
data?: Record<string, unknown>;
/** Keys present on the event's `data` payload — useful at a glance */
dataKeys?: string[];
/** ServerMessage.id — gateway WS frames carry an event-id we may resume from */
eventId?: string | null;
message?: string;
/** Last 10 chars of the operationId (full id is excessively long) */
opIdTail: string;
raw?: string;
/** Raw frame byte length, when applicable */
rawLen?: number;
/** For non-agent_event server frames (auth_success, heartbeat_ack, …) */
serverType?: string;
sseEvent?: string;
status?: number;
stepIndex?: number;
/** Milliseconds since the probe's t0 (install time). */
t: number;
/** 'ws' for gateway WebSocket frames, 'sse' for direct /api/agent/stream */
transport: 'ws' | 'sse';
/** Either the AgentStreamEvent.type, or a probe sentinel like `_WS_OPEN_` */
type: string;
url?: string;
}
export interface ProbeActionCall {
args?: {
count?: number;
context?: unknown;
params?: unknown;
};
error?: string;
/** `replaceMessages` / `refreshMessages` / `MARK:<label>` / `_WRAP_ERROR_` */
name: string;
stack?: string;
t: number;
}
export interface ProbeMessageSummary {
/** children.length */
chN: number;
/** content.length */
cLen: number;
/** Last 8 chars of the message id */
id: string;
/** reasoning.content.length */
rLen: number;
role: string;
/** tools.length */
tools: number;
}
export interface ProbeTimelineSample {
/** Last 10 chars of activeTopicId, or null */
activeTopic: string | null;
/** Per-key breakdown: display count, db count, message summaries */
byKey: Record<
string,
{
n: number;
dbN: number;
msgs: ProbeMessageSummary[];
}
>;
err?: string;
/** All messagesMap keys that have content at this moment */
keys: string[];
/** Number of operations in 'running' status */
runOps: number;
t: number;
}
export interface ProbeDumpMeta {
callCount: number;
/** Date.now() at dump call */
collectedAt: number;
eventCount: number;
sampleCount: number;
/** Date.now() at probe install */
t0: number;
}
export interface ProbeDump {
actionCalls: ProbeActionCall[];
meta: ProbeDumpMeta;
streamEvents: ProbeStreamEvent[];
timeline: ProbeTimelineSample[];
}
/**
* Globals the probe attaches to `window`. Keeps `as any` casts at the boundary
* instead of sprinkling them through the probe body.
*/
declare global {
interface Window {
__clickTabByKey?: (key: string) => string;
__listTabs?: () => Array<{ i: number; key: string | null; active: boolean; title: string }>;
__LOBE_STORES?: Record<string, () => any>;
__PROBE_ACTION_CALLS?: ProbeActionCall[];
__PROBE_EVENT?: (label: string) => void;
__PROBE_MSG_TIMELINE?: ProbeTimelineSample[];
__PROBE_ORIG_FETCH?: typeof fetch;
__PROBE_ORIG_WEBSOCKET?: typeof WebSocket;
__PROBE_STREAM_EVENTS?: ProbeStreamEvent[];
__PROBE_T0?: number;
__PROBE_TIMELINE_TIMER?: ReturnType<typeof setInterval> | null;
}
}
@@ -76,7 +76,9 @@ find_project_pids() {
port_pid=$(lsof -ti tcp:"$CDP_PORT" -sTCP:LISTEN 2>/dev/null || true)
pids="$pids $port_pid"
echo "$pids" | tr ' ' '\n' | sort -u | grep -v '^$' | tr '\n' ' '
# `|| true` because `grep -v '^$'` exits 1 when input has no non-empty
# lines, which (with pipefail + set -e) silently kills the caller.
echo "$pids" | tr ' ' '\n' | sort -u | grep -v '^$' | tr '\n' ' ' || true
}
# Wait for the CDP HTTP endpoint to respond, with a deadline + early bail-out
@@ -146,7 +148,7 @@ do_stop() {
for pid in $seed_pids; do
all_pids="$all_pids $(expand_descendants "$pid")"
done
all_pids=$(echo "$all_pids" | tr ' ' '\n' | sort -u | grep -v '^$' | tr '\n' ' ')
all_pids=$(echo "$all_pids" | tr ' ' '\n' | sort -u | grep -v '^$' | tr '\n' ' ' || true)
if [ -z "$all_pids" ]; then
echo "[electron-dev] No project Electron/vite processes found."
@@ -270,10 +272,17 @@ do_start() {
# Launch in a new session (setsid) so the whole process tree shares a PGID
# we can later signal in one shot. `setsid bash -c '... exec ...' &` keeps
# the bash shell as the session leader; its PID is what we save.
setsid bash -c "
# macOS doesn't ship setsid by default — fall back to plain bash; cleanup
# still works via `expand_descendants` walking the process tree.
local launch_cmd="
cd '$PROJECT_ROOT/apps/desktop'
exec npx electron-vite dev -- --remote-debugging-port=$CDP_PORT
" >> "$ELECTRON_LOG" 2>&1 < /dev/null &
"
if command -v setsid >/dev/null 2>&1; then
setsid bash -c "$launch_cmd" >> "$ELECTRON_LOG" 2>&1 < /dev/null &
else
bash -c "$launch_cmd" >> "$ELECTRON_LOG" 2>&1 < /dev/null &
fi
local launcher_pid=$!
echo "$launcher_pid" > "$PIDFILE"
echo "[electron-dev] Launcher PID (session leader): $launcher_pid"
+6
View File
@@ -1,10 +1,16 @@
---
name: microcopy
description: UI copy and microcopy guidelines. Use when writing UI text, buttons, error messages, empty states, onboarding, or any user-facing copy. Triggers on i18n translation, UI text writing, or copy improvement tasks. Supports both Chinese and English.
user-invocable: false
---
# LobeHub UI Microcopy Guidelines
This file is the quick-reference summary. For full prompt-style guidelines with extensive examples (anti-patterns, tone matrices, scenario walk-throughs), load the language-specific reference:
- **中文文案** — [`references/zh.md`](./references/zh.md)
- **English copy** — [`references/en.md`](./references/en.md)
Brand: **Where Agents Collaborate** - Focus on collaborative agent system, not just "generation".
## Fixed Terminology
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: modal
description: MUST use when creating, editing, or writing modal dialogs or imperative modals. Prefer createModal / useModalContext / confirmModal from @lobehub/ui/base-ui; root @lobehub/ui is legacy (antd Modal). Covers patterns, ModalHost, and migration notes.
description: "LobeHub imperative-modal conventions. Use whenever creating, editing, opening, or migrating a modal/dialog/popup — prefer `createModal` / `confirmModal` / `useModalContext` from `@lobehub/ui/base-ui` (headless) over the legacy root `@lobehub/ui` `createModal` (antd Modal props) and over any declarative `open` state + `<Modal />` pattern. Covers required `ModalHost` mounting, the `Content` + `index.tsx` file layout, `content` vs `children` slot, i18n inside `createModal()` (`import { t } from 'i18next'`), and migration notes. Triggers on `createModal`, `confirmModal`, `useModalContext`, `ModalHost`, `antd Modal`, `<Modal open>`, 'open a modal', 'popup', 'dialog', 'confirm dialog', '弹框', '弹窗', '确认框', 'migrate to base-ui'."
user-invocable: false
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: pr
description: "Create a PR for the current branch. Use when the user asks to create a pull request, submit PR, or says 'pr'."
description: "Create a PR for the current branch (targets `canary` by default). Use when the user asks to create a pull request, submit a PR, or says 'pr'. Triggers on 'pr', 'create pr', 'submit pr', 'open a PR', 'pull request', '提 PR', '提个 PR', '新建 PR'."
user-invocable: true
---
+63 -115
View File
@@ -1,19 +1,25 @@
---
name: project-overview
description: Complete project architecture and structure guide. Use when exploring the codebase, understanding project organization, finding files, or needing comprehensive architectural context. Triggers on architecture questions, directory navigation, or project overview needs.
description: "LobeHub open-source monorepo architecture map — flat `apps/` + `packages/@lobechat/*` + `src/` layout, per-layer location table, and `src/business/` stubs that the cloud repo overrides. Use when exploring an unfamiliar part of the codebase, locating where a layer lives (store / service / router / schema / etc.), or onboarding to the monorepo. Triggers on 'where does X live', 'project structure', 'monorepo layout', `src/business/` stub, 'architecture overview', '项目结构', '架构总览'."
user-invocable: false
---
# LobeHub Project Overview
> The directory listings below are a **curated map of key locations**, not an
> exhaustive tree. `packages/`, `src/store/`, route groups etc. grow over time —
> run `ls` against the real directory for the current set.
## Project Description
Open-source, modern-design AI Agent Workspace: **LobeHub** (previously LobeChat).
This repo is the **open-source root** (`github.com/lobehub/lobehub`, package `@lobehub/lobehub`).
**Supported platforms:**
- Web desktop/mobile
- Desktop (Electron)
- Mobile app (React Native) - coming soon
- Desktop (Electron)`apps/desktop`
- Mobile app (React Native) **separate repo, already launched** (not in this monorepo)
**Logo emoji:** 🤯
@@ -38,123 +44,49 @@ Open-source, modern-design AI Agent Workspace: **LobeHub** (previously LobeChat)
| Database | Neon PostgreSQL + Drizzle ORM |
| Testing | Vitest |
## Complete Project Structure
> Exact versions live in the root `package.json` — check there, not here.
Monorepo using `@lobechat/` namespace for workspace packages.
## Monorepo Layout
Flat layout — `apps/`, `packages/`, and `src/` all sit at the repo root. No
git submodules.
```
lobehub/
(repo root)
├── apps/
── desktop/ # Electron desktop app
├── docs/
── changelog/
│ ├── development/
│ ├── self-hosting/
│ └── usage/
├── locales/
│ ├── en-US/
── zh-CN/
├── packages/
│ ├── agent-runtime/ # Agent runtime
│ ├── builtin-agents/
│ ├── builtin-tool-*/ # Builtin tool packages
│ ├── business/ # Cloud-only business logic
│ │ ├── config/
│ │ ├── const/
│ │ └── model-runtime/
│ ├── config/
│ ├── const/
── cli/ # LobeHub CLI
├── desktop/ # Electron desktop app
── device-gateway/ # Device gateway service
├── docs/ # changelog, development, self-hosting, usage
├── locales/ # en-US, zh-CN, ...
├── packages/ # ~80 @lobechat/* workspace packages — `ls` for the full set. Key ones:
│ ├── agent-runtime/ # Agent runtime core
│ ├── agent-signal/ # Agent Signal pipeline
── agent-tracing/ # Tracing / snapshots
│ ├── builtin-tool-*/ # Per-tool packages (calculator, web-browsing, claude-code, ...)
│ ├── builtin-tools/ # Central registries that compose builtin-tool-*
│ ├── context-engine/
│ ├── conversation-flow/
│ ├── database/
│ └── src/
│ ├── models/
│ │ ├── schemas/
│ │ └── repositories/
│ ├── desktop-bridge/
│ ├── edge-config/
│ ├── editor-runtime/
│ ├── electron-client-ipc/
│ ├── electron-server-ipc/
│ ├── fetch-sse/
│ ├── file-loaders/
│ ├── memory-user-memory/
│ ├── model-bank/
│ ├── model-runtime/
│ │ └── src/
│ │ ├── core/
│ │ └── providers/
│ ├── observability-otel/
│ ├── prompts/
│ ├── python-interpreter/
│ ├── ssrf-safe-fetch/
│ ├── types/
│ ├── utils/
│ └── web-crawler/
├── src/
│ ├── app/
│ │ ├── (backend)/
│ │ │ ├── api/
│ │ │ ├── f/
│ │ │ ├── market/
│ │ │ ├── middleware/
│ │ │ ├── oidc/
│ │ │ ├── trpc/
│ │ │ └── webapi/
│ │ ├── spa/ # SPA HTML template service
│ │ └── [variants]/
│ │ └── (auth)/ # Auth pages (SSR required)
│ ├── routes/ # SPA page components (Vite)
│ │ ├── (main)/
│ │ ├── (mobile)/
│ │ ├── (desktop)/
│ │ ├── onboarding/
│ │ └── share/
│ ├── spa/ # SPA entry points and router config
│ │ ├── entry.web.tsx
│ │ ├── entry.mobile.tsx
│ │ ├── entry.desktop.tsx
│ │ └── router/
│ ├── business/ # Cloud-only (client/server)
│ │ ├── client/
│ │ ├── locales/
│ │ └── server/
│ ├── components/
│ ├── config/
│ ├── const/
│ ├── envs/
│ ├── features/
│ ├── helpers/
│ ├── hooks/
│ ├── layout/
│ │ ├── AuthProvider/
│ │ └── GlobalProvider/
│ ├── libs/
│ │ ├── better-auth/
│ │ ├── oidc-provider/
│ │ └── trpc/
│ ├── locales/
│ │ └── default/
│ ├── server/
│ │ ├── featureFlags/
│ │ ├── globalConfig/
│ │ ├── modules/
│ │ ├── routers/
│ │ │ ├── async/
│ │ │ ├── lambda/
│ │ │ ├── mobile/
│ │ │ └── tools/
│ │ └── services/
│ ├── services/
│ ├── store/
│ │ ├── agent/
│ │ ├── chat/
│ │ └── user/
│ ├── styles/
│ ├── tools/
│ ├── database/ # src/{models,schemas,repositories}
│ ├── model-bank/ # Model definitions & provider cards
├── model-runtime/ # src/{core,providers}
├── business/ # Open-source stubs (config, const, model-bank, model-runtime) — overridden by cloud
│ ├── types/
│ └── utils/
└── e2e/ # E2E tests (Cucumber + Playwright)
└── src/
├── app/
│ ├── (backend)/ # api, f, market, middleware, oidc, trpc, webapi
│ ├── spa/ # SPA HTML template service
│ └── [variants]/(auth)/ # Auth pages (SSR required)
├── routes/ # SPA page segments (thin — delegate to features/)
│ └── (main)/ (mobile)/ (desktop)/ (popup)/ onboarding/ share/
├── spa/ # SPA entries + router config
│ ├── entry.{web,mobile,desktop,popup}.tsx
│ └── router/
├── business/ # Open-source stubs (client/server) — cloud repo provides real impls
├── features/ # Domain business components
├── store/ # ~30 zustand stores — `ls` for the full set
├── server/ # featureFlags, globalConfig, modules, routers, services, workflows, agent-hono
└── ... # components, hooks, layout, libs, locales, services, types, utils
```
## Architecture Map
@@ -177,11 +109,27 @@ lobehub/
| DB Model | `packages/database/src/models` |
| DB Repository | `packages/database/src/repositories` |
| Third-party | `src/libs` (analytics, oidc, etc.) |
| Builtin Tools | `src/tools`, `packages/builtin-tool-*` |
| Cloud-only | `src/business/*`, `packages/business/*` |
| Builtin Tools | `packages/builtin-tool-*`, `packages/builtin-tools` |
| Open-source stub | `src/business/*`, `packages/business/*` (this repo) |
## Data Flow
```
React UI → Store Actions → Client Service → TRPC Lambda → Server Services → DB Model → PostgreSQL
```
## Note: Relationship to the Cloud Repo
This open-source repo is consumed by a **separate, private cloud (SaaS) repo**
as a git submodule mounted at `lobehub/`. The cloud repo provides:
- **`src/business/{client,server}`** and **`packages/business/*`** implementations
that override the stubs shipped here.
- Cloud-only routes (e.g. `(cloud)/`, `embed/`), cloud-only stores (e.g.
`subscription/`), cloud-only TRPC routers (billing, budget, risk control, …),
and Vercel cron routes under `src/app/(backend)/cron/`.
- File-resolution order in cloud: `@/store/x` → cloud `src/store/x` first, then
`lobehub/packages/store/src/x`, then `lobehub/src/store/x`. **Cloud override wins.**
When working in this repo alone, ignore the cloud layer — the stubs in
`src/business/` and `packages/business/` are the source of truth here.
+72 -70
View File
@@ -1,94 +1,96 @@
---
name: react
description: React component development guide. Use when working with React components (.tsx files), creating UI, using @lobehub/ui components, implementing routing, or building frontend features. Triggers on React component creation, modification, layout implementation, or navigation tasks.
description: "LobeHub React component conventions — styling via `antd-style` `createStaticStyles` + `cssVar.*` (zero-runtime preferred over `createStyles` + `token`), `@lobehub/ui` over antd when both exist, routing via `react-router-dom` (not `next/link`). Use when writing or editing any `.tsx` under `src/**`. Triggers on `createStaticStyles`, `createStyles`, `cssVar`, `antd-style`, `Flexbox`, `Center`, `Select`, `Modal`, `Drawer`, `Button`, `Tooltip`, `DropdownMenu`, `Popover`, `Switch`, `ScrollArea`, `Link`, `useNavigate`, `react-router-dom`, `next/link`, `desktopRouter`, `componentMap.desktop`, `.desktop.tsx`, 'new component', 'new page', 'edit layout', 'add styles', 'zustand selector', '@lobehub/ui', 'antd import'."
user-invocable: false
---
# React Component Writing Guide
- Use antd-style for complex styles; for simple cases, use inline `style` attribute
- **Prefer `createStaticStyles` with `cssVar.*`** (zero-runtime) — module-level, no hook call required
- Only fall back to `createStyles` + `token` when styles genuinely need runtime computation (dynamic props, JS color fns like `readableColor`/`chroma`)
- See `.cursor/docs/createStaticStyles_migration_guide.md` for full pattern
- Use `Flexbox` and `Center` from `@lobehub/ui` for layouts (see `references/layout-kit.md`)
- Component priority: `src/components` > `@lobehub/ui/base-ui` > `@lobehub/ui` > custom implementation
- Always prefer `@lobehub/ui/base-ui` primitives (Select, Modal, DropdownMenu, Popover, Switch, ScrollArea…) over antd equivalents
- Fall back to `@lobehub/ui` higher-level components when base-ui has no match
- Only implement a custom component as a last resort — never reach for antd directly
- Use selectors to access zustand store data
## Styling
## @lobehub/ui Components
| Scenario | Approach |
| ---------------------------------------------------------- | -------------------------------------------------------------- |
| Most cases | `createStaticStyles` + `cssVar.*` (zero-runtime, module-level) |
| Simple one-off | Inline `style` attribute |
| Truly dynamic (JS color fns like `readableColor`/`chroma`) | `createStyles` + `token`**last resort** |
If unsure about component usage, search existing code in this project. Most components extend antd with additional props.
## Component Priority
Reference: `node_modules/@lobehub/ui/es/index.mjs` for all available components.
1. **`src/components`** — project-specific reusable components
2. **`@lobehub/ui/base-ui`** — headless primitives (Select, Modal, DropdownMenu, Popover, Switch, ScrollArea…)
3. **`@lobehub/ui`** — higher-level components (ActionIcon, Markdown, DragPage…)
4. **Custom implementation** — last resort; never reach for antd directly
**Common Components:**
If unsure about available components, search existing code or check `node_modules/@lobehub/ui/es/index.mjs`.
- General: ActionIcon, ActionIconGroup, Block, Button, Icon
- Data Display: Avatar, Collapse, Empty, Highlighter, Markdown, Tag, Tooltip
- Data Entry: CodeEditor, CopyButton, EditableText, Form, FormModal, Input, SearchBar, Select
- Feedback: Alert, Drawer, Modal
- Layout: Center, DraggablePanel, Flexbox, Grid, Header, MaskShadow
- Navigation: Burger, Dropdown, Menu, SideNav, Tabs
### Common @lobehub/ui Components
| Category | Components |
| ------------ | ------------------------------------------------------------------------------- |
| General | ActionIcon, ActionIconGroup, Block, Button, Icon |
| Data Display | Avatar, Collapse, Empty, Highlighter, Markdown, Tag, Tooltip |
| Data Entry | CodeEditor, CopyButton, EditableText, Form, FormModal, Input, SearchBar, Select |
| Feedback | Alert, Drawer, Modal |
| Layout | Center, DraggablePanel, Flexbox, Grid, Header, MaskShadow |
| Navigation | Burger, Dropdown, Menu, SideNav, Tabs |
## Layout
Use `Flexbox` and `Center` from `@lobehub/ui`. See `references/layout-kit.md` for full props and examples.
- Use `gap` instead of `margin` for spacing between flex children
- Use `flex={1}` to fill available space
- Nest Flexbox for complex layouts; set `overflow: 'auto'` for scrollable regions
## Navigation
**For SPA pages, use `react-router-dom`, NOT `next/link`.**
```tsx
// ❌ Wrong
import Link from 'next/link';
// ✅ Correct
import { Link, useNavigate } from 'react-router-dom';
```
Access navigate from stores: `useGlobalStore.getState().navigate?.('/settings');`
## Desktop File Sync Rule
Files with a `.desktop.ts(x)` variant must be edited **in sync**. Drift causes blank pages in Electron.
| Base file (web) | Desktop file (Electron) |
| -------------------------- | ---------------------------------- |
| `desktopRouter.config.tsx` | `desktopRouter.config.desktop.tsx` |
| `componentMap.ts` | `componentMap.desktop.ts` |
**After editing any `.ts`/`.tsx`:** glob for `<filename>.desktop.{ts,tsx}` in the same directory. If found, apply the equivalent sync-import change.
## Routing Architecture
Hybrid routing: Next.js App Router (static pages) + React Router DOM (main SPA).
| Route Type | Use Case | Implementation |
| ------------------ | ---------- | -------------------------------------------------- |
| Next.js App Router | Auth pages | `src/app/[variants]/(auth)/` |
| React Router DOM | Main SPA | `desktopRouter.config.tsx` + `.desktop.tsx` (pair) |
| Route Type | Use Case | Implementation |
| ------------------ | --------------------------------- | ---------------------------------------------------------------------------- |
| Next.js App Router | Auth pages (login, signup, oauth) | `src/app/[variants]/(auth)/` |
| React Router DOM | Main SPA (chat, settings) | `desktopRouter.config.tsx` + `desktopRouter.config.desktop.tsx` (must match) |
### Key Files
- Entry: `src/spa/entry.web.tsx` (web), `src/spa/entry.mobile.tsx`, `src/spa/entry.desktop.tsx`
- Desktop router (pair — **always edit both** when changing routes): `src/spa/router/desktopRouter.config.tsx` (dynamic imports) and `src/spa/router/desktopRouter.config.desktop.tsx` (sync imports). Drift can cause unregistered routes / blank screen.
- Mobile router: `src/spa/router/mobileRouter.config.tsx`
- Router utilities: `src/utils/router.tsx`
### `.desktop.{ts,tsx}` File Sync Rule
**CRITICAL**: Some files have a `.desktop.ts(x)` variant that Electron uses instead of the base file. When editing a base file, **always check** if a `.desktop` counterpart exists and update it in sync. Drift causes blank pages or missing features in Electron.
Known pairs that must stay in sync:
| Base file (web, dynamic imports) | Desktop file (Electron, sync imports) |
| ----------------------------------------------------- | ------------------------------------------------------------- |
| `src/spa/router/desktopRouter.config.tsx` | `src/spa/router/desktopRouter.config.desktop.tsx` |
| `src/routes/(main)/settings/features/componentMap.ts` | `src/routes/(main)/settings/features/componentMap.desktop.ts` |
**How to check**: After editing any `.ts` / `.tsx` file, run `Glob` for `<filename>.desktop.{ts,tsx}` in the same directory. If a match exists, update it with the equivalent sync-import change.
### Router Utilities
Router utilities:
```tsx
import { dynamicElement, redirectElement, ErrorBoundary } from '@/utils/router';
element: dynamicElement(() => import('./chat'), 'Desktop > Chat');
element: redirectElement('/settings/profile');
errorElement: <ErrorBoundary />;
```
### Navigation
## Common Mistakes
**Important**: For SPA pages, use `Link` from `react-router-dom`, NOT `next/link`.
```tsx
// ❌ Wrong
import Link from 'next/link';
<Link href="/">Home</Link>;
// ✅ Correct
import { Link } from 'react-router-dom';
<Link to="/">Home</Link>;
// In components
import { useNavigate } from 'react-router-dom';
const navigate = useNavigate();
navigate('/chat');
// From stores
const navigate = useGlobalStore.getState().navigate;
navigate?.('/settings');
```
| Mistake | Fix |
| ----------------------------------------------------------------- | ----------------------------------------------------------------- |
| Using `next/link` in SPA | Use `react-router-dom` `Link` |
| Using antd directly | Use `@lobehub/ui/base-ui` first, then `@lobehub/ui` |
| `createStyles` for static styles | Use `createStaticStyles` + `cssVar` |
| Editing only `desktopRouter.config.tsx` | Must edit both `.tsx` and `.desktop.tsx` |
| Using `margin` for flex spacing | Use `gap` prop on Flexbox |
| Accessing zustand store without selector | Use selectors to access store data (see zustand skill) |
| Text or icon-text actions built with `Flexbox`/`Text` + `onClick` | Use `Button type={'text'} size={'small'}` with `icon` when needed |
+2 -1
View File
@@ -1,6 +1,7 @@
---
name: review-checklist
description: 'Common recurring mistakes in LobeHub code review — console leftovers, missing return await, hardcoded secrets, hardcoded i18n strings, desktop router pair drift, antd vs @lobehub/ui, non-idempotent migrations, cloud impact red flags. Use as a quick checklist when reviewing PRs, diffs, or branch changes.'
description: "Common recurring mistakes in LobeHub code review — `console.*` leftovers, missing `return await`, hardcoded secrets, hardcoded i18n strings, desktop router pair drift, antd vs `@lobehub/ui`, non-idempotent migrations, cloud impact red flags. Use as a quick checklist when reviewing a PR, diff, or branch change. Triggers on 'code review', 'review the diff', 'review this PR', 'review changes', 'PR review checklist', '审一下', '审 PR'."
user-invocable: false
---
# Review Checklist
+142
View File
@@ -0,0 +1,142 @@
---
name: skills-audit
description: Weekly audit of `.agents/skills/*/SKILL.md` — surfaces duplicate / overlapping / stale skills, inconsistent descriptions, broken cross-references, and merge/delete candidates. Run as a recurring health-check, not during normal feature work.
disable-model-invocation: true
argument-hint: '[--verbose | --apply]'
---
# Skills Audit
Periodic review of the project-local skill set under `.agents/skills/`. The goal is to catch drift before the catalog becomes confusing — too many skills, overlapping triggers, descriptions that no longer match the body, references to skills that were renamed/deleted.
**Recommended cadence:** weekly, or after any week where >1 skill was added/renamed.
## Procedure
### 1 — Inventory
Build a fresh census of all SKILL.md files. Do NOT trust any prior cached list.
```bash
find .agents/skills -name SKILL.md | wc -l # total count
find .agents/skills -name SKILL.md -exec wc -l {} \; | sort -rn # by body length
```
Group by domain in a mental table (DB / state / UI / agent / testing / workflow / docs / etc.). Note new arrivals since last audit (`git log --since="1 week ago" -- .agents/skills/`).
### 2 — Pull frontmatter for all skills
```bash
# Extract name + description for each SKILL.md
for f in .agents/skills/*/SKILL.md; do
echo "=== $(basename $(dirname $f)) ==="
awk '/^---$/{c++; next} c==1' "$f" | head -20
done
```
Read the description block of every skill. The body can stay unread unless step 4 flags it.
### 3 — Detect overlap / redundancy
For each pair within the same domain, ask:
- **Same description**? → likely duplicate (one is probably a stale rename leftover, or a global-vs-local collision).
- **Trigger keywords substantially overlap**? → either merge, OR tighten one description so the model can choose unambiguously.
- **One skill's body says "see also: foo"**? → confirm `foo` still exists, AND confirm the cross-reference is still meaningful (the referenced skill may have absorbed the referrer's concerns).
- **Skill duplicates content from `AGENTS.md`**? → fold into AGENTS.md or slim the skill to just the delta.
Common false positives (do NOT merge):
- `db-migrations` vs `drizzle` — distinct workflows (migration files vs schema authoring).
- `microcopy` vs `i18n` — content vs mechanics.
- `agent-runtime-hooks` vs `agent-tracing` vs `agent-signal` — different surfaces of the agent system.
- `testing` vs `local-testing` vs `cli-backend-testing` — different test types.
### 4 — Description format consistency
Apply the **standard template**:
```
{Topic + key conventions or scope}. Use when {scenarios — verbs + nouns}. Triggers on {`code-symbols`, 'natural phrases', '中文'}.
```
Skills with `disable-model-invocation: true` (user-invoked only, slash commands) don't need `Triggers on` — they're never auto-routed.
Flag descriptions that:
- ❌ Have NO `Use when` clause (model can't decide when to load it).
- ❌ Have NO `Triggers on` clause (and aren't `disable-model-invocation`).
- ❌ Use weird formats (numbered lists `(1)(2)(3)`, `Triggers:` colon instead of `Triggers on`, `MUST use when ...` as opening word).
- ❌ Are dramatically terse for a 200+ line body, or dramatically verbose for a 60-line body.
- ❌ Reference deleted/renamed skills.
### 5 — Stale-skill check
For narrow domain skills (e.g. `response-compliance`, one-off CLI workflows):
```bash
# Confirm the referenced code surface still exists
rg -l "response-compliance|openresponses" packages/ src/ # adjust per skill
git log --since="3 months ago" -- .agents/skills/ < skill > /SKILL.md # is it being maintained?
```
If the underlying surface is gone and the skill hasn't been edited in 3+ months → flag for archival.
### 6 — Cross-reference integrity
Any skill body mentioning another skill by name:
```bash
# Scan all skill bodies for skill-name references
rg -o '`[a-z][a-z0-9-]+`' .agents/skills/*/SKILL.md | grep -v ':\s*$' | sort -u
```
For each name extracted, confirm `.agents/skills/<name>/SKILL.md` exists. Broken references happen after renames — fix them in the same audit pass.
### 7 — Output report
Produce a markdown summary back to the user with the same structure as the original audit (this skill was created during one):
```markdown
## 📊 Inventory
{count, domain breakdown}
## 🎯 Recommendations
### 🔴 High confidence
- {action} — {reason}
### 🟡 Medium confidence
- {action} — {reason needs verification}
### 🟢 Low confidence / no-op
- {item considered but skipping because ...}
## 📋 Suggested order
{table of actions with risk + LOC estimate}
```
End by asking the user which actions to apply — do NOT auto-apply unless the user passed `--apply` and even then confirm destructive deletes individually.
## Output rules
- Be specific. "Skill X overlaps with Y" is useless without naming the overlapping triggers.
- Cite line numbers when flagging description / body issues.
- Don't recommend merges unless the call sites would actually load the merged skill in the same context.
- Don't recommend deletes for skills that haven't been touched recently — "unused" can mean "stable", not "dead".
## What NOT to do
- ❌ Don't rename skill directories without checking for cross-references AND user memory entries that name the old slug.
- ❌ Don't normalize a description by removing trigger keywords just to fit the template — the keywords are the routing signal.
- ❌ Don't fold a heavy 200+ line skill into another just because they share a domain — large skills get loaded selectively and merging makes everything load.
- ❌ Don't propose `.agents/skills/INDEX.md` or `<domain>-<skill>` prefix renames unless the user explicitly asks — costs > benefits for cosmetic reorgs.
## Related history
- First audit: `chore/skills-audit` branch (2026-05-25) — deleted `source-command-dedupe`, renamed `data-fetching``data-fetching-architecture`, normalized 9 descriptions, created this skill.
@@ -1,44 +0,0 @@
---
name: 'source-command-dedupe'
description: 'Find duplicate GitHub issues'
---
# source-command-dedupe
Use this skill when the user asks to run the migrated source command `dedupe`.
## Command Template
Find up to 3 likely duplicate issues for a given GitHub issue.
To do this, follow these steps precisely:
1. Use an agent to check if the Github issue (a) is closed, (b) does not need to be deduped (eg. because it is broad product feedback without a specific solution, or positive feedback), or (c) already has a duplicates comment that you made earlier. If so, do not proceed.
2. Use an agent to view a Github issue, and ask the agent to return a summary of the issue
3. Then, launch 5 parallel agents to search Github for duplicates of this issue, using diverse keywords and search approaches, using the summary from #1
4. Next, feed the results from #1 and #2 into another agent, so that it can filter out false positives, that are likely not actually duplicates of the original issue. If there are no duplicates remaining, do not proceed.
5. Finally, comment back on the issue with a list of up to three duplicate issues (or zero, if there are no likely duplicates)
Notes (be sure to tell this to your agents, too):
- Use `gh` to interact with Github, rather than web fetch
- Do not use other tools, beyond `gh` (eg. don't use other MCP servers, file edit, etc.)
- Make a todo list first
- For your comment, follow the following format precisely (assuming for this example that you found 3 suspected duplicates):
---
Found 3 possible duplicate issues:
1. <link to issue>
2. <link to issue>
3. <link to issue>
This issue will be automatically closed as a duplicate in 3 days.
- If your issue is a duplicate, please close it and 👍 the existing issue instead
- To prevent auto-closure, add a comment or 👎 this comment
> 🤖 Generated with Codex
---
+6 -5
View File
@@ -1,6 +1,7 @@
---
name: spa-routes
description: MUST use when editing src/routes/ segments, src/spa/router/desktopRouter.config.tsx or desktopRouter.config.desktop.tsx (always change both together), mobileRouter.config.tsx, or when moving UI/logic between routes and src/features/.
description: "SPA roots-vs-features split for LobeHub — thin route segments under `src/routes/` delegate to domain components under `src/features/`. Use when editing `src/routes/` segments, `src/spa/router/desktopRouter.config.tsx` or `desktopRouter.config.desktop.tsx` (MUST update both together`desktopRouter.sync.test.tsx` enforces this), `mobileRouter.config.tsx`, `popupRouter.config.tsx`, or moving UI/logic between `routes/` and `features/`. Triggers on `desktopRouter.config`, `mobileRouter.config`, `popupRouter.config`, `src/routes/**`, `src/features/**`, 'add a route', 'new page', 'route segment', '路由'."
user-invocable: false
---
# SPA Routes and Features Guide
@@ -84,10 +85,10 @@ Each feature should:
## 3a. Desktop router pair (`desktopRouter.config` × 2)
| File | Role |
|------|------|
| `desktopRouter.config.tsx` | Dynamic imports via `dynamicElement` / `dynamicLayout` — code-splitting; used by `entry.web.tsx` and `entry.desktop.tsx`. |
| `desktopRouter.config.desktop.tsx` | Same route tree with **synchronous** imports — kept for Electron / local parity and predictable bundling. |
| File | Role |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `desktopRouter.config.tsx` | Dynamic imports via `dynamicElement` / `dynamicLayout` — code-splitting; used by `entry.web.tsx` and `entry.desktop.tsx`. |
| `desktopRouter.config.desktop.tsx` | Same route tree with **synchronous** imports — kept for Electron / local parity and predictable bundling. |
Anything that changes the tree (new segment, renamed `path`, moved layout, new child route) must be reflected in **both** files in one PR or commit. Remove routes from both when deleting.
+71 -381
View File
@@ -1,257 +1,91 @@
---
name: store-data-structures
description: Zustand store data structure patterns for LobeHub. Covers List vs Detail data structures, Map + Reducer patterns, type definitions, and when to use each pattern. Use when designing store state, choosing data structures, or implementing list/detail pages.
description: "Zustand store data-shape patterns for LobeHub List vs Detail split, Map + Reducer, type definitions sourced from `@lobechat/types` (not `@lobechat/database`). Use when designing store state, choosing between Array (list) and `Record<string, Detail>` (detail map), or implementing a list/detail page pair. Triggers on `messagesMap`, `topicsMap`, `Record<string, Detail>`, 'list vs detail', 'store data shape', 'normalize state', 'state structure'."
user-invocable: false
---
# LobeHub Store Data Structures
This guide covers how to structure data in Zustand stores for optimal performance and user experience.
How to structure data in Zustand stores for fast list rendering, multi-detail caching, and ergonomic optimistic updates.
## Core Principles
### ✅ DO
1. **Separate List and Detail** - Use different structures for list pages and detail pages
2. **Use Map for Details** - Cache multiple detail pages with `Record<string, Detail>`
3. **Use Array for Lists** - Simple arrays for list display
4. **Types from @lobechat/types** - Never use `@lobechat/database` types in stores
5. **Distinguish List and Detail types** - List types may have computed UI fields
1. **Separate List and Detail** different structures for list pages and detail pages
2. **Use Map for Details** — cache multiple detail pages with `Record<string, Detail>`
3. **Use Array for Lists** — simple arrays for list display
4. **Types from `@lobechat/types`** — never use `@lobechat/database` types in stores
5. **Distinguish List and Detail types** List types may have computed UI fields
### ❌ DON'T
1. **Don't use single detail object** - Can't cache multiple pages
2. **Don't mix List and Detail types** - They have different purposes
3. **Don't use database types** - Use types from `@lobechat/types`
4. **Don't use Map for lists** - Simple arrays are sufficient
1. **Don't use a single detail object** — can't cache multiple pages
2. **Don't mix List and Detail types** — they have different purposes
3. **Don't use database types** — use types from `@lobechat/types`
4. **Don't use Map for lists** — simple arrays are sufficient
---
## Type Definitions
Types should be organized by entity in separate files:
Each entity gets its own file under `@lobechat/types/`. Each file exports two types:
```
@lobechat/types/src/eval/
├── benchmark.ts # Benchmark types
├── agentEvalDataset.ts # Dataset types
├── agentEvalRun.ts # Run types
└── index.ts # Re-exports
```
- **Detail type** — full entity, including heavy fields (rubrics, content, editor state, …)
- **List item type** — a **subset** that excludes heavy fields, may add computed UI fields (counts, timestamps formatted for display)
### Example: Benchmark Types
**Important:** the List type is a **subset**, not an `extends` of Detail. Extending pulls the heavy fields right back in.
```typescript
// packages/types/src/eval/benchmark.ts
import type { EvalBenchmarkRubric } from './rubric';
// ============================================
// Detail Type - Full entity (for detail pages)
// ============================================
/**
* Full benchmark entity with all fields including heavy data
*/
export interface AgentEvalBenchmark {
createdAt: Date;
description?: string | null;
id: string;
identifier: string;
isSystem: boolean;
metadata?: Record<string, unknown> | null;
name: string;
referenceUrl?: string | null;
rubrics: EvalBenchmarkRubric[]; // Heavy field
updatedAt: Date;
}
// ============================================
// List Type - Lightweight (for list display)
// ============================================
/**
* Lightweight benchmark item - excludes heavy fields
* May include computed statistics for UI
*/
export interface AgentEvalBenchmarkListItem {
createdAt: Date;
description?: string | null;
id: string;
identifier: string;
isSystem: boolean;
name: string;
// Note: rubrics NOT included (heavy field)
// Computed statistics for UI display
datasetCount?: number;
runCount?: number;
testCaseCount?: number;
}
```
### Example: Document Types (with heavy content)
```typescript
// packages/types/src/document.ts
/**
* Full document entity - includes heavy content fields
*/
export interface Document {
id: string;
title: string;
description?: string;
content: string; // Heavy field - full markdown content
editorData: any; // Heavy field - editor state
metadata?: Record<string, unknown>;
createdAt: Date;
updatedAt: Date;
}
/**
* Lightweight document item - excludes heavy content
*/
export interface DocumentListItem {
id: string;
title: string;
description?: string;
// Note: content and editorData NOT included
createdAt: Date;
updatedAt: Date;
// Computed statistics
wordCount?: number;
lastEditedBy?: string;
}
```
**Key Points:**
- **Detail types** include ALL fields from database (full entity)
- **List types** are **subsets** that exclude heavy/large fields
- List types may add computed statistics for UI (e.g., `testCaseCount`)
- **Each entity gets its own file** (not mixed together)
- **All types** exported from `@lobechat/types`, NOT `@lobechat/database`
**Heavy fields to exclude from List:**
- Large text content (`content`, `editorData`, `fullDescription`)
- Complex objects (`rubrics`, `config`, `metrics`)
- Binary data (`image`, `file`)
- Large arrays (`messages`, `items`)
> See [`references/types.md`](./references/types.md) for full worked examples (Benchmark, Document) and the heavy-field exclusion checklist.
---
## When to Use Map vs Array
### Use Map + Reducer (for Detail Data)
### Use Map + Reducer for Detail Data
**Detail page data caching** - Cache multiple detail pages simultaneously
**Optimistic updates** - Update UI before API responds
**Per-item loading states** - Track which items are being updated
**Multiple pages open** - User can navigate between details without refetching
**Structure:**
✅ Detail page data caching multiple detail pages cached simultaneously
✅ Optimistic updates — update UI before API responds
✅ Per-item loading states — track which items are being updated
✅ Multi-page navigation — user can switch between details without refetching
```typescript
benchmarkDetailMap: Record<string, AgentEvalBenchmark>;
```
**Example:** Benchmark detail pages, Dataset detail pages, User profiles
Examples: benchmark detail pages, dataset detail pages, user profiles.
### Use Simple Array (for List Data)
### Use Simple Array for List Data
**List display** - Lists, tables, cards
**Read-only or refresh-as-whole** - Entire list refreshes together
**No per-item updates** - No need to update individual items
**Simple data flow** - Easier to understand and maintain
**Structure:**
✅ List display — lists, tables, cards
Refresh as a whole — entire list refreshes together
✅ No per-item updates — no need to mutate individual rows in place
✅ Simple data flow — fewer moving parts
```typescript
benchmarkList: AgentEvalBenchmarkListItem[]
benchmarkList: AgentEvalBenchmarkListItem[];
```
**Example:** Benchmark list, Dataset list, User list
Examples: benchmark list, dataset list, user list.
---
## State Structure Pattern
### Complete Example
```typescript
// packages/types/src/eval/benchmark.ts
import type { EvalBenchmarkRubric } from './rubric';
/**
* Full benchmark entity (for detail pages)
*/
export interface AgentEvalBenchmark {
id: string;
name: string;
description?: string | null;
identifier: string;
rubrics: EvalBenchmarkRubric[]; // Heavy field
metadata?: Record<string, unknown> | null;
isSystem: boolean;
createdAt: Date;
updatedAt: Date;
}
/**
* Lightweight benchmark (for list display)
* Excludes heavy fields like rubrics
*/
export interface AgentEvalBenchmarkListItem {
id: string;
name: string;
description?: string | null;
identifier: string;
isSystem: boolean;
createdAt: Date;
// Note: rubrics excluded
// Computed statistics
testCaseCount?: number;
datasetCount?: number;
runCount?: number;
}
```
```typescript
// src/store/eval/slices/benchmark/initialState.ts
import type { AgentEvalBenchmark, AgentEvalBenchmarkListItem } from '@lobechat/types';
export interface BenchmarkSliceState {
// ============================================
// List Data - Simple Array
// ============================================
/**
* List of benchmarks for list page display
* May include computed fields like testCaseCount
*/
// List — simple array
benchmarkList: AgentEvalBenchmarkListItem[];
benchmarkListInit: boolean;
// ============================================
// Detail Data - Map for Caching
// ============================================
/**
* Map of benchmark details keyed by ID
* Caches detail page data for multiple benchmarks
* Enables optimistic updates and per-item loading
*/
// Detail — map for multi-entity caching
benchmarkDetailMap: Record<string, AgentEvalBenchmark>;
loadingBenchmarkDetailIds: string[]; // per-item loading
/**
* Track which benchmark details are being loaded/updated
* For showing spinners on specific items
*/
loadingBenchmarkDetailIds: string[];
// ============================================
// Mutation States
// ============================================
// Mutation states (drive form-level UI)
isCreatingBenchmark: boolean;
isUpdatingBenchmark: boolean;
isDeletingBenchmark: boolean;
@@ -272,180 +106,51 @@ export const benchmarkInitialState: BenchmarkSliceState = {
## Reducer Pattern (for Detail Map)
### Why Use Reducer?
When the Detail Map needs optimistic updates (i.e. the user edits a row and the UI should reflect it before the server confirms), wire a typed reducer instead of inlining `set` calls. This keeps mutations testable and the dispatch surface small.
- **Immutable updates** - Immer ensures immutability
- **Type-safe actions** - TypeScript discriminated unions
- **Testable** - Pure functions easy to test
- **Reusable** - Same reducer for optimistic updates and server data
### Reducer Structure
```typescript
// src/store/eval/slices/benchmark/reducer.ts
import { produce } from 'immer';
import type { AgentEvalBenchmark } from '@lobechat/types';
// ============================================
// Action Types
// ============================================
type SetBenchmarkDetailAction = {
id: string;
type: 'setBenchmarkDetail';
value: AgentEvalBenchmark;
};
type UpdateBenchmarkDetailAction = {
id: string;
type: 'updateBenchmarkDetail';
value: Partial<AgentEvalBenchmark>;
};
type DeleteBenchmarkDetailAction = {
id: string;
type: 'deleteBenchmarkDetail';
};
export type BenchmarkDetailDispatch =
| SetBenchmarkDetailAction
| UpdateBenchmarkDetailAction
| DeleteBenchmarkDetailAction;
// ============================================
// Reducer Function
// ============================================
export const benchmarkDetailReducer = (
state: Record<string, AgentEvalBenchmark> = {},
payload: BenchmarkDetailDispatch,
): Record<string, AgentEvalBenchmark> => {
switch (payload.type) {
case 'setBenchmarkDetail': {
return produce(state, (draft) => {
draft[payload.id] = payload.value;
});
}
case 'updateBenchmarkDetail': {
return produce(state, (draft) => {
if (draft[payload.id]) {
draft[payload.id] = { ...draft[payload.id], ...payload.value };
}
});
}
case 'deleteBenchmarkDetail': {
return produce(state, (draft) => {
delete draft[payload.id];
});
}
default:
return state;
}
};
```
### Internal Dispatch Methods
```typescript
// In action.ts
export interface BenchmarkAction {
// ... other methods ...
// Internal methods - not for direct UI use
internal_dispatchBenchmarkDetail: (payload: BenchmarkDetailDispatch) => void;
internal_updateBenchmarkDetailLoading: (id: string, loading: boolean) => void;
}
export const createBenchmarkSlice: StateCreator<...> = (set, get) => ({
// ... other methods ...
// Internal - Dispatch to reducer
internal_dispatchBenchmarkDetail: (payload) => {
const currentMap = get().benchmarkDetailMap;
const nextMap = benchmarkDetailReducer(currentMap, payload);
// Only update if changed
if (isEqual(nextMap, currentMap)) return;
set(
{ benchmarkDetailMap: nextMap },
false,
`dispatchBenchmarkDetail/${payload.type}`,
);
},
// Internal - Update loading state
internal_updateBenchmarkDetailLoading: (id, loading) => {
set(
(state) => {
if (loading) {
return { loadingBenchmarkDetailIds: [...state.loadingBenchmarkDetailIds, id] };
}
return {
loadingBenchmarkDetailIds: state.loadingBenchmarkDetailIds.filter((i) => i !== id),
};
},
false,
'updateBenchmarkDetailLoading',
);
},
});
```
> See [`references/reducer.md`](./references/reducer.md) for the full discriminated-union action types, the `produce`-based reducer, and the `internal_dispatch*` slice methods that connect them to Zustand.
---
## Data Structure Comparison
### ❌ WRONG - Single Detail Object
### ❌ WRONG Single Detail Object
```typescript
interface BenchmarkSliceState {
// ❌ Can only cache one detail
benchmarkDetail: AgentEvalBenchmark | null;
// ❌ Global loading state
isLoadingBenchmarkDetail: boolean;
}
```
**Problems:**
Problems:
- Can only cache one detail page at a time
- Switching between details causes unnecessary refetches
- Switching between details forces refetch
- No optimistic updates
- No per-item loading states
### ✅ CORRECT - Separate List and Detail
### ✅ CORRECT Separate List and Detail
```typescript
import type { AgentEvalBenchmark, AgentEvalBenchmarkListItem } from '@lobechat/types';
interface BenchmarkSliceState {
// ✅ List data - simple array
benchmarkList: AgentEvalBenchmarkListItem[];
benchmarkListInit: boolean;
// ✅ Detail data - map for caching
benchmarkDetailMap: Record<string, AgentEvalBenchmark>;
// ✅ Per-item loading
loadingBenchmarkDetailIds: string[];
// ✅ Mutation states
isCreatingBenchmark: boolean;
isUpdatingBenchmark: boolean;
isDeletingBenchmark: boolean;
}
```
**Benefits:**
Benefits:
- Cache multiple detail pages
- Fast navigation between cached details
- Optimistic updates with reducer
- Optimistic updates via reducer
- Per-item loading states
- Clear separation of concerns
@@ -455,22 +160,16 @@ interface BenchmarkSliceState {
### Accessing List Data
```typescript
```tsx
const BenchmarkList = () => {
// Simple array access
const benchmarks = useEvalStore((s) => s.benchmarkList);
const isInit = useEvalStore((s) => s.benchmarkListInit);
if (!isInit) return <Loading />;
return (
<div>
{benchmarks.map(b => (
<BenchmarkCard
key={b.id}
name={b.name}
testCaseCount={b.testCaseCount} // Computed field
/>
{benchmarks.map((b) => (
<BenchmarkCard key={b.id} name={b.name} testCaseCount={b.testCaseCount} />
))}
</div>
);
@@ -479,22 +178,18 @@ const BenchmarkList = () => {
### Accessing Detail Data
```typescript
```tsx
const BenchmarkDetail = () => {
const { benchmarkId } = useParams<{ benchmarkId: string }>();
// Get from map
const benchmark = useEvalStore((s) =>
benchmarkId ? s.benchmarkDetailMap[benchmarkId] : undefined,
);
// Check loading
const isLoading = useEvalStore((s) =>
benchmarkId ? s.loadingBenchmarkDetailIds.includes(benchmarkId) : false,
);
if (!benchmark) return <Loading />;
return (
<div>
<h1>{benchmark.name}</h1>
@@ -510,7 +205,6 @@ const BenchmarkDetail = () => {
// src/store/eval/slices/benchmark/selectors.ts
export const benchmarkSelectors = {
getBenchmarkDetail: (id: string) => (s: EvalStore) => s.benchmarkDetailMap[id],
isLoadingBenchmarkDetail: (id: string) => (s: EvalStore) =>
s.loadingBenchmarkDetailIds.includes(id),
};
@@ -524,7 +218,7 @@ const isLoading = useEvalStore(benchmarkSelectors.isLoadingBenchmarkDetail(bench
## Decision Tree
```
```text
Need to store data?
├─ Is it a LIST for display?
@@ -547,43 +241,40 @@ Need to store data?
When designing store state structure:
- [ ] **Organize types by entity** in separate files (e.g., `benchmark.ts`, `agentEvalDataset.ts`)
- [ ] **Organize types by entity** in separate files (e.g. `benchmark.ts`, `agentEvalDataset.ts`)
- [ ] Create **Detail** type (full entity with all fields including heavy ones)
- [ ] Create **ListItem** type:
- [ ] Subset of Detail type (exclude heavy fields)
- [ ] Subset of Detail (exclude heavy fields)
- [ ] May include computed statistics for UI
- [ ] **NOT** extending Detail type (it's a subset, not extension)
- [ ] **NOT** `extends` Detail
- [ ] Use **array** for list data: `xxxList: XxxListItem[]`
- [ ] Use **Map** for detail data: `xxxDetailMap: Record<string, Xxx>`
- [ ] Add per-item loading: `loadingXxxDetailIds: string[]`
- [ ] Create **reducer** for detail map if optimistic updates needed
- [ ] Add **internal dispatch** and **loading** methods
- [ ] Create **selectors** for clean access (optional but recommended)
- [ ] Document in comments:
- [ ] What fields are excluded from List and why
- [ ] What computed fields mean
- [ ] What each Map is for
- [ ] Per-item loading: `loadingXxxDetailIds: string[]`
- [ ] **Reducer** for detail map if optimistic updates needed (see [`references/reducer.md`](./references/reducer.md))
- [ ] **Internal dispatch** and **loading** methods
- [ ] **Selectors** for clean access (optional but recommended)
- [ ] Document in comments which fields are excluded from List and why
---
## Best Practices
1. **File organization** - One entity per file, not mixed together
2. **List is subset** - ListItem excludes heavy fields, not extends Detail
3. **Clear naming** - `xxxList` for arrays, `xxxDetailMap` for maps
4. **Consistent patterns** - All detail maps follow same structure
5. **Type safety** - Never use `any`, always use proper types
6. **Document exclusions** - Comment which fields are excluded from List and why
7. **Selectors** - Encapsulate access patterns
8. **Loading states** - Per-item for details, global for lists
9. **Immutability** - Use Immer in reducers
1. **File organization** — one entity per file, not mixed
2. **List is a subset** ListItem excludes heavy fields, does not `extends` Detail
3. **Clear naming** `xxxList` for arrays, `xxxDetailMap` for maps
4. **Consistent patterns** — all detail maps follow the same shape
5. **Type safety** — never use `any`, always use proper types
6. **Document exclusions** — comment which fields are excluded and why
7. **Selectors** — encapsulate access patterns
8. **Loading states** — per-item for details, global for mutations
9. **Immutability** — use Immer in reducers
### Common Mistakes to Avoid
**DON'T extend Detail in List:**
```typescript
// Wrong - List should not extend Detail
// Wrong — pulls heavy fields back in
export interface BenchmarkListItem extends Benchmark {
testCaseCount?: number;
}
@@ -592,7 +283,6 @@ export interface BenchmarkListItem extends Benchmark {
**DO create separate subset:**
```typescript
// Correct - List is a subset with computed fields
export interface BenchmarkListItem {
id: string;
name: string;
@@ -603,14 +293,14 @@ export interface BenchmarkListItem {
**DON'T mix entities in one file:**
```typescript
// Wrong - all entities in agentEvalEntities.ts
```text
// Wrong all entities in agentEvalEntities.ts
```
**DO separate by entity:**
```typescript
// Correct - separate files
```text
// Correct separate files
// benchmark.ts
// agentEvalDataset.ts
// agentEvalRun.ts
@@ -620,5 +310,5 @@ export interface BenchmarkListItem {
## Related Skills
- `data-fetching` - How to fetch and update this data
- `zustand` - General Zustand patterns
- `data-fetching-architecture` — how to fetch and update this data
- `zustand` — general Zustand patterns
@@ -0,0 +1,118 @@
# Reducer Pattern (for Detail Map)
## Why Use a Reducer?
- **Immutable updates** — Immer makes immutability easy
- **Type-safe actions** — discriminated union of action types prevents typos
- **Testable** — pure function, easy to unit test
- **Reusable** — same reducer powers optimistic updates and server-data writes
## Reducer Structure
```typescript
// src/store/eval/slices/benchmark/reducer.ts
import { produce } from 'immer';
import type { AgentEvalBenchmark } from '@lobechat/types';
// Action types — discriminated union
type SetBenchmarkDetailAction = {
id: string;
type: 'setBenchmarkDetail';
value: AgentEvalBenchmark;
};
type UpdateBenchmarkDetailAction = {
id: string;
type: 'updateBenchmarkDetail';
value: Partial<AgentEvalBenchmark>;
};
type DeleteBenchmarkDetailAction = {
id: string;
type: 'deleteBenchmarkDetail';
};
export type BenchmarkDetailDispatch =
| SetBenchmarkDetailAction
| UpdateBenchmarkDetailAction
| DeleteBenchmarkDetailAction;
export const benchmarkDetailReducer = (
state: Record<string, AgentEvalBenchmark> = {},
payload: BenchmarkDetailDispatch,
): Record<string, AgentEvalBenchmark> => {
switch (payload.type) {
case 'setBenchmarkDetail': {
return produce(state, (draft) => {
draft[payload.id] = payload.value;
});
}
case 'updateBenchmarkDetail': {
return produce(state, (draft) => {
if (draft[payload.id]) {
draft[payload.id] = { ...draft[payload.id], ...payload.value };
}
});
}
case 'deleteBenchmarkDetail': {
return produce(state, (draft) => {
delete draft[payload.id];
});
}
default:
return state;
}
};
```
## Internal Dispatch Methods
The slice exposes two `internal_*` methods so the reducer and the loading state stay encapsulated behind a stable contract:
```typescript
// In action.ts
export interface BenchmarkAction {
// ... other methods ...
// Internal — not for direct UI use
internal_dispatchBenchmarkDetail: (payload: BenchmarkDetailDispatch) => void;
internal_updateBenchmarkDetailLoading: (id: string, loading: boolean) => void;
}
export const createBenchmarkSlice: StateCreator<...> = (set, get) => ({
// ... other methods ...
// Dispatch to reducer
internal_dispatchBenchmarkDetail: (payload) => {
const currentMap = get().benchmarkDetailMap;
const nextMap = benchmarkDetailReducer(currentMap, payload);
// Skip set when nothing changed — avoids unnecessary re-renders
if (isEqual(nextMap, currentMap)) return;
set(
{ benchmarkDetailMap: nextMap },
false,
`dispatchBenchmarkDetail/${payload.type}`,
);
},
// Update loading state for a specific id
internal_updateBenchmarkDetailLoading: (id, loading) => {
set(
(state) => ({
loadingBenchmarkDetailIds: loading
? [...state.loadingBenchmarkDetailIds, id]
: state.loadingBenchmarkDetailIds.filter((i) => i !== id),
}),
false,
'updateBenchmarkDetailLoading',
);
},
});
```
The `internal_` prefix is a convention — UI components should call the public mutation methods (e.g. `updateBenchmark`), which in turn call `internal_dispatch*`. This keeps reducer dispatch shapes out of the component layer.
@@ -0,0 +1,101 @@
# Type Definitions in Detail
The skill body's Type Definitions section covers the rules; this file holds the full worked examples to keep SKILL.md lean.
## Organization
Types should be organized by entity in separate files (not mixed):
```text
@lobechat/types/src/eval/
├── benchmark.ts # Benchmark types
├── agentEvalDataset.ts # Dataset types
├── agentEvalRun.ts # Run types
└── index.ts # Re-exports
```
## Example: Benchmark Types
```typescript
// packages/types/src/eval/benchmark.ts
import type { EvalBenchmarkRubric } from './rubric';
/**
* Full benchmark entity with all fields including heavy data.
*/
export interface AgentEvalBenchmark {
createdAt: Date;
description?: string | null;
id: string;
identifier: string;
isSystem: boolean;
metadata?: Record<string, unknown> | null;
name: string;
referenceUrl?: string | null;
rubrics: EvalBenchmarkRubric[]; // Heavy field
updatedAt: Date;
}
/**
* Lightweight benchmark item — excludes heavy fields, may add computed stats.
*/
export interface AgentEvalBenchmarkListItem {
createdAt: Date;
description?: string | null;
id: string;
identifier: string;
isSystem: boolean;
name: string;
// Note: rubrics NOT included (heavy field)
// Computed statistics for UI display
datasetCount?: number;
runCount?: number;
testCaseCount?: number;
}
```
## Example: Document Types (with heavy content)
```typescript
// packages/types/src/document.ts
/**
* Full document entity — includes heavy content fields.
*/
export interface Document {
id: string;
title: string;
description?: string;
content: string; // Heavy field — full markdown content
editorData: any; // Heavy field — editor state
metadata?: Record<string, unknown>;
createdAt: Date;
updatedAt: Date;
}
/**
* Lightweight document item — excludes heavy content.
*/
export interface DocumentListItem {
id: string;
title: string;
description?: string;
// Note: content and editorData NOT included
createdAt: Date;
updatedAt: Date;
// Computed statistics
wordCount?: number;
lastEditedBy?: string;
}
```
## Heavy Fields to Exclude from List
- Large text content (`content`, `editorData`, `fullDescription`)
- Complex objects (`rubrics`, `config`, `metrics`)
- Binary data (`image`, `file`)
- Large arrays (`messages`, `items`)
The reason these belong only on Detail: list pages render many rows, so pulling heavy fields blows up payload size and slows render. Detail pages render one entity, so the full payload is fine.
+1
View File
@@ -1,6 +1,7 @@
---
name: testing
description: Testing guide using Vitest. Use when writing tests (.test.ts, .test.tsx), fixing failing tests, improving test coverage, or debugging test issues. Triggers on test creation, test debugging, mock setup, or test-related questions.
user-invocable: false
---
# LobeHub Testing Guide
+1
View File
@@ -1,6 +1,7 @@
---
name: trpc-router
description: TRPC router development guide. Use when creating or modifying TRPC routers (src/server/routers/**), adding procedures, or working with server-side API endpoints. Triggers on TRPC router creation, procedure implementation, or API endpoint tasks.
user-invocable: false
---
# TRPC Router Guide
+8 -2
View File
@@ -1,6 +1,7 @@
---
name: typescript
description: TypeScript code style and optimization guidelines. MUST READ before writing or modifying any TypeScript code (.ts, .tsx, .mts files). Also use when reviewing code quality or implementing type-safe patterns. Triggers on any TypeScript file edit, code style discussions, or type safety questions.
description: "TypeScript code style and type-safety guide for LobeHub. Read before writing or editing any `.ts` / `.tsx` / `.mts` — covers `interface` vs `type`, `Record<PropertyKey, unknown>` over `any`/`object`, `as const satisfies`, `@ts-expect-error` over `@ts-ignore`, `import type` (`separate-type-imports`), `async`/`await` + `Promise.all`, `for…of` over indexed `for`, and the no-silent-`.catch(() => fallback)` rule. Also use when reviewing type quality, deciding module augmentation (`declare module`) over `namespace`, or designing extensible types (e.g. `PipelineContext.metadata`). Triggers on any TypeScript file edit, 'fix the type', 'why is this `any`', 'should this be interface or type', 'eslint type-import', 'ts-expect-error'."
user-invocable: false
---
# TypeScript Code Style Guide
@@ -28,12 +29,16 @@ description: TypeScript code style and optimization guidelines. MUST READ before
## Imports
- This project uses `simple-import-sort/imports` and `consistent-type-imports` (`fixStyle: 'separate-type-imports'`)
- **Separate type imports**: always use `import type { ... }` for type-only imports, NOT `import { type ... }` inline syntax
- When a file already has `import type { ... }` from a package and you need to add a value import, keep them as **two separate statements**:
```ts
import type { ChatTopicBotContext } from '@lobechat/types';
import { RequestTrigger } from '@lobechat/types';
```
- Within each import statement, specifiers are sorted **alphabetically by name**
## Code Structure
@@ -42,6 +47,8 @@ description: TypeScript code style and optimization guidelines. MUST READ before
- Use consistent, descriptive naming; avoid obscure abbreviations
- Replace magic numbers/strings with well-named constants
- Defer formatting to tooling
- Prefer **named exports** over `export default` — keeps refactor renames and IDE auto-import in sync, and avoids the `default` re-naming drift you get with `import Foo from './foo'`. Reserve `export default` for files where the framework requires it (Next.js page/route/layout, React.lazy targets, config files like `vitest.config.ts`)
- Before adding local helpers for common guards/parsing/normalization (record checks, string extraction, empty-string handling, timing helpers, JSON-safe utilities, etc.), search `packages/utils` first. If the helper already exists or clearly belongs there, import it from `@lobechat/utils` (or the relevant `@lobechat/utils/*` subpath) instead of duplicating tiny helpers across feature files.
## UI and Theming
@@ -51,7 +58,6 @@ description: TypeScript code style and optimization guidelines. MUST READ before
## Performance
- Prefer `for…of` loops over index-based `for` loops
- Reuse existing utils in `packages/utils` or installed npm packages
- Query only required columns from database
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,226 @@
# Best Practices & Common Pitfalls
Apply these once your scaffold from `implementation.md` is in place.
## Table of Contents
1. [Error Handling](#1-error-handling)
2. [Logging](#2-logging)
3. [Return Values](#3-return-values)
4. [flowControl Configuration](#4-flowcontrol-configuration)
5. [context.run() Best Practices](#5-contextrun-best-practices)
6. [Payload Validation](#6-payload-validation)
7. [Database Connection](#7-database-connection)
8. [Testing](#8-testing)
9. [Common Pitfalls](#common-pitfalls)
---
## 1. Error Handling
```typescript
export const { POST } = serve<Payload>(
async (context) => {
const { itemId } = context.requestPayload ?? {};
if (!itemId) {
return { success: false, error: 'Missing itemId in payload' };
}
try {
const result = await context.run('step-name', () => doWork(itemId));
return { success: true, itemId, result };
} catch (error) {
console.error('[workflow:error]', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
},
{ flowControl: { ... } },
);
```
## 2. Logging
Consistent prefixes make debugging much easier across QStash dashboards and grep:
```typescript
console.log('[{workflow}:{layer}] Starting with payload:', payload);
console.log('[{workflow}:{layer}] Processing items:', { count: items.length });
console.log('[{workflow}:{layer}] Completed:', result);
console.error('[{workflow}:{layer}:error]', error);
```
## 3. Return Values
Pick the shape that matches the layer's purpose — entry points return statistics, execution layers return per-item results.
```typescript
// Success
return { success: true, itemId, result, message: 'Optional success message' };
// Error
return { success: false, error: 'Error description', itemId };
// Statistics (entry point)
return {
success: true,
totalEligible: 100,
toProcess: 80,
alreadyProcessed: 20,
dryRun: true, // if applicable
message: 'Summary message',
};
```
## 4. flowControl Configuration
Tune concurrency by layer — entry points are singletons, execution layers fan out.
```typescript
// Layer 1: Entry — single instance to avoid duplicate processing
flowControl: { key: '{workflow}.process', parallelism: 1, ratePerSecond: 1 }
// Layer 2: Pagination — moderate concurrency
flowControl: { key: '{workflow}.paginate', parallelism: 20, ratePerSecond: 5 }
// Layer 3: Execution — higher concurrency for parallel item work
flowControl: { key: '{workflow}.execute', parallelism: 10, ratePerSecond: 5 }
```
**Why these defaults:**
- **Layer 1** always uses `parallelism: 1` so concurrent triggers don't both start the same batch.
- **Layer 2** can fan out widely (10-20) since pagination is cheap.
- **Layer 3** caps at 5-10 by default; raise/lower based on external API rate limits.
## 5. context.run() Best Practices
- Use descriptive step names with prefixes: `{workflow}:step-name`
- Each step should be idempotent (safe to retry)
- Don't nest `context.run()` calls — keep them flat
- Use unique step names when processing multiple items:
```typescript
// ✅ Unique step names
await Promise.all(
items.map((item) => context.run(`{workflow}:execute:${item.id}`, () => processItem(item))),
);
// ❌ Same step name — Upstash de-dupes by step name and you'll lose data
await Promise.all(items.map((item) => context.run(`{workflow}:execute`, () => processItem(item))));
```
## 6. Payload Validation
Validate at the top so failures are explicit, not silent `undefined` cascades:
```typescript
export const { POST } = serve<Payload>(
async (context) => {
const { itemId, configId } = context.requestPayload ?? {};
if (!itemId) return { success: false, error: 'Missing itemId in payload' };
if (!configId) return { success: false, error: 'Missing configId in payload' };
// Proceed with work...
},
{ flowControl: { ... } },
);
```
## 7. Database Connection
Get the connection once per workflow — `getServerDB()` is async, repeating it inside each step adds latency:
```typescript
export const { POST } = serve<Payload>(
async (context) => {
const db = await getServerDB();
const item = await context.run('get-item', () => itemModel.findById(db, itemId));
const result = await context.run('save-result', () => resultModel.create(db, result));
},
{ flowControl: { ... } },
);
```
## 8. Testing
Integration tests should exercise both the dry-run statistics path and the full execution path:
```typescript
describe('WorkflowName', () => {
it('should process items successfully', async () => {
const items = await createTestItems();
await WorkflowClass.triggerProcessItems({ dryRun: false });
await waitForCompletion();
const results = await getResults();
expect(results).toHaveLength(items.length);
});
it('should support dryRun mode', async () => {
const result = await WorkflowClass.triggerProcessItems({ dryRun: true });
expect(result).toMatchObject({
success: true,
dryRun: true,
totalEligible: expect.any(Number),
toProcess: expect.any(Number),
});
});
});
```
---
## Common Pitfalls
### ❌ Reusing `context.run()` step names
```typescript
// Bad — Upstash dedupes by step name
await Promise.all(items.map((item) => context.run('process', () => process(item))));
// Good
await Promise.all(items.map((item) => context.run(`process:${item.id}`, () => process(item))));
```
### ❌ Skipping payload validation
```typescript
// Bad — undefined cascades into a confusing failure later
const { itemId } = context.requestPayload ?? {};
const result = await process(itemId);
// Good — fail fast with a clear error
if (!itemId) return { success: false, error: 'Missing itemId' };
```
### ❌ Skipping the filter step
```typescript
// Bad — duplicates work for items that were already processed
const allItems = await getAllItems();
await Promise.all(allItems.map((item) => triggerExecute(item)));
// Good — keeps the pipeline idempotent
const allItems = await getAllItems();
const itemsNeedingProcessing = await filterExisting(allItems);
await Promise.all(itemsNeedingProcessing.map((item) => triggerExecute(item)));
```
### ❌ Inconsistent logging
```typescript
// Bad — different prefixes, mixed formats
console.log('Starting workflow');
log.info('Processing item:', itemId);
console.log(`Done with ${itemId}`);
// Good — uniform prefix lets you grep by workflow+layer
console.log('[workflow:layer] Starting with payload:', payload);
console.log('[workflow:layer] Processing item:', { itemId });
console.log('[workflow:layer] Completed:', { itemId, result });
```
@@ -1,6 +1,20 @@
# Cloud Project Workflow Configuration
This document covers cloud-specific workflow configurations and patterns for the lobehub-cloud project.
Cloud-specific workflow configurations and patterns for the lobehub-cloud project.
## Table of Contents
1. [Overview](#overview)
2. [Directory Structure](#directory-structure) — submodule + cloud layout
3. [Cloud-Specific Patterns](#cloud-specific-patterns) — cloud-only workflows + re-export pattern
4. [TypeScript Path Mappings](#typescript-path-mappings)
5. [Workflow Class Location](#workflow-class-location) — cloud-only vs shared
6. [Environment Variables](#environment-variables)
7. [Best Practices](#best-practices) — decide cloud vs OSS, re-export rules, naming
8. [Migration Guide](#migration-guide) — moving workflows from cloud to lobehub
9. [Examples](#examples) — `welcome-placeholder`, `agent-eval-run`
10. [Troubleshooting](#troubleshooting) — circular imports, 404s, type errors
11. [Related Documentation](#related-documentation)
## Overview
@@ -15,7 +29,7 @@ The lobehub-cloud project extends the open-source lobehub codebase with cloud-sp
### Lobehub Submodule (Open-source)
```
```text
lobehub/
└── src/
├── app/(backend)/api/workflows/
@@ -28,7 +42,7 @@ lobehub/
### Lobehub-cloud (Proprietary)
```
```text
lobehub-cloud/
└── src/
├── app/(backend)/api/workflows/
@@ -60,7 +74,7 @@ lobehub-cloud/
**Structure**:
```
```text
lobehub-cloud/src/
├── app/(backend)/api/workflows/
│ └── feature-name/
@@ -162,7 +176,7 @@ This allows cloud to override specific modules while using lobehub defaults.
Place workflow class in cloud:
```
```text
lobehub-cloud/src/server/workflows/featureName/index.ts
```
@@ -170,7 +184,7 @@ lobehub-cloud/src/server/workflows/featureName/index.ts
Place workflow class in lobehub, re-export in cloud if needed:
```
```text
lobehub/src/server/workflows/featureName/index.ts
```
@@ -245,7 +259,7 @@ For shared features:
Follow consistent naming across lobehub and cloud:
```
```text
# Both should use same structure
lobehub/src/app/(backend)/api/workflows/feature-name/
lobehub-cloud/src/app/(backend)/api/workflows/feature-name/
@@ -306,7 +320,7 @@ import { Workflow } from 'lobehub/src/server/workflows/feature';
**Structure**:
```
```text
lobehub-cloud/
├── src/app/(backend)/api/workflows/welcome-placeholder/
│ ├── process-users/route.ts
@@ -0,0 +1,91 @@
# Worked Examples
Two real workflows already in the codebase that follow this skill's pattern verbatim. Skim them when you want to see the pattern applied to concrete entities.
## Example 1: Welcome Placeholder
**Use case:** Generate AI-powered welcome placeholders for users.
**Structure:**
- Layer 1: `process-users` — entry point, checks eligible users
- Layer 2: `paginate-users` — paginates through active users
- Layer 3: `generate-user` — generates placeholders for ONE user
**Key features:**
- Filters users who already have cached placeholders in Redis
- `paidOnly` flag to scope to subscribed users
- `dryRun` mode for statistics
- Fan-out for large user batches (`CHUNK_SIZE=20`)
**Layer 3 shape:**
```typescript
export const { POST } = serve<GenerateUserPlaceholderPayload>(async (context) => {
const { userId } = context.requestPayload ?? {};
const workflow = new WelcomePlaceholderWorkflow(db, userId);
const placeholders = await context.run('generate', () => workflow.generate());
return { success: true, userId, placeholdersCount: placeholders.length };
});
```
**Files:**
- `/api/workflows/welcome-placeholder/process-users/route.ts`
- `/api/workflows/welcome-placeholder/paginate-users/route.ts`
- `/api/workflows/welcome-placeholder/generate-user/route.ts`
- `/server/workflows/welcomePlaceholder/index.ts`
---
## Example 2: Agent Welcome
**Use case:** Generate welcome messages and open questions for AI agents.
**Structure:**
- Layer 1: `process-agents` — entry point, checks eligible agents
- Layer 2: `paginate-agents` — paginates through active agents
- Layer 3: `generate-agent` — generates welcome data for ONE agent
**Key features:**
- Filters agents who already have cached data in Redis
- `paidOnly` flag for subscribed users' agents only
- `dryRun` mode for statistics
- Fan-out for large agent batches (`CHUNK_SIZE=20`)
**Layer 3 shape:**
```typescript
export const { POST } = serve<GenerateAgentWelcomePayload>(async (context) => {
const { agentId } = context.requestPayload ?? {};
const workflow = new AgentWelcomeWorkflow(db, agentId);
const data = await context.run('generate', () => workflow.generate());
return { success: true, agentId, data };
});
```
**Files:**
- `/api/workflows/agent-welcome/process-agents/route.ts`
- `/api/workflows/agent-welcome/paginate-agents/route.ts`
- `/api/workflows/agent-welcome/generate-agent/route.ts`
- `/server/workflows/agentWelcome/index.ts`
---
## What's identical, what differs
Both workflows are the **same pattern** — they only differ in:
- Entity type (users vs agents)
- Business logic (placeholder generation vs welcome generation)
- Data source (different database queries)
Everything else — the 3-layer split, dry-run handling, fan-out, filter-existing, flowControl tuning — is identical. That's the whole point: once you internalize the pattern, adding a new workflow is mostly entity-substitution.
@@ -0,0 +1,333 @@
# Implementation Patterns
Full code templates for the 3-layer architecture. Read this when actually writing workflow files.
## Table of Contents
1. [Workflow Class](#workflow-class) — `src/server/workflows/{workflowName}/index.ts`
2. [Layer 1: Entry Point](#layer-1-entry-point-process-) — `process-*` route
3. [Layer 2: Pagination](#layer-2-pagination-paginate-) — `paginate-*` route
4. [Layer 3: Execution](#layer-3-execution-execute--generate-) — `execute-*` / `generate-*` route
---
## Workflow Class
**Location:** `src/server/workflows/{workflowName}/index.ts`
```typescript
import { Client } from '@upstash/workflow';
import debug from 'debug';
const log = debug('lobe-server:workflows:{workflow-name}');
// Workflow paths
const WORKFLOW_PATHS = {
processItems: '/api/workflows/{workflow-name}/process-items',
paginateItems: '/api/workflows/{workflow-name}/paginate-items',
executeItem: '/api/workflows/{workflow-name}/execute-item',
} as const;
// Payload types
export interface ProcessItemsPayload {
dryRun?: boolean;
force?: boolean;
}
export interface PaginateItemsPayload {
cursor?: string;
itemIds?: string[]; // For fanout chunks
}
export interface ExecuteItemPayload {
itemId: string;
}
const getWorkflowUrl = (path: string): string => {
const baseUrl = process.env.APP_URL;
if (!baseUrl) throw new Error('APP_URL is required to trigger workflows');
return new URL(path, baseUrl).toString();
};
const getWorkflowClient = (): Client => {
const token = process.env.QSTASH_TOKEN;
if (!token) throw new Error('QSTASH_TOKEN is required to trigger workflows');
const config: ConstructorParameters<typeof Client>[0] = { token };
if (process.env.QSTASH_URL) {
(config as Record<string, unknown>).url = process.env.QSTASH_URL;
}
return new Client(config);
};
export class {WorkflowName}Workflow {
private static client: Client;
private static getClient(): Client {
if (!this.client) this.client = getWorkflowClient();
return this.client;
}
static triggerProcessItems(payload: ProcessItemsPayload) {
const url = getWorkflowUrl(WORKFLOW_PATHS.processItems);
log('Triggering process-items workflow');
return this.getClient().trigger({ body: payload, url });
}
static triggerPaginateItems(payload: PaginateItemsPayload) {
const url = getWorkflowUrl(WORKFLOW_PATHS.paginateItems);
log('Triggering paginate-items workflow');
return this.getClient().trigger({ body: payload, url });
}
static triggerExecuteItem(payload: ExecuteItemPayload) {
const url = getWorkflowUrl(WORKFLOW_PATHS.executeItem);
log('Triggering execute-item workflow: %s', payload.itemId);
return this.getClient().trigger({ body: payload, url });
}
/**
* Filter items that need processing (e.g. check Redis cache, database state).
* Return only the ones that actually need work — keeps the pipeline idempotent.
*/
static async filterItemsNeedingProcessing(itemIds: string[]): Promise<string[]> {
if (itemIds.length === 0) return [];
// Check existing state and return items that need processing
return itemIds;
}
}
```
---
## Layer 1: Entry Point (process-\*)
**Purpose:** Validates prerequisites, calculates statistics, supports dry-run mode.
```typescript
import { serve } from '@upstash/workflow/nextjs';
import { getServerDB } from '@/database/server';
import { WorkflowClass, type ProcessPayload } from '@/server/workflows/{workflowName}';
export const { POST } = serve<ProcessPayload>(
async (context) => {
const { dryRun, force } = context.requestPayload ?? {};
console.log('[{workflow}:process] Starting with payload:', { dryRun, force });
const allItemIds = await context.run('{workflow}:get-all-items', async () => {
const db = await getServerDB();
// Query database for eligible items
return items.map((item) => item.id);
});
console.log('[{workflow}:process] Total eligible items:', allItemIds.length);
if (allItemIds.length === 0) {
return { success: true, totalEligible: 0, message: 'No eligible items found' };
}
const itemsNeedingProcessing = await context.run('{workflow}:filter-existing', () =>
WorkflowClass.filterItemsNeedingProcessing(allItemIds),
);
const result = {
success: true,
totalEligible: allItemIds.length,
toProcess: itemsNeedingProcessing.length,
alreadyProcessed: allItemIds.length - itemsNeedingProcessing.length,
};
// Dry-run short-circuits before any side effects
if (dryRun) {
console.log('[{workflow}:process] Dry run mode, returning statistics only');
return {
...result,
dryRun: true,
message: `[DryRun] Would process ${itemsNeedingProcessing.length} items`,
};
}
if (itemsNeedingProcessing.length === 0) {
return { ...result, message: 'All items already processed' };
}
await context.run('{workflow}:trigger-paginate', () => WorkflowClass.triggerPaginateItems({}));
return {
...result,
message: `Triggered pagination for ${itemsNeedingProcessing.length} items`,
};
},
{
flowControl: {
key: '{workflow}.process',
parallelism: 1, // single instance — avoids duplicate processing
ratePerSecond: 1,
},
},
);
```
---
## Layer 2: Pagination (paginate-\*)
**Purpose:** Handles cursor-based pagination, implements fan-out for large batches.
```typescript
import { serve } from '@upstash/workflow/nextjs';
import { chunk } from 'es-toolkit/compat';
import { getServerDB } from '@/database/server';
import { WorkflowClass, type PaginatePayload } from '@/server/workflows/{workflowName}';
const PAGE_SIZE = 50;
const CHUNK_SIZE = 20;
export const { POST } = serve<PaginatePayload>(
async (context) => {
const { cursor, itemIds: payloadItemIds } = context.requestPayload ?? {};
console.log('[{workflow}:paginate] Starting:', {
cursor,
itemIdsCount: payloadItemIds?.length ?? 0,
});
// If specific itemIds were passed in (from a fanout chunk), process them directly
if (payloadItemIds && payloadItemIds.length > 0) {
await Promise.all(
payloadItemIds.map((itemId) =>
context.run(`{workflow}:execute:${itemId}`, () =>
WorkflowClass.triggerExecuteItem({ itemId }),
),
),
);
return { success: true, processedItems: payloadItemIds.length };
}
// Paginate through all items
const itemBatch = await context.run('{workflow}:get-batch', async () => {
const db = await getServerDB();
const items = await db.query(...);
if (!items.length) return { ids: [] };
const last = items.at(-1);
return {
ids: items.map((item) => item.id),
cursor: last ? last.id : undefined,
};
});
const batchItemIds = itemBatch.ids;
const nextCursor = 'cursor' in itemBatch ? itemBatch.cursor : undefined;
if (batchItemIds.length === 0) {
return { success: true, message: 'Pagination complete' };
}
const itemIds = await context.run('{workflow}:filter-existing', () =>
WorkflowClass.filterItemsNeedingProcessing(batchItemIds),
);
if (itemIds.length > 0) {
if (itemIds.length > CHUNK_SIZE) {
// Fan out — recursively re-enter pagination with each chunk
const chunks = chunk(itemIds, CHUNK_SIZE);
console.log('[{workflow}:paginate] Fanout mode:', {
chunks: chunks.length,
chunkSize: CHUNK_SIZE,
});
await Promise.all(
chunks.map((ids, idx) =>
context.run(`{workflow}:fanout:${idx + 1}/${chunks.length}`, () =>
WorkflowClass.triggerPaginateItems({ itemIds: ids }),
),
),
);
} else {
// Process this page directly
await Promise.all(
itemIds.map((itemId) =>
context.run(`{workflow}:execute:${itemId}`, () =>
WorkflowClass.triggerExecuteItem({ itemId }),
),
),
);
}
}
// Tail-call into the next page
if (nextCursor) {
await context.run('{workflow}:next-page', () =>
WorkflowClass.triggerPaginateItems({ cursor: nextCursor }),
);
}
return {
success: true,
processedItems: itemIds.length,
skippedItems: batchItemIds.length - itemIds.length,
nextCursor: nextCursor ?? null,
};
},
{
flowControl: {
key: '{workflow}.paginate',
parallelism: 20,
ratePerSecond: 5,
},
},
);
```
---
## Layer 3: Execution (execute-\* / generate-\*)
**Purpose:** Performs the actual business logic for exactly ONE item.
```typescript
import { serve } from '@upstash/workflow/nextjs';
import { getServerDB } from '@/database/server';
import { WorkflowClass, type ExecutePayload } from '@/server/workflows/{workflowName}';
export const { POST } = serve<ExecutePayload>(
async (context) => {
const { itemId } = context.requestPayload ?? {};
if (!itemId) {
return { success: false, error: 'Missing itemId' };
}
const db = await getServerDB();
const item = await context.run('{workflow}:get-item', async () => {
// Query database for item
return item;
});
if (!item) {
return { success: false, error: 'Item not found' };
}
const result = await context.run('{workflow}:process-item', async () => {
const workflow = new WorkflowClass(db, itemId);
return workflow.generate(); // or process(), execute(), etc.
});
await context.run('{workflow}:save-result', async () => {
const workflow = new WorkflowClass(db, itemId);
return workflow.saveToRedis(result); // or saveToDatabase(), etc.
});
return { success: true, itemId, result };
},
{
flowControl: {
key: '{workflow}.execute',
parallelism: 10,
ratePerSecond: 5,
},
},
);
```
+13 -11
View File
@@ -1,11 +1,13 @@
---
name: version-release
description: "Version release workflow. Use when the user mentions 'release', 'hotfix', 'version upgrade', 'weekly release', or '发版'/'发布'/'小班车'. This skill is for release process and GitHub Release notes (not docs/changelog page writing)."
description: 'Version release workflow release process and GitHub Release notes (not docs/changelog pages).'
disable-model-invocation: true
argument-hint: '[minor|patch] [version?]'
---
# Version Release Workflow
This skill is a router. The detailed steps live in `reference/`.
This skill is a router. The detailed steps live in `references/`.
## Scope Boundary (Important)
@@ -30,12 +32,12 @@ The primary development branch is **canary**. All day-to-day development happens
Only two release types are used in practice (major releases are extremely rare and can be ignored):
| Type | Use Case | Frequency | Source Branch | PR Title Format | Version | Reference |
| ----- | ---------------------------------------------- | --------------------- | -------------- | ------------------------------------ | ------------- | -------------------------------------- |
| Minor | Feature iteration release | \~Every 4 weeks | canary | `🚀 release: v{x.y.0}` | Manually set | `reference/minor-release.md` |
| Patch | Weekly release / hotfix / model / DB migration | \~Weekly or as needed | canary or main | Custom (e.g. `🚀 release: 20260222`) | Auto patch +1 | `reference/patch-release-scenarios.md` |
| Type | Use Case | Frequency | Source Branch | PR Title Format | Version | Reference |
| ----- | ---------------------------------------------- | --------------------- | -------------- | ------------------------------------ | ------------- | --------------------------------------- |
| Minor | Feature iteration release | \~Every 4 weeks | canary | `🚀 release: v{x.y.0}` | Manually set | `references/minor-release.md` |
| Patch | Weekly release / hotfix / model / DB migration | \~Weekly or as needed | canary or main | Custom (e.g. `🚀 release: 20260222`) | Auto patch +1 | `references/patch-release-scenarios.md` |
For writing the release-note body (any release type), see `reference/release-notes-style.md`.
For writing the release-note body (any release type), see `references/release-notes-style.md`.
## Auto-Release Trigger Rules (`auto-tag-release.yml`)
@@ -85,9 +87,9 @@ Before creating the release branch, verify the source branch:
Pick the right reference and follow it end-to-end:
- **Minor release** → `reference/minor-release.md`
- **Patch release** (weekly / hotfix / model launch / DB migration) → `reference/patch-release-scenarios.md`
- **Writing the PR body / release notes** (any release type) → `reference/release-notes-style.md`
- **Minor release** → `references/minor-release.md`
- **Patch release** (weekly / hotfix / model launch / DB migration) → `references/patch-release-scenarios.md`
- **Writing the PR body / release notes** (any release type) → `references/release-notes-style.md`
### Hard Rules (apply to every release type)
@@ -95,4 +97,4 @@ Pick the right reference and follow it end-to-end:
- **Do NOT** manually create tags — CI handles them.
- Minor PR title format is strict (`🚀 release: v{x.y.z}`).
- Patch PRs do not need an explicit version number.
- Keep release facts accurate; do not invent metrics or availability statements. Release-note inputs (compare base, PR refs, contributor list) **must be derived from `git`** per `reference/release-notes-style.md` § Computing Inputs — never from memory or descriptions.
- Keep release facts accurate; do not invent metrics or availability statements. Release-note inputs (compare base, PR refs, contributor list) **must be derived from `git`** per `references/release-notes-style.md` § Computing Inputs — never from memory or descriptions.
@@ -2,6 +2,20 @@
Use this guide for **GitHub Release notes** — the body of a release PR that becomes the GitHub Release after merge. Do **not** use it for `docs/changelog/*.mdx` website pages (load `../../docs-changelog/SKILL.md` instead).
## Table of Contents
1. [Positioning](#positioning) — what this style optimizes for
2. [Required Inputs Before Writing](#required-inputs-before-writing)
3. [Computing Inputs (Hard Rules — Verify, Never Guess)](#computing-inputs-hard-rules--verify-never-guess) — base ref, PR refs, metrics, authors, pre-publish verification
4. [Canonical Structure (Long-Form: Minor / Weekly)](#canonical-structure-long-form-minor--weekly)
5. [Variants for Shorter Releases](#variants-for-shorter-releases) — hotfix, DB migration
6. [Writing Rules (Hard)](#writing-rules-hard)
7. [Style Rules (Long-Form)](#style-rules-long-form)
8. [Release Size Heuristics](#release-size-heuristics) — when to use which variant
9. [Contributor Ordering](#contributor-ordering)
10. [Template](#template) — copy-paste skeleton
11. [Quick Checklist](#quick-checklist) — long-form + hotfix
## Positioning
This release-note style is:
+2 -1
View File
@@ -1,6 +1,7 @@
---
name: zustand
description: Zustand state management guide. Use when working with store code (src/store/**), implementing actions, managing state, or creating slices. Triggers on Zustand store development, state management questions, or action implementation.
description: "LobeHub Zustand store conventions: public/internal/dispatch action layers, optimistic update pattern, slice composition via `flattenActions`, and class-based action migration. Use whenever working under `src/store/**`, adding a `createXxxSlice`, writing `internal_*` or `internal_dispatch*` actions, designing `messagesMap`/`topicsMap` reducers, refactoring a `StateCreator` object slice into a `XxxActionImpl` class, or debugging stale store reads. Triggers on `useChatStore`/`useUserStore`/`useGlobalStore`, `createStore`, `flattenActions`, `StoreSetter`, `internal_dispatch`, 'add an action', 'zustand selector', 'store slice', 'class action', 'optimistic update'."
user-invocable: false
---
# LobeHub Zustand State Management
+148 -188
View File
@@ -1,6 +1,10 @@
# Issue Triage Guide
This guide is used for batch triaging GitHub issues - analyzing issues and applying appropriate labels.
This guide is used for triaging GitHub issues analyzing issues and applying only the most essential business-domain labels.
## Core Principle
**Each issue should have 1-3 labels that describe its core business domain.** Do NOT apply redundant labels that can be inferred from other labels. Less is more.
## Workflow
@@ -20,23 +24,76 @@ For each issue number, run:
gh issue view [ISSUE_NUMBER] --json number,title,body,labels,comments
```
### Step 3: Analyze and Select Labels
### Step 3: Select Labels (1-3 per issue)
Extract information from the issue template and content:
Only apply labels from these THREE categories:
#### Template Fields Mapping
#### Category 1: Technology Carrier
- 📦 Platform field → `platform:web/desktop/mobile`
- 💻 Operating System → `os:windows/macos/linux/ios`
- 🌐 Browser → `device:pc/mobile`
- 📦 Deployment mode → `deployment:server/client/pglite`
- Platform (hosting) → `hosting:cloud/self-host/vercel/zeabur/railway`
The runtime environment or technology wrapper where the issue occurs:
#### Provider Detection
| Label | When to apply |
|-------|--------------|
| `electron` | Desktop/Electron-specific issues. This REPLACES `platform:desktop`, `os:*`, `deployment:*`, `hosting:*` — do NOT add those. |
| `pwa` | PWA/mobile-app-specific issues |
| `docker` | Docker-specific deployment issues |
**IMPORTANT**: Always check issue title and body for provider mentions!
**Rule**: If `electron` is applied, do NOT add `platform:desktop`, `os:*`, `deployment:*`, or `hosting:*`. The `electron` label already implies all of these.
**Official Providers** (check for these keywords in title/body):
#### Category 2: Feature / Component
The functional area affected. Select the 1-2 MOST relevant:
Core Features:
- `feature:agent` - Agent/Assistant functionality
- `feature:topic` - Topic/Conversation management
- `feature:marketplace` - Agent/plugin marketplace
- `feature:settings` - Settings and configuration
Content & Knowledge:
- `feature:editor` - Lobe Editor / rich text / markdown rendering
- `feature:markdown` - Markdown rendering (if separate from editor)
- `feature:files` - File upload/management
- `feature:knowledge-base` - Knowledge base and RAG
- `feature:export` - Export functionality
Model Capabilities:
- `feature:tool` - Tool calling and function execution
- `feature:streaming` - Streaming responses
- `feature:vision` - Vision/multimodal capabilities
- `feature:image` - AI image generation
- `feature:tts` - Text-to-speech
Technical:
- `feature:api` - Backend API
- `feature:auth` - Authentication/authorization
- `feature:sync` - Cloud sync functionality
- `feature:search` - Search functionality
- `feature:mcp` - MCP integration
- `feature:thread` - Thread/Subtopic functionality
Collaboration:
- `feature:group-chat` - Group chat functionality
- `feature:memory` - Memory feature
- `feature:team-workspace` - Team workspace
- `feature:im-integration` - IM and bot integration
Other:
- `feature:schedule-task` - Scheduled task functionality
**Rule**: Pick only the 1-2 most specific feature labels. Don't stack multiple features unless the issue genuinely spans multiple areas.
#### Category 3: Model Provider
Only when the issue is SPECIFICALLY about a provider's behavior:
**Official Providers** (check title and body for these keywords):
- `openai`, `gpt``provider:openai`
- `gemini``provider:gemini`
@@ -57,197 +114,100 @@ Extract information from the issue template and content:
**Third-party Aggregation Providers**:
- `aihubmix`, `AIHubMix`, `AIHUBMIX``provider:aihubmix`
- Check environment variables like `AIHUBMIX_*` in issue body
- `zenmux``provider:zenmux`
**Multiple Providers**: If issue mentions multiple providers, add ALL applicable provider labels.
**Rule**: Only add a provider label if the issue is specifically about that provider's behavior (e.g., "Gemini returns error X"). Do NOT add provider labels just because the issue template mentions a provider.
### Label Categories
#### a) Issue Type (select ONE if applicable)
- `💄 Design` - UI/UX design issues
- `📝 Documentation` - Documentation improvements
- `⚡️ Performance` - Performance optimization
#### b) Priority (select ONE if applicable)
- `priority:high` - Critical issues, data loss, security, maintainer mentions "urgent"/"serious"/"critical"
- `priority:medium` - Important issues affecting multiple users, significant functionality impact
- `priority:low` - Nice to have, minor issues, edge cases
**Priority Guidelines**:
- Set `priority:high` for: data loss, authentication failures, deployment blockers, critical bugs
- Set `priority:medium` for: feature bugs affecting multiple users, workflow issues
- Set `priority:low` for: cosmetic issues, feature requests, configuration questions
#### c) Platform (select ALL applicable)
- `platform:web`
- `platform:desktop`
- `platform:mobile`
#### d) Device (for platform:web, select ONE)
- `device:pc`
- `device:mobile`
#### e) Operating System (select ALL applicable)
- `os:windows`
- `os:macos`
- `os:linux`
- `os:ios`
- `os:android`
#### f) Hosting Platform (select ONE)
- `hosting:cloud` - Official LobeHub Cloud
- `hosting:self-host` - Self-hosted deployment
- `hosting:vercel` - Vercel deployment
- `hosting:zeabur` - Zeabur deployment
- `hosting:railway` - Railway deployment
#### g) Deployment Mode (select ONE if mentioned)
- `deployment:server` - Server-side database mode
- `deployment:client` - Client-side database mode
- `deployment:pglite` - PGLite mode
**Additional deployment tags**:
- `docker` - If using Docker deployment
- `electron` - If desktop/Electron specific
#### h) Model Provider (select ALL applicable)
See "Provider Detection" section above for complete list.
**IMPORTANT**: Always scan issue title and body for provider keywords!
#### i) Feature/Component (select ALL applicable)
Core Features:
- `feature:settings` - Settings and configuration
- `feature:agent` - Agent/Assistant functionality
- `feature:topic` - Topic/Conversation management
- `feature:marketplace` - Agent marketplace
File & Knowledge:
- `feature:files` - File upload/management
- `feature:knowledge-base` - Knowledge base and RAG
- `feature:export` - Export functionality
Model Capabilities:
- `feature:streaming` - Streaming responses
- `feature:tool` - Tool calling
- `feature:vision` - Vision/multimodal capabilities
- `feature:image` - AI image generation
- `feature:dalle` - DALL-E specific
- `feature:tts` - Text-to-speech
Technical:
- `feature:api` - Backend API
- `feature:auth` - Authentication/authorization
- `feature:sync` - Cloud sync functionality
- `feature:search` - Search functionality
- `feature:mcp` - MCP integration
- `feature:editor` - Lobe Editor
- `feature:markdown` - Markdown rendering
- `feature:thread` - Thread/Subtopic functionality
Collaboration:
- `feature:group-chat` - Group chat functionality
- `feature:memory` - Memory feature
- `feature:team-workspace` - Team workspace
#### j) Workflow/Status
#### Special Labels (use sparingly)
- `i18n` - Internationalization / translation issues
- `Duplicate` - Only if duplicate of an OPEN issue (mention issue number)
- `needs-reproduction` - Cannot reproduce, needs more information
- `good-first-issue` - Good for first-time contributors
- `🤔 Need Reproduce` - Needs reproduction steps
- `good-first-issue` - Good for first-time contributors
### Step 4: Apply Labels
Add labels (comma-separated, no spaces after commas):
```bash
gh issue edit [ISSUE_NUMBER] --add-label "label1,label2,label3"
```
Remove "unconfirm" label if adding other labels:
```bash
gh issue edit [ISSUE_NUMBER] --add-label "label1,label2"
gh issue edit [ISSUE_NUMBER] --remove-label "unconfirm"
```
**Important**: Combine both commands when possible for efficiency.
### Step 5: Log Summary
For each issue, provide reasoning (2-4 sentences):
For each issue, provide a brief reasoning (1-2 sentences) explaining why each label was chosen.
- Labels applied and why
- Key factors from issue template/comments
- Provider detection reasoning (if applicable)
## What NOT to Label
These categories are INTENTIONALLY OMITTED — do NOT apply them:
| Do NOT apply | Reason |
|-------------|--------|
| `platform:web`, `platform:desktop`, `platform:mobile` | Inferred from `electron`/`pwa` or issue context |
| `os:windows`, `os:macos`, `os:linux`, `os:ios`, `os:android` | Low triage value; inferred from `electron` |
| `device:pc`, `device:mobile` | Redundant with platform |
| `hosting:cloud`, `hosting:self-host`, `hosting:vercel`, etc. | Low triage value unless deployment-specific |
| `deployment:server`, `deployment:client`, `deployment:pglite` | Low triage value; inferred from `electron` |
| `priority:high`, `priority:medium`, `priority:low` | Maintainers judge priority themselves |
| `🐛 Bug`, `💄 Design`, `📝 Documentation`, `⚡️ Performance` | Issue type is already indicated by GitHub issue template |
| `Inactive` | Handled separately; do NOT add during triage |
## Examples
### Example 1: Electron desktop bug
**Issue**: "Connection failure when executing tasks on macOS desktop app"
**Analysis**: Desktop Electron app issue with task scheduling.
**Labels**: `electron,feature:schedule-task`
**Why**: `electron` covers the desktop platform. `feature:schedule-task` identifies the affected feature. No need for `platform:desktop`, `os:macos`, `hosting:cloud`, `priority:*`, or `Bug`.
### Example 2: Provider-specific issue
**Issue**: "Gemini tool calling returns empty response on desktop"
**Analysis**: Desktop app issue, but the core problem is Gemini provider behavior with tool calling.
**Labels**: `electron,provider:gemini`
**Why**: `electron` for the desktop context. `provider:gemini` because the issue is about Gemini's behavior. The tool calling aspect is secondary — the provider is the key domain.
### Example 3: Feature-specific issue
**Issue**: "Underscore auto-escaped in markdown editor"
**Analysis**: Markdown rendering bug in the editor component.
**Labels**: `feature:markdown`
**Why**: Single label is sufficient — the issue is purely about markdown rendering. No need for platform, OS, or priority labels.
### Example 4: Web-only feature request
**Issue**: "Add search functionality to plugin marketplace"
**Analysis**: Feature request for marketplace search. Web platform, no specific provider.
**Labels**: `feature:marketplace,feature:search`
**Why**: Two feature labels capture the core domain. No platform label needed — it's a web app by default.
### Example 5: Ollama self-hosted issue
**Issue**: "Ollama model not loading on self-hosted Docker deployment"
**Analysis**: Provider-specific issue with Ollama on Docker.
**Labels**: `docker,provider:ollama`
**Why**: `docker` for the deployment context, `provider:ollama` for the model provider. No need for `hosting:self-host` or `platform:*`.
## Important Rules
1. **Read Carefully**: Read issue template fields AND issue body/title for complete context
2. **Provider Detection**: ALWAYS check title and body for provider keywords (including aihubmix, etc.)
3. **Multiple Categories**: Use ALL applicable labels from different categories
4. **Label Prefixes**: Always use proper prefixes (`feature:`, `provider:`, `os:`, `platform:`, etc.)
5. **Maintainer Comments**: Check maintainer comments for priority/status hints
6. **No Comments**: Only apply labels, DO NOT post comments to issues
7. **Batch Efficiency**: Process issues in parallel when possible
## Common Patterns
### Provider in Environment Variables
If issue body contains `AIHUBMIX_*`, add `provider:aihubmix`
### Multiple Provider Issues
If comparing providers (e.g., "works with OpenAI but not Gemini"), add both provider labels
### Desktop Issues
Desktop issues often need: `platform:desktop`, `electron`, specific `os:*`, and `deployment:client` or `deployment:server`
### Knowledge Base Issues
Usually need: `feature:knowledge-base`, often with `feature:files`, may need `provider:*` for embedding models
### Tool Calling Issues
Usually need: `feature:tool`, specific `provider:*`, may need `feature:mcp` if MCP-related
### Streaming Issues
Usually need: `feature:streaming`, specific `provider:*`, check for timeout/performance issues
## Example Triage
**Issue #8850**: "aihubmix 的优惠 app 没有生效"
**Analysis**:
- Title contains "aihubmix" → `provider:aihubmix`
- Template shows: Windows, Chrome, Docker, Client mode
- About API discount codes not working
**Labels Applied**:
```bash
gh issue edit 8850 --add-label "provider:aihubmix,platform:web,os:windows,deployment:client,hosting:self-host,docker"
gh issue edit 8850 --remove-label "unconfirm"
```
**Reasoning**: AIHubMix provider discount feature not working. Client mode deployment on Windows with Docker. Provider detection from title keyword "aihubmix".
1. **1-3 labels per issue** — Never exceed 3 labels. If you find yourself adding more, you're being too granular.
2. **`electron` replaces all platform/OS/deployment labels** — Never combine `electron` with `platform:desktop`, `os:*`, `deployment:*`, or `hosting:*`.
3. **Provider only when relevant** — Only add `provider:*` if the issue is specifically about that provider's behavior.
4. **No priority, no type** — Do NOT add `priority:*`, `🐛 Bug`, `💄 Design`, etc. Maintainers handle these.
5. **No comments** — Only apply labels. Do NOT post comments to issues.
6. **Remove `unconfirm`** — Always remove the `unconfirm` label when applying triage labels.
+3
View File
@@ -77,6 +77,9 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# use a proxy to connect to the Anthropic API
# ANTHROPIC_PROXY_URL=https://api.anthropic.com
# Anthropic SDK client timeout in milliseconds
# ANTHROPIC_CLIENT_TIMEOUT=295000
# ## Google AI ####
# GOOGLE_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+9 -1
View File
@@ -1,5 +1,13 @@
#!/usr/bin/env sh
set -e
[ "${HUSKY-}" = "0" ] && exit 0
export PATH="node_modules/.bin:$PATH"
BRANCH=$(git branch --show-current)
if [ "$BRANCH" = "dev" ] || [ "$BRANCH" = "main" ]; then
npm run type-check
fi
npx --no-install lint-staged
lint-staged
+6
View File
@@ -10,6 +10,10 @@ inputs:
description: Pass-through to actions/setup-node package-manager-cache
required: false
default: 'false'
bun-version:
description: Bun version
required: false
default: '1.3.2'
runs:
using: composite
@@ -21,6 +25,8 @@ runs:
- name: Install bun
uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ inputs.bun-version }}
- name: Setup Node.js
uses: actions/setup-node@v6
+40
View File
@@ -21,6 +21,46 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
# Remind contributors when a non-release PR targets `main`.
# Day-to-day PRs should target `canary`; `main` is reserved for releases
# (see .agents/skills/version-release/SKILL.md). Allowed exceptions:
# - PR title matches `🚀 release: v{x.y.z}` (minor release)
# - head branch matches `hotfix/*` or `release/*` (patch release)
- name: Remind contributor if base branch is not canary
if: github.event.action == 'opened' && github.event.pull_request.base.ref == 'main'
env:
HEAD_REF: ${{ github.event.pull_request.head.ref }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_NUMBER: ${{ github.event.pull_request.number }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
run: |
if [[ "$HEAD_REF" == hotfix/* ]] || [[ "$HEAD_REF" == release/* ]]; then
echo "✅ Release/hotfix branch ($HEAD_REF) -> main is allowed"
exit 0
fi
if [[ "$PR_TITLE" =~ ^🚀[[:space:]]+release: ]]; then
echo "✅ Release-titled PR -> main is allowed"
exit 0
fi
echo "⚠️ Non-release PR targets main; posting reminder comment."
gh pr comment "$PR_NUMBER" --body "$(cat <<'EOF'
👋 Thanks for your contribution!
This PR currently targets the **`main`** branch, but `main` is reserved for release PRs only. Day-to-day development (features, fixes, refactors, docs, etc.) should target the **`canary`** branch.
### How to fix
On the PR page, click **Edit** next to the title, then change the base branch from `main` to `canary`.
### When targeting `main` is allowed
- PR title starts with `🚀 release: v{x.y.z}` (minor release)
- Head branch matches `hotfix/*` or `release/*` (patch release)
If your PR fits one of these cases, please ignore this message.
EOF
)"
- name: Check if author is a team member
id: check-team
run: |
+8 -1
View File
@@ -59,7 +59,14 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v6
env:
REF_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
REPOSITORY: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
run: |
git init .
git remote add origin "https://github.com/${REPOSITORY}.git"
git fetch --no-tags --depth=1 origin "${REF_SHA}"
git checkout --force FETCH_HEAD
- name: Setup environment
uses: ./.github/actions/setup-env
+4 -11
View File
@@ -16,14 +16,14 @@ permissions:
jobs:
run:
permissions:
issues: write # for actions-cool/issues-helper to update issues
pull-requests: write # for actions-cool/issues-helper to update PRs
issues: write
pull-requests: write
runs-on: ubuntu-latest
steps:
- name: Auto Comment on Issues Closed
uses: wow-actions/auto-comment@v1
with:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN}}
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
issuesClosed: |
✅ @{{ author }}
@@ -51,11 +51,4 @@ jobs:
The growth of project is inseparable from user feedback and contribution, thanks for your contribution! If you are interesting with the lobehub developer community, please join our [discord](https://discord.com/invite/AYFPHvv2jT) and then dm @arvinxx or @canisminor1990. They will invite you to our private developer channel. We are talking about the lobe-chat development or sharing ai newsletter around the world.
emoji: 'hooray'
pr-emoji: '+1, heart'
- name: Remove inactive
if: github.event.issue.state == 'open' && github.actor == github.event.issue.user.login
uses: actions-cool/issues-helper@v3
with:
actions: 'remove-labels'
token: ${{ secrets.GH_TOKEN }}
issue-number: ${{ github.event.issue.number }}
labels: 'Inactive'
-63
View File
@@ -1,63 +0,0 @@
name: Issue Close Require
on:
schedule:
- cron: '0 0 * * *'
permissions:
contents: read
jobs:
issue-check-inactive:
permissions:
issues: write # for actions-cool/issues-helper to update issues
pull-requests: write # for actions-cool/issues-helper to update PRs
runs-on: ubuntu-latest
steps:
- name: check-inactive
uses: actions-cool/issues-helper@v3
with:
actions: 'check-inactive'
token: ${{ secrets.GH_TOKEN }}
inactive-label: 'Inactive'
inactive-day: 60
issue-close-require:
permissions:
issues: write # for actions-cool/issues-helper to update issues
pull-requests: write # for actions-cool/issues-helper to update PRs
runs-on: ubuntu-latest
steps:
- name: need reproduce
uses: actions-cool/issues-helper@v3
with:
actions: 'close-issues'
token: ${{ secrets.GH_TOKEN }}
labels: '✅ Fixed'
inactive-day: 3
body: |
👋 @{{ author }}
<br/>
Since the issue was labeled with `✅ Fixed`, but no response in 3 days. This issue will be closed. If you have any questions, you can comment and reply.
- name: need reproduce
uses: actions-cool/issues-helper@v3
with:
actions: 'close-issues'
token: ${{ secrets.GH_TOKEN }}
labels: '🤔 Need Reproduce'
inactive-day: 3
body: |
👋 @{{ author }}
<br/>
Since the issue was labeled with `🤔 Need Reproduce`, but no response in 3 days. This issue will be closed. If you have any questions, you can comment and reply.
- name: need reproduce
uses: actions-cool/issues-helper@v3
with:
actions: 'close-issues'
token: ${{ secrets.GH_TOKEN }}
labels: "🙅🏻‍♀️ WON'T DO"
inactive-day: 3
body: |
👋 @{{ github.event.issue.user.login }}
<br/>
Since the issue was labeled with `🙅🏻‍♀️ WON'T DO`, and no response in 3 days. This issue will be closed. If you have any questions, you can comment and reply.
+2 -2
View File
@@ -20,7 +20,7 @@ jobs:
- uses: actions/checkout@v6
- name: Clean issue notice
uses: actions-cool/issues-helper@v3
uses: actions-cool/issues-helper@e361abf610221f09495ad510cb1e69328d839e1c # v3.7.6
with:
actions: 'close-issues'
labels: '🚨 Sync Fail'
@@ -37,7 +37,7 @@ jobs:
- name: Sync check
if: failure()
uses: actions-cool/issues-helper@v3
uses: actions-cool/issues-helper@e361abf610221f09495ad510cb1e69328d839e1c # v3.7.6
with:
actions: 'create-issue'
title: '🚨 同步失败 | Sync Fail'
+45 -5
View File
@@ -35,7 +35,15 @@ jobs:
PACKAGES: '@lobechat/file-loaders @lobechat/prompts @lobechat/model-runtime @lobechat/web-crawler @lobechat/electron-server-ipc @lobechat/utils @lobechat/python-interpreter @lobechat/context-engine @lobechat/agent-runtime @lobechat/conversation-flow @lobechat/ssrf-safe-fetch @lobechat/memory-user-memory @lobechat/types @lobechat/builtin-tool-lobe-agent model-bank'
steps:
- uses: actions/checkout@v6
- name: Checkout
env:
REF_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
REPOSITORY: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
run: |
git init .
git remote add origin "https://github.com/${REPOSITORY}.git"
git fetch --no-tags --depth=1 origin "${REF_SHA}"
git checkout --force FETCH_HEAD
- name: Setup environment
uses: ./.github/actions/setup-env
@@ -101,7 +109,15 @@ jobs:
name: Test App (shard ${{ matrix.shard }}/3)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Checkout
env:
REF_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
REPOSITORY: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
run: |
git init .
git remote add origin "https://github.com/${REPOSITORY}.git"
git fetch --no-tags --depth=1 origin "${REF_SHA}"
git checkout --force FETCH_HEAD
- name: Setup environment
uses: ./.github/actions/setup-env
@@ -128,7 +144,15 @@ jobs:
name: Merge and Upload App Coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Checkout
env:
REF_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
REPOSITORY: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
run: |
git init .
git remote add origin "https://github.com/${REPOSITORY}.git"
git fetch --no-tags --depth=1 origin "${REF_SHA}"
git checkout --force FETCH_HEAD
- name: Setup environment
uses: ./.github/actions/setup-env
@@ -161,7 +185,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Checkout
env:
REF_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
REPOSITORY: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
run: |
git init .
git remote add origin "https://github.com/${REPOSITORY}.git"
git fetch --no-tags --depth=1 origin "${REF_SHA}"
git checkout --force FETCH_HEAD
- name: Setup environment
uses: ./.github/actions/setup-env
@@ -207,7 +239,15 @@ jobs:
- 5432:5432
steps:
- uses: actions/checkout@v6
- name: Checkout
env:
REF_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
REPOSITORY: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
run: |
git init .
git remote add origin "https://github.com/${REPOSITORY}.git"
git fetch --no-tags --depth=1 origin "${REF_SHA}"
git checkout --force FETCH_HEAD
- name: Setup environment
uses: ./.github/actions/setup-env
+5 -1
View File
@@ -28,6 +28,9 @@ prd
# Recordings
.records/
# Agent-gateway probe captures (local debugging dumps)
.agent-gateway/
# Temporary files
.temp/
temp/
@@ -96,7 +99,7 @@ sitemap*.xml
robots.txt
# Git hooks
.husky/prepare-commit-msg
.githooks/prepare-commit-msg
# Documents and media
*.pdf
@@ -106,6 +109,7 @@ vertex-ai-key.json
# Agent tracing snapshots
.agent-tracing/
.llm-generation-tracing/
# AI coding tools
.local/
+2 -2
View File
@@ -8,7 +8,7 @@
.history
.temp
.env.local
.husky
.githooks
.npmrc
.gitkeep
venv
@@ -59,4 +59,4 @@ Dockerfile*
# misc
# add other ignore file below
.next
.next
+273
View File
@@ -2,6 +2,279 @@
# Changelog
### [Version 2.2.0](https://github.com/lobehub/lobe-chat/compare/v2.1.59-canary.27...v2.2.0)
<sup>Released on **2026-05-18**</sup>
#### 💄 Styles
- **pricing**: restore DeepSeek models to official pricing.
#### 🐛 Bug Fixes
- **conversation**: animate only the last markdown block + drop clearMessages hotkey.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### Styles
- **pricing**: restore DeepSeek models to official pricing, closes [#14911](https://github.com/lobehub/lobe-chat/issues/14911) ([e566688](https://github.com/lobehub/lobe-chat/commit/e566688))
#### What's fixed
- **conversation**: animate only the last markdown block + drop clearMessages hotkey, closes [#14906](https://github.com/lobehub/lobe-chat/issues/14906) ([469a8e6](https://github.com/lobehub/lobe-chat/commit/469a8e6))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
## [Version 2.1.58](https://github.com/lobehub/lobe-chat/compare/v2.1.57...v2.1.58)
<sup>Released on **2026-05-13**</sup>
#### ✨ Features
- **agent-runtime**: persist agent operations to `agent_operations` table.
- **misc**: support slack mpim and fix discord dm problem.
- **database**: add `agent_operations` table.
- **markdown**: user_feedback card + task card polish + Run now context menu.
- **documents**: add optimistic create/delete and inline rename for document tree.
- **devtools**: add dev-only feature flag override panel.
- **misc**: add service model assignments settings.
- **misc**: inline skill auth in recommended task templates.
- **activator**: require activation reason.
- **agent-signal,server,prompts**: consolidate in self-review implemented.
- **hetero-agent**: support AskUserQuestion tools for claude code.
- **bot**: gate device tools by sender identity.
- **misc**: add user activity business hook.
- **misc**: add Gemini 3.1 Flash-Lite provider cards.
- **misc**: home daily brief with linkable welcome + paired input hint.
- **agent-signal,prompts,database**: self-review now proposal actions to briefs, and automatically execute actions.
- **misc**: add signOperationJwt with 4h expiry for hetero-agent operations.
- **misc**: migrate Notion to LobeHub Market.
- **misc**: Cloud Claude Code V3 — repo picker, GitHub token, sandbox context.
#### 🐛 Bug Fixes
- **hetero-agent**: wire AskUserBridge response events to renderer.
- **home**: blank user bubble when sending the placeholder hint.
- **conversation**: prevent synthetic scroll from shrinking spacer.
- **task-card**: localize task card date independent of dayjs global locale.
- **web-crawler**: cap response body size to prevent serverless OOM.
- **desktop**: focus onboarding auth success state.
- **misc**: Docs image.
- **desktop**: detect Windows npm .cmd shims for CLI agents (claude/codex/…).
- **misc**: update Task page placeholder copy.
- **builtin-tool-task**: expose `lobe-task` and add `setTaskSchedule`.
- **desktop**: reset pendingLoginMethod on auth failure/cancel paths.
- **utils**: cap image binary at 3.75MB so base64 payload stays under Anthropic 5MB limit.
- **tasks**: scheduler, hotkey, comment & TodoList polish.
- **cli**: remove stale cron entry from generated man page.
- **misc**: sidebar add agent.
- **misc**: replace ScrollShadow with ScrollArea to fix React #185 infinite render loop.
- **heteroFinish**: trigger task lifecycle on cloud sandbox agent completion.
- **hotkey**: remove redundant onClear to prevent double updateHotkey calls.
- **misc**: reject inactive OIDC access.
- **misc**: drop unreachable aihubmix empty-apiKey test.
- **aihubmix**: use full models endpoint to return complete model list.
- **onboarding**: skip marketplace on early exit, drop CJK in prompts.
- **model-runtime**: enrich stream parse errors with provider/model context.
- **home**: strip markdown links from daily-brief input placeholder.
- **misc**: consume visual content parts in server runtime.
- **misc**: store onboarding interests as keys.
- **hetero-agent**: sync new-step assistant across replicas.
- **misc**: remove the old cron job from lobehub.
- **misc**: refresh content baseline from DB on every ingest call.
- **hetero-agent**: disable Claude Code AskUserQuestion to avoid auto-decline.
- **local-system**: guard readFile against binary blobs and oversized output.
- **database,utils,userMemories**: should perfer to use `paradedb.match(...)` instead of hardcoded normalizer.
- **database**: attach error listeners to Neon/Node pools to prevent Lambda crash.
- **misc**: gateway client-tool pluginState + drop redundant `Exit code: 0` tail.
- **gemini**: handle zero cachedContentTokenCount in usage conversion.
- **misc**: first inject the cloudecc runtime session should use the existingStatus.
- **misc**: slack connect error & slash commands.
- **misc**: polish task agent manager.
- **agent-runtime**: recover malformed tool_call names instead of finishing silently.
- **misc**: remove signin captcha flow.
- **misc**: add temporary email auth error locale.
- **misc**: add bot callback service.
- **misc**: sanitize sensitive comments and examples from production JS bundle.
- **misc**: multiple account link.
#### 💄 Styles
- **misc**: use @lobehub/ui built-in HtmlPreview instead of custom component.
- **misc**: polish desktop header icons, sidebar density, and task menus.
- **review-panel**: hover revert button to discard per-file working-tree changes.
- **misc**: standardize header action icon sizes.
- **tool**: add word wrap toggle to tool arguments display.
- **nav**: unify ActionIcon sizing and improve TodoList encapsulation.
- **web-onboarding**: add Render for saveUserQuestion & showAgentMarketplace.
- **misc**: add `reasoning_effort` support for Grok 4.3.
- **misc**: increase chat topic title length.
- **hetero-agent**: read-only SubAgent threads with breadcrumb header and thread switcher.
- **chat-input**: show skeleton in action bar while config is loading.
- **home**: add Recommendations module with hetero agent action library.
- **copyable-label**: wrap long tool-call params instead of truncating.
- **misc**: format tool execution time as Xmin Ys instead of X.Y min.
- **misc**: Add new DeepSeek-V4 models.
- **topic**: add copy session ID to topic dropdown menu.
- **misc**: use visible divider between queued messages.
- **intervention**: polish confirmation bar layout.
- **settings**: remove image avatar from lab input markdown rendering item.
- **task**: activity card stop run + register /tasks in SPA proxy.
- **misc**: update auth captcha retry copy.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's improved
- **agent-runtime**: persist agent operations to `agent_operations` table, closes [#14736](https://github.com/lobehub/lobe-chat/issues/14736) ([a772341](https://github.com/lobehub/lobe-chat/commit/a772341))
- **misc**: support slack mpim and fix discord dm problem, closes [#14733](https://github.com/lobehub/lobe-chat/issues/14733) ([729265a](https://github.com/lobehub/lobe-chat/commit/729265a))
- **database**: add `agent_operations` table, closes [#14416](https://github.com/lobehub/lobe-chat/issues/14416) ([cb8b616](https://github.com/lobehub/lobe-chat/commit/cb8b616))
- **markdown**: user_feedback card + task card polish + Run now context menu, closes [#14727](https://github.com/lobehub/lobe-chat/issues/14727) ([79152fa](https://github.com/lobehub/lobe-chat/commit/79152fa))
- **documents**: add optimistic create/delete and inline rename for document tree, closes [#14714](https://github.com/lobehub/lobe-chat/issues/14714) ([0007984](https://github.com/lobehub/lobe-chat/commit/0007984))
- **devtools**: add dev-only feature flag override panel, closes [#14565](https://github.com/lobehub/lobe-chat/issues/14565) ([18b1c25](https://github.com/lobehub/lobe-chat/commit/18b1c25))
- **misc**: add service model assignments settings, closes [#14712](https://github.com/lobehub/lobe-chat/issues/14712) ([eb924ec](https://github.com/lobehub/lobe-chat/commit/eb924ec))
- **misc**: inline skill auth in recommended task templates, closes [#14676](https://github.com/lobehub/lobe-chat/issues/14676) ([4490e3e](https://github.com/lobehub/lobe-chat/commit/4490e3e))
- **activator**: require activation reason, closes [#14597](https://github.com/lobehub/lobe-chat/issues/14597) ([5f14b7e](https://github.com/lobehub/lobe-chat/commit/5f14b7e))
- **agent-signal,server,prompts**: consolidate in self-review implemented, closes [#14657](https://github.com/lobehub/lobe-chat/issues/14657) ([1374fd2](https://github.com/lobehub/lobe-chat/commit/1374fd2))
- **hetero-agent**: support AskUserQuestion tools for claude code, closes [#14639](https://github.com/lobehub/lobe-chat/issues/14639) ([49c3d7e](https://github.com/lobehub/lobe-chat/commit/49c3d7e))
- **bot**: gate device tools by sender identity, closes [#14634](https://github.com/lobehub/lobe-chat/issues/14634) ([3c81011](https://github.com/lobehub/lobe-chat/commit/3c81011))
- **misc**: add user activity business hook, closes [#14601](https://github.com/lobehub/lobe-chat/issues/14601) ([521566b](https://github.com/lobehub/lobe-chat/commit/521566b))
- **misc**: add Gemini 3.1 Flash-Lite provider cards, closes [#14604](https://github.com/lobehub/lobe-chat/issues/14604) ([9b032f0](https://github.com/lobehub/lobe-chat/commit/9b032f0))
- **misc**: home daily brief with linkable welcome + paired input hint, closes [#14589](https://github.com/lobehub/lobe-chat/issues/14589) ([12e37f1](https://github.com/lobehub/lobe-chat/commit/12e37f1))
- **agent-signal,prompts,database**: self-review now proposal actions to briefs, and automatically execute actions, closes [#14583](https://github.com/lobehub/lobe-chat/issues/14583) ([b7a5020](https://github.com/lobehub/lobe-chat/commit/b7a5020))
- **misc**: add signOperationJwt with 4h expiry for hetero-agent operations, closes [#14586](https://github.com/lobehub/lobe-chat/issues/14586) ([d2c379c](https://github.com/lobehub/lobe-chat/commit/d2c379c))
- **misc**: migrate Notion to LobeHub Market, closes [#14578](https://github.com/lobehub/lobe-chat/issues/14578) ([f1f2e58](https://github.com/lobehub/lobe-chat/commit/f1f2e58))
- **misc**: Cloud Claude Code V3 — repo picker, GitHub token, sandbox context, closes [#14568](https://github.com/lobehub/lobe-chat/issues/14568) ([7792f63](https://github.com/lobehub/lobe-chat/commit/7792f63))
#### What's fixed
- **hetero-agent**: wire AskUserBridge response events to renderer, closes [#14732](https://github.com/lobehub/lobe-chat/issues/14732) ([5174c13](https://github.com/lobehub/lobe-chat/commit/5174c13))
- **home**: blank user bubble when sending the placeholder hint, closes [#14678](https://github.com/lobehub/lobe-chat/issues/14678) ([fc275ca](https://github.com/lobehub/lobe-chat/commit/fc275ca))
- **conversation**: prevent synthetic scroll from shrinking spacer, closes [#14584](https://github.com/lobehub/lobe-chat/issues/14584) ([217afcf](https://github.com/lobehub/lobe-chat/commit/217afcf))
- **task-card**: localize task card date independent of dayjs global locale, closes [#14730](https://github.com/lobehub/lobe-chat/issues/14730) ([df0e635](https://github.com/lobehub/lobe-chat/commit/df0e635))
- **web-crawler**: cap response body size to prevent serverless OOM, closes [#14660](https://github.com/lobehub/lobe-chat/issues/14660) ([2202189](https://github.com/lobehub/lobe-chat/commit/2202189))
- **desktop**: focus onboarding auth success state, closes [#14694](https://github.com/lobehub/lobe-chat/issues/14694) ([4e4294f](https://github.com/lobehub/lobe-chat/commit/4e4294f))
- **misc**: Docs image, closes [#14726](https://github.com/lobehub/lobe-chat/issues/14726) ([3a4bd4a](https://github.com/lobehub/lobe-chat/commit/3a4bd4a))
- **desktop**: detect Windows npm .cmd shims for CLI agents (claude/codex/…), closes [#14720](https://github.com/lobehub/lobe-chat/issues/14720) ([a40fe91](https://github.com/lobehub/lobe-chat/commit/a40fe91))
- **misc**: update Task page placeholder copy, closes [#14704](https://github.com/lobehub/lobe-chat/issues/14704) ([eea742f](https://github.com/lobehub/lobe-chat/commit/eea742f))
- **builtin-tool-task**: expose `lobe-task` and add `setTaskSchedule`, closes [#14713](https://github.com/lobehub/lobe-chat/issues/14713) ([5ff4590](https://github.com/lobehub/lobe-chat/commit/5ff4590))
- **desktop**: reset pendingLoginMethod on auth failure/cancel paths, closes [#14695](https://github.com/lobehub/lobe-chat/issues/14695) ([51cefe0](https://github.com/lobehub/lobe-chat/commit/51cefe0))
- **utils**: cap image binary at 3.75MB so base64 payload stays under Anthropic 5MB limit, closes [#14711](https://github.com/lobehub/lobe-chat/issues/14711) ([948e48b](https://github.com/lobehub/lobe-chat/commit/948e48b))
- **tasks**: scheduler, hotkey, comment & TodoList polish, closes [#14707](https://github.com/lobehub/lobe-chat/issues/14707) ([1ae774d](https://github.com/lobehub/lobe-chat/commit/1ae774d))
- **cli**: remove stale cron entry from generated man page, closes [#14709](https://github.com/lobehub/lobe-chat/issues/14709) ([94e4ea6](https://github.com/lobehub/lobe-chat/commit/94e4ea6))
- **misc**: sidebar add agent, closes [#14693](https://github.com/lobehub/lobe-chat/issues/14693) ([fdedc96](https://github.com/lobehub/lobe-chat/commit/fdedc96))
- **misc**: replace ScrollShadow with ScrollArea to fix React #185 infinite render loop, closes [#185](https://github.com/lobehub/lobe-chat/issues/185), closes [#14689](https://github.com/lobehub/lobe-chat/issues/14689) ([7349ad0](https://github.com/lobehub/lobe-chat/commit/7349ad0))
- **heteroFinish**: trigger task lifecycle on cloud sandbox agent completion, closes [#14681](https://github.com/lobehub/lobe-chat/issues/14681) ([744059c](https://github.com/lobehub/lobe-chat/commit/744059c))
- **hotkey**: remove redundant onClear to prevent double updateHotkey calls, closes [#14663](https://github.com/lobehub/lobe-chat/issues/14663) ([dfe1932](https://github.com/lobehub/lobe-chat/commit/dfe1932))
- **misc**: reject inactive OIDC access, closes [#14674](https://github.com/lobehub/lobe-chat/issues/14674) ([b79c5d8](https://github.com/lobehub/lobe-chat/commit/b79c5d8))
- **misc**: drop unreachable aihubmix empty-apiKey test, closes [#14669](https://github.com/lobehub/lobe-chat/issues/14669) ([b0ee35d](https://github.com/lobehub/lobe-chat/commit/b0ee35d))
- **aihubmix**: use full models endpoint to return complete model list, closes [#14511](https://github.com/lobehub/lobe-chat/issues/14511) ([f4de472](https://github.com/lobehub/lobe-chat/commit/f4de472))
- **onboarding**: skip marketplace on early exit, drop CJK in prompts, closes [#14598](https://github.com/lobehub/lobe-chat/issues/14598) ([a9eb904](https://github.com/lobehub/lobe-chat/commit/a9eb904))
- **model-runtime**: enrich stream parse errors with provider/model context, closes [#14636](https://github.com/lobehub/lobe-chat/issues/14636) ([7daed90](https://github.com/lobehub/lobe-chat/commit/7daed90))
- **home**: strip markdown links from daily-brief input placeholder, closes [#14635](https://github.com/lobehub/lobe-chat/issues/14635) ([0babdcf](https://github.com/lobehub/lobe-chat/commit/0babdcf))
- **misc**: consume visual content parts in server runtime, closes [#14637](https://github.com/lobehub/lobe-chat/issues/14637) ([d445a89](https://github.com/lobehub/lobe-chat/commit/d445a89))
- **misc**: store onboarding interests as keys, closes [#14624](https://github.com/lobehub/lobe-chat/issues/14624) ([9982de3](https://github.com/lobehub/lobe-chat/commit/9982de3))
- **hetero-agent**: sync new-step assistant across replicas, closes [#14631](https://github.com/lobehub/lobe-chat/issues/14631) ([7675bd9](https://github.com/lobehub/lobe-chat/commit/7675bd9))
- **misc**: remove the old cron job from lobehub, closes [#14630](https://github.com/lobehub/lobe-chat/issues/14630) ([457d112](https://github.com/lobehub/lobe-chat/commit/457d112))
- **misc**: refresh content baseline from DB on every ingest call, closes [#14603](https://github.com/lobehub/lobe-chat/issues/14603) ([6595961](https://github.com/lobehub/lobe-chat/commit/6595961))
- **hetero-agent**: disable Claude Code AskUserQuestion to avoid auto-decline, closes [#14629](https://github.com/lobehub/lobe-chat/issues/14629) ([ae8f9cf](https://github.com/lobehub/lobe-chat/commit/ae8f9cf))
- **local-system**: guard readFile against binary blobs and oversized output, closes [#14602](https://github.com/lobehub/lobe-chat/issues/14602) ([96165e4](https://github.com/lobehub/lobe-chat/commit/96165e4))
- **database,utils,userMemories**: should perfer to use `paradedb.match(...)` instead of hardcoded normalizer, closes [#14590](https://github.com/lobehub/lobe-chat/issues/14590) ([38b793f](https://github.com/lobehub/lobe-chat/commit/38b793f))
- **database**: attach error listeners to Neon/Node pools to prevent Lambda crash, closes [#14606](https://github.com/lobehub/lobe-chat/issues/14606) ([11ec59b](https://github.com/lobehub/lobe-chat/commit/11ec59b))
- **misc**: gateway client-tool pluginState + drop redundant `Exit code: 0` tail, closes [#14596](https://github.com/lobehub/lobe-chat/issues/14596) ([4bfd434](https://github.com/lobehub/lobe-chat/commit/4bfd434))
- **gemini**: handle zero cachedContentTokenCount in usage conversion, closes [#14567](https://github.com/lobehub/lobe-chat/issues/14567) ([307cd8e](https://github.com/lobehub/lobe-chat/commit/307cd8e))
- **misc**: first inject the cloudecc runtime session should use the existingStatus, closes [#14592](https://github.com/lobehub/lobe-chat/issues/14592) ([09c66ff](https://github.com/lobehub/lobe-chat/commit/09c66ff))
- **misc**: slack connect error & slash commands, closes [#14591](https://github.com/lobehub/lobe-chat/issues/14591) ([8274be0](https://github.com/lobehub/lobe-chat/commit/8274be0))
- **misc**: polish task agent manager, closes [#14569](https://github.com/lobehub/lobe-chat/issues/14569) ([a02ecbc](https://github.com/lobehub/lobe-chat/commit/a02ecbc))
- **agent-runtime**: recover malformed tool_call names instead of finishing silently, closes [#14577](https://github.com/lobehub/lobe-chat/issues/14577) ([5f8ec8b](https://github.com/lobehub/lobe-chat/commit/5f8ec8b))
- **misc**: remove signin captcha flow, closes [#14573](https://github.com/lobehub/lobe-chat/issues/14573) ([181b7eb](https://github.com/lobehub/lobe-chat/commit/181b7eb))
- **misc**: add temporary email auth error locale, closes [#14564](https://github.com/lobehub/lobe-chat/issues/14564) ([2bdd901](https://github.com/lobehub/lobe-chat/commit/2bdd901))
- **misc**: add bot callback service, closes [#14570](https://github.com/lobehub/lobe-chat/issues/14570) ([e4b5e52](https://github.com/lobehub/lobe-chat/commit/e4b5e52))
- **misc**: sanitize sensitive comments and examples from production JS bundle, closes [#14557](https://github.com/lobehub/lobe-chat/issues/14557) ([1a6e07b](https://github.com/lobehub/lobe-chat/commit/1a6e07b))
- **misc**: multiple account link, closes [#14562](https://github.com/lobehub/lobe-chat/issues/14562) ([760a342](https://github.com/lobehub/lobe-chat/commit/760a342))
#### Styles
- **misc**: use @lobehub/ui built-in HtmlPreview instead of custom component, closes [#14703](https://github.com/lobehub/lobe-chat/issues/14703) ([266d102](https://github.com/lobehub/lobe-chat/commit/266d102))
- **misc**: polish desktop header icons, sidebar density, and task menus, closes [#14724](https://github.com/lobehub/lobe-chat/issues/14724) ([e56edab](https://github.com/lobehub/lobe-chat/commit/e56edab))
- **review-panel**: hover revert button to discard per-file working-tree changes, closes [#14716](https://github.com/lobehub/lobe-chat/issues/14716) ([846e648](https://github.com/lobehub/lobe-chat/commit/846e648))
- **misc**: standardize header action icon sizes, closes [#14717](https://github.com/lobehub/lobe-chat/issues/14717) ([ca9a781](https://github.com/lobehub/lobe-chat/commit/ca9a781))
- **tool**: add word wrap toggle to tool arguments display, closes [#14706](https://github.com/lobehub/lobe-chat/issues/14706) ([bfa2850](https://github.com/lobehub/lobe-chat/commit/bfa2850))
- **nav**: unify ActionIcon sizing and improve TodoList encapsulation, closes [#14692](https://github.com/lobehub/lobe-chat/issues/14692) ([877052f](https://github.com/lobehub/lobe-chat/commit/877052f))
- **web-onboarding**: add Render for saveUserQuestion & showAgentMarketplace, closes [#14667](https://github.com/lobehub/lobe-chat/issues/14667) ([f591f7a](https://github.com/lobehub/lobe-chat/commit/f591f7a))
- **misc**: add `reasoning_effort` support for Grok 4.3, closes [#14642](https://github.com/lobehub/lobe-chat/issues/14642) ([a1fac45](https://github.com/lobehub/lobe-chat/commit/a1fac45))
- **misc**: increase chat topic title length, closes [#14659](https://github.com/lobehub/lobe-chat/issues/14659) ([e0ead0c](https://github.com/lobehub/lobe-chat/commit/e0ead0c))
- **hetero-agent**: read-only SubAgent threads with breadcrumb header and thread switcher, closes [#14658](https://github.com/lobehub/lobe-chat/issues/14658) ([31e9130](https://github.com/lobehub/lobe-chat/commit/31e9130))
- **chat-input**: show skeleton in action bar while config is loading, closes [#14656](https://github.com/lobehub/lobe-chat/issues/14656) ([84b802c](https://github.com/lobehub/lobe-chat/commit/84b802c))
- **home**: add Recommendations module with hetero agent action library, closes [#14645](https://github.com/lobehub/lobe-chat/issues/14645) ([e261a6f](https://github.com/lobehub/lobe-chat/commit/e261a6f))
- **copyable-label**: wrap long tool-call params instead of truncating, closes [#14640](https://github.com/lobehub/lobe-chat/issues/14640) ([60a127b](https://github.com/lobehub/lobe-chat/commit/60a127b))
- **misc**: format tool execution time as Xmin Ys instead of X.Y min, closes [#14641](https://github.com/lobehub/lobe-chat/issues/14641) ([b85a1ad](https://github.com/lobehub/lobe-chat/commit/b85a1ad))
- **misc**: Add new DeepSeek-V4 models, closes [#14110](https://github.com/lobehub/lobe-chat/issues/14110) ([867e22a](https://github.com/lobehub/lobe-chat/commit/867e22a))
- **topic**: add copy session ID to topic dropdown menu, closes [#14595](https://github.com/lobehub/lobe-chat/issues/14595) ([a275009](https://github.com/lobehub/lobe-chat/commit/a275009))
- **misc**: use visible divider between queued messages, closes [#14593](https://github.com/lobehub/lobe-chat/issues/14593) ([909b1ec](https://github.com/lobehub/lobe-chat/commit/909b1ec))
- **intervention**: polish confirmation bar layout, closes [#14587](https://github.com/lobehub/lobe-chat/issues/14587) ([5c11130](https://github.com/lobehub/lobe-chat/commit/5c11130))
- **settings**: remove image avatar from lab input markdown rendering item, closes [#14582](https://github.com/lobehub/lobe-chat/issues/14582) ([d73de25](https://github.com/lobehub/lobe-chat/commit/d73de25))
- **task**: activity card stop run + register /tasks in SPA proxy, closes [#14559](https://github.com/lobehub/lobe-chat/issues/14559) ([a7cc553](https://github.com/lobehub/lobe-chat/commit/a7cc553))
- **misc**: update auth captcha retry copy, closes [#14561](https://github.com/lobehub/lobe-chat/issues/14561) ([c208723](https://github.com/lobehub/lobe-chat/commit/c208723))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
## [Version 2.1.57](https://github.com/lobehub/lobe-chat/compare/v2.1.57-canary.33...v2.1.57)
<sup>Released on **2026-05-09**</sup>
#### 🐛 Bug Fixes
- **docker**: replace pnpm init with static package.json in /deps.
- **onboarding**: guard skip/mode-switch footer with feature flag, desktop & init checks.
- **misc**: hide runtime-only model aliases.
#### ✨ Features
- **misc**: set OSS default model to DeepSeek V4 Pro.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's fixed
- **docker**: replace pnpm init with static package.json in /deps, closes [#14576](https://github.com/lobehub/lobe-chat/issues/14576) ([8ed31df](https://github.com/lobehub/lobe-chat/commit/8ed31df))
- **onboarding**: guard skip/mode-switch footer with feature flag, desktop & init checks, closes [#14560](https://github.com/lobehub/lobe-chat/issues/14560) ([9756dab](https://github.com/lobehub/lobe-chat/commit/9756dab))
- **misc**: hide runtime-only model aliases, closes [#14552](https://github.com/lobehub/lobe-chat/issues/14552) ([2d33322](https://github.com/lobehub/lobe-chat/commit/2d33322))
#### What's improved
- **misc**: set OSS default model to DeepSeek V4 Pro, closes [#14555](https://github.com/lobehub/lobe-chat/issues/14555) ([8105fc0](https://github.com/lobehub/lobe-chat/commit/8105fc0))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 2.1.56](https://github.com/lobehub/lobe-chat/compare/v2.1.55...v2.1.56)
<sup>Released on **2026-05-01**</sup>
+1 -1
View File
@@ -219,7 +219,7 @@ ENV \
# AiHubMix
AIHUBMIX_API_KEY="" AIHUBMIX_MODEL_LIST="" \
# Anthropic
ANTHROPIC_API_KEY="" ANTHROPIC_MODEL_LIST="" ANTHROPIC_PROXY_URL="" \
ANTHROPIC_API_KEY="" ANTHROPIC_CLIENT_TIMEOUT="" ANTHROPIC_MODEL_LIST="" ANTHROPIC_PROXY_URL="" \
# Amazon Bedrock
ENABLED_AWS_BEDROCK="" AWS_ACCESS_KEY_ID="" AWS_SECRET_ACCESS_KEY="" AWS_REGION="" AWS_BEDROCK_MODEL_LIST="" \
# Azure OpenAI
+38 -453
View File
@@ -4,9 +4,11 @@
# LobeHub
LobeHub is the ultimate space for work and life: <br/>
to find, build, and collaborate with agent teammates that grow with you.<br/>
Were building the worlds largest humanagent co-evolving network.
LobeHub organizes your agents into 7×24 operation.
It hires, schedules, reports on your entire AI team.
You stay in charge — without staying online.
**English** · [简体中文](./README.zh-CN.md) · [Official Site][official-site] · [Changelog][changelog] · [Documents][docs] · [Blog][blog] · [Feedback][github-issues-link]
@@ -25,7 +27,6 @@ Were building the worlds largest humanagent co-evolving network.
[![][github-stars-shield]][github-stars-link]
[![][github-issues-shield]][github-issues-link]
[![][github-license-shield]][github-license-link]<br>
[![][sponsor-shield]][sponsor-link]
**Share LobeHub Repository**
@@ -37,9 +38,9 @@ Were building the worlds largest humanagent co-evolving network.
[![][share-mastodon-shield]][share-mastodon-link]
[![][share-linkedin-shield]][share-linkedin-link]
<sup>Agent teammates that grow with you</sup>
<sup>Your Chief Agent Operator</sup>
[![][github-trending-shield]][github-trending-url]
<a href="https://www.producthunt.com/products/lobehub?embed=true&amp;utm_source=badge-top-post-badge&amp;utm_medium=badge&amp;utm_campaign=badge-lobehub-2" target="_blank" rel="noopener noreferrer"><img alt="LobeHub - Your Chief Agent Operator for multi-agent work | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=1147569&amp;theme=light&amp;period=daily&amp;t=1779247564355"></a> <a href="https://trendshift.io/repositories/19224" target="_blank"><img src="https://trendshift.io/api/badge/repositories/19224" alt="lobehub%2Flobehub | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[![](https://vercel.com/oss/program-badge.svg)](https://vercel.com/oss)
@@ -52,30 +53,10 @@ Were building the worlds largest humanagent co-evolving network.
- [👋🏻 Getting Started & Join Our Community](#-getting-started--join-our-community)
- [✨ Features](#-features)
- [Operator: Agents as the Unit of Work](#operator-agents-as-the-unit-of-work)
- [Create: Agents as the Unit of Work](#create-agents-as-the-unit-of-work)
- [Collaborate: Scale New Forms of Collaboration Networks](#collaborate-scale-new-forms-of-collaboration-networks)
- [Evolve: Co-evolution of Humans and Agents](#evolve-co-evolution-of-humans-and-agents)
- [MCP Plugin One-Click Installation](#mcp-plugin-one-click-installation)
- [MCP Marketplace](#mcp-marketplace)
- [Desktop App](#desktop-app)
- [Smart Internet Search](#smart-internet-search)
- [Chain of Thought](#chain-of-thought)
- [Branching Conversations](#branching-conversations)
- [Artifacts Support](#artifacts-support)
- [File Upload /Knowledge Base](#file-upload-knowledge-base)
- [Multi-Model Service Provider Support](#multi-model-service-provider-support)
- [Local Large Language Model (LLM) Support](#local-large-language-model-llm-support)
- [Model Visual Recognition](#model-visual-recognition)
- [TTS & STT Voice Conversation](#tts--stt-voice-conversation)
- [Text to Image Generation](#text-to-image-generation)
- [Plugin System (Function Calling)](#plugin-system-function-calling)
- [Agent Market (GPTs)](#agent-market-gpts)
- [Support Local / Remote Database](#support-local--remote-database)
- [Support Multi-User Management](#support-multi-user-management)
- [Progressive Web App (PWA)](#progressive-web-app-pwa)
- [Mobile Device Adaptation](#mobile-device-adaptation)
- [Custom Themes](#custom-themes)
- [`*` What's more](#-whats-more)
- [🛳 Self Hosting](#-self-hosting)
- [`A` Deploying with Vercel, Zeabur , Sealos or Alibaba Cloud](#a-deploying-with-vercel-zeabur--sealos-or-alibaba-cloud)
- [`B` Deploying with Docker](#b-deploying-with-docker)
@@ -95,7 +76,7 @@ Were building the worlds largest humanagent co-evolving network.
<br/>
<https://github.com/user-attachments/assets/6710ad97-03d0-4175-bd75-adff9b55eca2>
<https://github.com/user-attachments/assets/0a33365f-b786-48b5-9ed6-f8af7927bccb>
## 👋🏻 Getting Started & Join Our Community
@@ -104,9 +85,9 @@ By adopting the Bootstrapping approach, we aim to provide developers and users w
Whether for users or professional developers, LobeHub will be your AI Agent playground. Please be aware that LobeHub is currently under active development, and feedback is welcome for any [issues][issues-link] encountered.
| [![](https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1065874&theme=light&t=1769347414733)](https://www.producthunt.com/products/lobehub?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-lobehub) | We are live on Product Hunt! We are thrilled to bring LobeHub to the world. If you believe in a future where humans and agents co-evolve, please support our journey. |
| :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [![][discord-shield-badge]][discord-link] | Join our Discord community! This is where you can connect with developers and other enthusiastic users of LobeHub. |
| [![](https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1065874&theme=light&t=1769347414733)](https://www.producthunt.com/products/lobehub?launch=lobehub-2&embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-lobehub) | We are live on Product Hunt! We are thrilled to bring LobeHub to the world. If you believe in a future where humans and agents co-evolve, please support our journey. |
| :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [![][discord-shield-badge]][discord-link] | Join our Discord community! This is where you can connect with developers and other enthusiastic users of LobeHub. |
> \[!IMPORTANT]
>
@@ -130,7 +111,26 @@ Todays agents are one-off, task-driven tools. They lack context, live in isol
LobeHub is a work-and-lifestyle space to find, build, and collaborate with agent teammates that grow with you. In LobeHub, we treat **Agents as the unit of work**, providing an infrastructure where humans and agents co-evolve.
![](https://hub-apac-1.lobeobjects.space/blog/assets/2204cde2228fb3f583f3f2c090bc49fb.webp)
![](https://github.com/user-attachments/assets/89d1c402-a62b-4794-82ea-17e5ee1a6165)
### Operator: Agents as the Unit of Work
Hires, schedules, and reports on your entire AI team.
- **More productivity. Fewer tools**: Bring all your agents under one roof.
- **IM Gateway**: Agents where you already chat.
![](https://github.com/user-attachments/assets/7b08d6d9-9dff-4b06-a919-324630554509)
[![][back-to-top]](#readme-top)
<div align="right">
[![][back-to-top]](#readme-top)
</div>
![](https://github.com/user-attachments/assets/81e89324-fc66-4024-99a3-aa8e16ec8184)
### Create: Agents as the Unit of Work
@@ -139,6 +139,8 @@ Building a personalized AI team starts with the **Agent Builder**. You can descr
- **Unified Intelligence**: Seamlessly access any model and any modality—all under your control.
- **10,000+ Skills**: Connect your agents to the skills you use every day with a library of over 10,000 tools and MCP-compatible plugins.
![](https://github.com/user-attachments/assets/949b8166-486d-4750-ad7a-cfe7bfcb84e3)
[![][back-to-top]](#readme-top)
<div align="right">
@@ -158,6 +160,8 @@ LobeHub introduces **Agent Groups**, allowing you to work with agents like real
- **Project**: Organize work by project to keep everything structured and easy to track.
- **Workspace**: A shared space for teams to collaborate with agents, ensuring clear ownership and visibility across the organization.
![](https://github.com/user-attachments/assets/e51526c6-e09c-4a5a-9cec-dcd3fd68a3a8)
[![][back-to-top]](#readme-top)
<div align="right">
@@ -175,113 +179,7 @@ The best AI is one that understands you deeply. LobeHub features **Personal Memo
- **Continual Learning**: Your agents learn from how you work, adapting their behavior to act at the right moment.
- **White-Box Memory**: We believe in transparency. Your agents use structured, editable memory, giving you full control over what they remember.
<div align="right">
[![][back-to-top]](#readme-top)
</div>
<details>
<summary>More Features</summary>
![][image-feat-mcp]
### MCP Plugin One-Click Installation
**Seamlessly Connect Your AI to the World**
Unlock the full potential of your AI by enabling smooth, secure, and dynamic interactions with external tools, data sources, and services. LobeHub's MCP (Model Context Protocol) plugin system breaks down the barriers between your AI and the digital ecosystem, allowing for unprecedented connectivity and functionality.
Transform your conversations into powerful workflows by connecting to databases, APIs, file systems, and more. Experience the freedom of AI that truly understands and interacts with your world.
[![][back-to-top]](#readme-top)
![][image-feat-mcp-market]
### MCP Marketplace
**Discover, Connect, Extend**
Browse a growing library of MCP plugins to expand your AI's capabilities and streamline your workflows effortlessly. Visit [lobehub.com/mcp](https://lobehub.com/mcp) to explore the MCP Marketplace, which offers a curated collection of integrations that enhance your AI's ability to work with various tools and services.
From productivity tools to development environments, discover new ways to extend your AI's reach and effectiveness. Connect with the community and find the perfect plugins for your specific needs.
[![][back-to-top]](#readme-top)
![][image-feat-desktop]
### Desktop App
**Peak Performance, Zero Distractions**
Get the full LobeHub experience without browser limitations—comprehensive, focused, and always ready to go. Our desktop application provides a dedicated environment for your AI interactions, ensuring optimal performance and minimal distractions.
Experience faster response times, better resource management, and a more stable connection to your AI assistant. The desktop app is designed for users who demand the best performance from their AI tools.
[![][back-to-top]](#readme-top)
![][image-feat-web-search]
### Smart Internet Search
**Online Knowledge On Demand**
With real-time internet access, your AI keeps up with the world—news, data, trends, and more. Stay informed and get the most current information available, enabling your AI to provide accurate and up-to-date responses.
Access live information, verify facts, and explore current events without leaving your conversation. Your AI becomes a gateway to the world's knowledge, always current and comprehensive.
[![][back-to-top]](#readme-top)
[![][image-feat-cot]][docs-feat-cot]
### [Chain of Thought][docs-feat-cot]
Experience AI reasoning like never before. Watch as complex problems unfold step by step through our innovative Chain of Thought (CoT) visualization. This breakthrough feature provides unprecedented transparency into AI's decision-making process, allowing you to observe how conclusions are reached in real-time.
By breaking down complex reasoning into clear, logical steps, you can better understand and validate the AI's problem-solving approach. Whether you're debugging, learning, or simply curious about AI reasoning, CoT visualization transforms abstract thinking into an engaging, interactive experience.
[![][back-to-top]](#readme-top)
[![][image-feat-branch]][docs-feat-branch]
### [Branching Conversations][docs-feat-branch]
Introducing a more natural and flexible way to chat with AI. With Branch Conversations, your discussions can flow in multiple directions, just like human conversations do. Create new conversation branches from any message, giving you the freedom to explore different paths while preserving the original context.
Choose between two powerful modes:
- **Continuation Mode:** Seamlessly extend your current discussion while maintaining valuable context
- **Standalone Mode:** Start fresh with a new topic based on any previous message
This groundbreaking feature transforms linear conversations into dynamic, tree-like structures, enabling deeper exploration of ideas and more productive interactions.
[![][back-to-top]](#readme-top)
[![][image-feat-artifacts]][docs-feat-artifacts]
### [Artifacts Support][docs-feat-artifacts]
Experience the power of Claude Artifacts, now integrated into LobeHub. This revolutionary feature expands the boundaries of AI-human interaction, enabling real-time creation and visualization of diverse content formats.
Create and visualize with unprecedented flexibility:
- Generate and display dynamic SVG graphics
- Build and render interactive HTML pages in real-time
- Produce professional documents in multiple formats
[![][back-to-top]](#readme-top)
[![][image-feat-knowledgebase]][docs-feat-knowledgebase]
### [File Upload /Knowledge Base][docs-feat-knowledgebase]
LobeHub supports file upload and knowledge base functionality. You can upload various types of files including documents, images, audio, and video, as well as create knowledge bases, making it convenient for users to manage and search for files. Additionally, you can utilize files and knowledge base features during conversations, enabling a richer dialogue experience.
<https://github.com/user-attachments/assets/faa8cf67-e743-4590-8bf6-ebf6ccc34175>
> \[!TIP]
>
> Learn more on [📘 LobeHub Knowledge Base Launch — From Now On, Every Step Counts](https://lobehub.com/blog/knowledge-base)
![](https://github.com/user-attachments/assets/5c6e16f0-7f47-4baf-9aeb-3a00deb8ff5b)
<div align="right">
@@ -289,277 +187,6 @@ LobeHub supports file upload and knowledge base functionality. You can upload va
</div>
[![][image-feat-privoder]][docs-feat-provider]
### [Multi-Model Service Provider Support][docs-feat-provider]
In the continuous development of LobeHub, we deeply understand the importance of diversity in model service providers for meeting the needs of the community when providing AI conversation services. Therefore, we have expanded our support to multiple model service providers, rather than being limited to a single one, in order to offer users a more diverse and rich selection of conversations.
In this way, LobeHub can more flexibly adapt to the needs of different users, while also providing developers with a wider range of choices.
#### Supported Model Service Providers
We have implemented support for the following model service providers:
<!-- PROVIDER LIST -->
<details><summary><kbd>See more providers (+-10)</kbd></summary>
</details>
> 📊 Total providers: [<kbd>**0**</kbd>](https://lobechat.com/discover/providers)
<!-- PROVIDER LIST -->
At the same time, we are also planning to support more model service providers. If you would like LobeHub to support your favorite service provider, feel free to join our [💬 community discussion](https://github.com/lobehub/lobehub/discussions/1284).
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-local]][docs-feat-local]
### [Local Large Language Model (LLM) Support][docs-feat-local]
To meet the specific needs of users, LobeHub also supports the use of local models based on [Ollama](https://ollama.ai), allowing users to flexibly use their own or third-party models.
> \[!TIP]
>
> Learn more about [📘 Using Ollama in LobeHub][docs-usage-ollama] by checking it out.
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-vision]][docs-feat-vision]
### [Model Visual Recognition][docs-feat-vision]
LobeHub now supports OpenAI's latest [`gpt-4-vision`](https://platform.openai.com/docs/guides/vision) model with visual recognition capabilities,
a multimodal intelligence that can perceive visuals. Users can easily upload or drag and drop images into the dialogue box,
and the agent will be able to recognize the content of the images and engage in intelligent conversation based on this,
creating smarter and more diversified chat scenarios.
This feature opens up new interactive methods, allowing communication to transcend text and include a wealth of visual elements.
Whether it's sharing images in daily use or interpreting images within specific industries, the agent provides an outstanding conversational experience.
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-tts]][docs-feat-tts]
### [TTS & STT Voice Conversation][docs-feat-tts]
LobeHub supports Text-to-Speech (TTS) and Speech-to-Text (STT) technologies, enabling our application to convert text messages into clear voice outputs,
allowing users to interact with our conversational agent as if they were talking to a real person. Users can choose from a variety of voices to pair with the agent.
Moreover, TTS offers an excellent solution for those who prefer auditory learning or desire to receive information while busy.
In LobeHub, we have meticulously selected a range of high-quality voice options (OpenAI Audio, Microsoft Edge Speech) to meet the needs of users from different regions and cultural backgrounds.
Users can choose the voice that suits their personal preferences or specific scenarios, resulting in a personalized communication experience.
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-t2i]][docs-feat-t2i]
### [Text to Image Generation][docs-feat-t2i]
With support for the latest text-to-image generation technology, LobeHub now allows users to invoke image creation tools directly within conversations with the agent. By leveraging the capabilities of AI tools such as [`DALL-E 3`](https://openai.com/dall-e-3), [`MidJourney`](https://www.midjourney.com/), and [`Pollinations`](https://pollinations.ai/), the agents are now equipped to transform your ideas into images.
This enables a more private and immersive creative process, allowing for the seamless integration of visual storytelling into your personal dialogue with the agent.
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-plugin]][docs-feat-plugin]
### [Plugin System (Function Calling)][docs-feat-plugin]
The plugin ecosystem of LobeHub is an important extension of its core functionality, greatly enhancing the practicality and flexibility of the LobeHub assistant.
<video controls src="https://github.com/lobehub/lobehub/assets/28616219/f29475a3-f346-4196-a435-41a6373ab9e2" muted="false"></video>
By utilizing plugins, LobeHub assistants can obtain and process real-time information, such as searching for web information and providing users with instant and relevant news.
In addition, these plugins are not limited to news aggregation, but can also extend to other practical functions, such as quickly searching documents, generating images, obtaining data from various platforms like Bilibili, Steam, and interacting with various third-party services.
> \[!TIP]
>
> Learn more about [📘 Plugin Usage][docs-usage-plugin] by checking it out.
<!-- PLUGIN LIST -->
| Recent Submits | Description |
| -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| [Shopping tools](https://lobechat.com/discover/plugin/ShoppingTools)<br/><sup>By **shoppingtools** on **2026-01-12**</sup> | Search for products on eBay & AliExpress, find eBay events & coupons. Get prompt examples.<br/>`shopping` `e-bay` `ali-express` `coupons` |
| [SEO Assistant](https://lobechat.com/discover/plugin/seo_assistant)<br/><sup>By **webfx** on **2026-01-12**</sup> | The SEO Assistant can generate search engine keyword information in order to aid the creation of content.<br/>`seo` `keyword` |
| [Video Captions](https://lobechat.com/discover/plugin/VideoCaptions)<br/><sup>By **maila** on **2025-12-13**</sup> | Convert Youtube links into transcribed text, enable asking questions, create chapters, and summarize its content.<br/>`video-to-text` `youtube` |
| [WeatherGPT](https://lobechat.com/discover/plugin/WeatherGPT)<br/><sup>By **steven-tey** on **2025-12-13**</sup> | Get current weather information for a specific location.<br/>`weather` |
> 📊 Total plugins: [<kbd>**40**</kbd>](https://lobechat.com/discover/plugins)
<!-- PLUGIN LIST -->
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-agent]][docs-feat-agent]
### [Agent Market (GPTs)][docs-feat-agent]
In LobeHub Agent Marketplace, creators can discover a vibrant and innovative community that brings together a multitude of well-designed agents,
which not only play an important role in work scenarios but also offer great convenience in learning processes.
Our marketplace is not just a showcase platform but also a collaborative space. Here, everyone can contribute their wisdom and share the agents they have developed.
> \[!TIP]
>
> By [🤖/🏪 Submit Agents][submit-agents-link], you can easily submit your agent creations to our platform.
> Importantly, LobeHub has established a sophisticated automated internationalization (i18n) workflow,
> capable of seamlessly translating your agent into multiple language versions.
> This means that no matter what language your users speak, they can experience your agent without barriers.
> \[!IMPORTANT]
>
> We welcome all users to join this growing ecosystem and participate in the iteration and optimization of agents.
> Together, we can create more interesting, practical, and innovative agents, further enriching the diversity and practicality of the agent offerings.
<!-- AGENT LIST -->
| Recent Submits | Description |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [Turtle Soup Host](https://lobechat.com/discover/assistant/lateral-thinking-puzzle)<br/><sup>By **[CSY2022](https://github.com/CSY2022)** on **2025-06-19**</sup> | A turtle soup host needs to provide the scenario, the complete story (truth of the event), and the key point (the condition for guessing correctly).<br/>`turtle-soup` `reasoning` `interaction` `puzzle` `role-playing` |
| [Academic Writing Assistant](https://lobechat.com/discover/assistant/academic-writing-assistant)<br/><sup>By **[swarfte](https://github.com/swarfte)** on **2025-06-17**</sup> | Expert in academic research paper writing and formal documentation<br/>`academic-writing` `research` `formal-style` |
| [Gourmet Reviewer🍟](https://lobechat.com/discover/assistant/food-reviewer)<br/><sup>By **[renhai-lab](https://github.com/renhai-lab)** on **2025-06-17**</sup> | Food critique expert<br/>`gourmet` `review` `writing` |
| [Minecraft Senior Developer](https://lobechat.com/discover/assistant/java-development)<br/><sup>By **[iamyuuk](https://github.com/iamyuuk)** on **2025-06-17**</sup> | Expert in advanced Java development and Minecraft mod and server plugin development<br/>`development` `programming` `minecraft` `java` |
> 📊 Total agents: [<kbd>**505**</kbd> ](https://lobechat.com/discover/assistants)
<!-- AGENT LIST -->
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-database]][docs-feat-database]
### [Support Local / Remote Database][docs-feat-database]
LobeHub supports the use of both server-side and local databases. Depending on your needs, you can choose the appropriate deployment solution:
- **Local database**: suitable for users who want more control over their data and privacy protection. LobeHub uses CRDT (Conflict-Free Replicated Data Type) technology to achieve multi-device synchronization. This is an experimental feature aimed at providing a seamless data synchronization experience.
- **Server-side database**: suitable for users who want a more convenient user experience. LobeHub supports PostgreSQL as a server-side database. For detailed documentation on how to configure the server-side database, please visit [Configure Server-side Database](https://lobehub.com/docs/self-hosting/advanced/server-database).
Regardless of which database you choose, LobeHub can provide you with an excellent user experience.
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-auth]][docs-feat-auth]
### [Support Multi-User Management][docs-feat-auth]
LobeHub supports multi-user management and provides flexible user authentication solutions:
- **Better Auth**: LobeHub integrates `Better Auth`, a modern and flexible authentication library that supports multiple authentication methods, including OAuth, email login, credential login, magic links, and more. With `Better Auth`, you can easily implement user registration, login, session management, social login, multi-factor authentication (MFA), and other functions to ensure the security and privacy of user data.
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-pwa]][docs-feat-pwa]
### [Progressive Web App (PWA)][docs-feat-pwa]
We deeply understand the importance of providing a seamless experience for users in today's multi-device environment.
Therefore, we have adopted Progressive Web Application ([PWA](https://support.google.com/chrome/answer/9658361)) technology,
a modern web technology that elevates web applications to an experience close to that of native apps.
Through PWA, LobeHub can offer a highly optimized user experience on both desktop and mobile devices while maintaining high-performance characteristics.
Visually and in terms of feel, we have also meticulously designed the interface to ensure it is indistinguishable from native apps,
providing smooth animations, responsive layouts, and adapting to different device screen resolutions.
> \[!NOTE]
>
> If you are unfamiliar with the installation process of PWA, you can add LobeHub as your desktop application (also applicable to mobile devices) by following these steps:
>
> - Launch the Chrome or Edge browser on your computer.
> - Visit the LobeHub webpage.
> - In the upper right corner of the address bar, click on the <kbd>Install</kbd> icon.
> - Follow the instructions on the screen to complete the PWA Installation.
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-mobile]][docs-feat-mobile]
### [Mobile Device Adaptation][docs-feat-mobile]
We have carried out a series of optimization designs for mobile devices to enhance the user's mobile experience. Currently, we are iterating on the mobile user experience to achieve smoother and more intuitive interactions. If you have any suggestions or ideas, we welcome you to provide feedback through GitHub Issues or Pull Requests.
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-theme]][docs-feat-theme]
### [Custom Themes][docs-feat-theme]
As a design-engineering-oriented application, LobeHub places great emphasis on users' personalized experiences,
hence introducing flexible and diverse theme modes, including a light mode for daytime and a dark mode for nighttime.
Beyond switching theme modes, a range of color customization options allow users to adjust the application's theme colors according to their preferences.
Whether it's a desire for a sober dark blue, a lively peach pink, or a professional gray-white, users can find their style of color choices in LobeHub.
> \[!TIP]
>
> The default configuration can intelligently recognize the user's system color mode and automatically switch themes to ensure a consistent visual experience with the operating system.
> For users who like to manually control details, LobeHub also offers intuitive setting options and a choice between chat bubble mode and document mode for conversation scenarios.
<div align="right">
[![][back-to-top]](#readme-top)
</div>
### `*` What's more
Beside these features, LobeHub also have much better basic technique underground:
- [x] 💨 **Quick Deployment**: Using the Vercel platform or docker image, you can deploy with just one click and complete the process within 1 minute without any complex configuration.
- [x] 🌐 **Custom Domain**: If users have their own domain, they can bind it to the platform for quick access to the dialogue agent from anywhere.
- [x] 🔒 **Privacy Protection**: All data is stored locally in the user's browser, ensuring user privacy.
- [x] 💎 **Exquisite UI Design**: With a carefully designed interface, it offers an elegant appearance and smooth interaction. It supports light and dark themes and is mobile-friendly. PWA support provides a more native-like experience.
- [x] 🗣️ **Smooth Conversation Experience**: Fluid responses ensure a smooth conversation experience. It fully supports Markdown rendering, including code highlighting, LaTex formulas, Mermaid flowcharts, and more.
</details>
> ✨ more features will be added when LobeHub evolve.
<div align="right">
@@ -855,28 +482,10 @@ This project is [LobeHub Community License](./LICENSE) licensed.
[docs-dev-guide]: https://lobehub.com/docs/development/start
[docs-docker]: https://lobehub.com/docs/self-hosting/server-database/docker-compose
[docs-env-var]: https://lobehub.com/docs/self-hosting/environment-variables
[docs-feat-agent]: https://lobehub.com/docs/usage/features/agent-market
[docs-feat-artifacts]: https://lobehub.com/docs/usage/features/artifacts
[docs-feat-auth]: https://lobehub.com/docs/usage/features/auth
[docs-feat-branch]: https://lobehub.com/docs/usage/features/branching-conversations
[docs-feat-cot]: https://lobehub.com/docs/usage/features/cot
[docs-feat-database]: https://lobehub.com/docs/usage/features/database
[docs-feat-knowledgebase]: https://lobehub.com/blog/knowledge-base
[docs-feat-local]: https://lobehub.com/docs/usage/features/local-llm
[docs-feat-mobile]: https://lobehub.com/docs/usage/features/mobile
[docs-feat-plugin]: https://lobehub.com/docs/usage/features/plugin-system
[docs-feat-provider]: https://lobehub.com/docs/usage/features/multi-ai-providers
[docs-feat-pwa]: https://lobehub.com/docs/usage/features/pwa
[docs-feat-t2i]: https://lobehub.com/docs/usage/features/text-to-image
[docs-feat-theme]: https://lobehub.com/docs/usage/features/theme
[docs-feat-tts]: https://lobehub.com/docs/usage/features/tts
[docs-feat-vision]: https://lobehub.com/docs/usage/features/vision
[docs-function-call]: https://lobehub.com/blog/openai-function-call
[docs-plugin-dev]: https://lobehub.com/docs/usage/plugins/development
[docs-self-hosting]: https://lobehub.com/docs/self-hosting/start
[docs-upstream-sync]: https://lobehub.com/docs/self-hosting/advanced/upstream-sync
[docs-usage-ollama]: https://lobehub.com/docs/usage/providers/ollama
[docs-usage-plugin]: https://lobehub.com/docs/usage/plugins/basic
[fossa-license-link]: https://app.fossa.com/projects/git%2Bgithub.com%2Flobehub%2Flobehub
[fossa-license-shield]: https://app.fossa.com/api/projects/git%2Bgithub.com%2Flobehub%2Flobehub.svg?type=large
[github-action-release-link]: https://github.com/actions/workflows/lobehub/lobehub/release.yml
@@ -898,29 +507,7 @@ This project is [LobeHub Community License](./LICENSE) licensed.
[github-releasedate-shield]: https://img.shields.io/github/release-date/lobehub/lobehub?labelColor=black&style=flat-square
[github-stars-link]: https://github.com/lobehub/lobehub/stargazers
[github-stars-shield]: https://img.shields.io/github/stars/lobehub/lobehub?color=ffcb47&labelColor=black&style=flat-square
[github-trending-shield]: https://trendshift.io/api/badge/repositories/2256
[github-trending-url]: https://trendshift.io/repositories/2256
[image-banner]: https://github.com/user-attachments/assets/0fe626a3-0ddc-4f67-b595-3c5b3f1701e0
[image-feat-agent]: https://github.com/user-attachments/assets/b3ab6e35-4fbc-468d-af10-e3e0c687350f
[image-feat-artifacts]: https://github.com/user-attachments/assets/7f95fad6-b210-4e6e-84a0-7f39e96f3a00
[image-feat-auth]: https://github.com/user-attachments/assets/80bb232e-19d1-4f97-98d6-e291f3585e6d
[image-feat-branch]: https://github.com/user-attachments/assets/92f72082-02bd-4835-9c54-b089aad7fd41
[image-feat-cot]: https://github.com/user-attachments/assets/f74f1139-d115-4e9c-8c43-040a53797a5e
[image-feat-database]: https://github.com/user-attachments/assets/f1697c8b-d1fb-4dac-ba05-153c6295d91d
[image-feat-desktop]: https://github.com/user-attachments/assets/a7bac8d3-ea96-4000-bb39-fadc9b610f96
[image-feat-knowledgebase]: https://github.com/user-attachments/assets/7da7a3b2-92fd-4630-9f4e-8560c74955ae
[image-feat-local]: https://github.com/user-attachments/assets/1239da50-d832-4632-a7ef-bd754c0f3850
[image-feat-mcp]: https://github.com/user-attachments/assets/1be85d36-3975-4413-931f-27e05e440995
[image-feat-mcp-market]: https://github.com/user-attachments/assets/bb114f9f-24c5-4000-a984-c10d187da5a0
[image-feat-mobile]: https://github.com/user-attachments/assets/32cf43c4-96bd-4a4c-bfb6-59acde6fe380
[image-feat-plugin]: https://github.com/user-attachments/assets/66a891ac-01b6-4e3f-b978-2eb07b489b1b
[image-feat-privoder]: https://github.com/user-attachments/assets/e553e407-42de-4919-977d-7dbfcf44a821
[image-feat-pwa]: https://github.com/user-attachments/assets/9647f70f-b71b-43b6-9564-7cdd12d1c24d
[image-feat-t2i]: https://github.com/user-attachments/assets/708274a7-2458-494b-a6ec-b73dfa1fa7c2
[image-feat-theme]: https://github.com/user-attachments/assets/b47c39f1-806f-492b-8fcb-b0fa973937c1
[image-feat-tts]: https://github.com/user-attachments/assets/50189597-2cc3-4002-b4c8-756a52ad5c0a
[image-feat-vision]: https://github.com/user-attachments/assets/18574a1f-46c2-4cbc-af2c-35a86e128a07
[image-feat-web-search]: https://github.com/user-attachments/assets/cfdc48ac-b5f8-4a00-acee-db8f2eba09ad
[image-banner]: https://github.com/user-attachments/assets/5f78ae58-ed4f-4d38-8037-96109fbba58c
[image-star]: https://github.com/user-attachments/assets/3216e25b-186f-4a54-9cb4-2f124aec0471
[issues-link]: https://img.shields.io/github/issues/lobehub/lobehub.svg?style=flat
[lobe-chat-plugins]: https://github.com/lobehub/lobe-chat-plugins
@@ -958,8 +545,6 @@ This project is [LobeHub Community License](./LICENSE) licensed.
[share-whatsapp-shield]: https://img.shields.io/badge/-share%20on%20whatsapp-black?labelColor=black&logo=whatsapp&logoColor=white&style=flat-square
[share-x-link]: https://x.com/intent/tweet?hashtags=chatbot%2CchatGPT%2CopenAI&text=Check%20this%20GitHub%20repository%20out%20%F0%9F%A4%AF%20LobeHub%20-%20An%20open-source%2C%20extensible%20%28Function%20Calling%29%2C%20high-performance%20chatbot%20framework.%20It%20supports%20one-click%20free%20deployment%20of%20your%20private%20ChatGPT%2FLLM%20web%20application.&url=https%3A%2F%2Fgithub.com%2Flobehub%2Flobehub
[share-x-shield]: https://img.shields.io/badge/-share%20on%20x-black?labelColor=black&logo=x&logoColor=white&style=flat-square
[sponsor-link]: https://opencollective.com/lobehub 'Become ❤️ LobeHub Sponsor'
[sponsor-shield]: https://img.shields.io/badge/-Sponsor%20LobeHub-f04f88?logo=opencollective&logoColor=white&style=flat-square
[submit-agents-link]: https://github.com/lobehub/lobe-chat-agents
[submit-agents-shield]: https://img.shields.io/badge/🤖/🏪_submit_agent-%E2%86%92-c4f042?labelColor=black&style=for-the-badge
[submit-plugin-link]: https://github.com/lobehub/lobe-chat-plugins
+39 -429
View File
@@ -4,8 +4,11 @@
# LobeHub
LobeHub 是一个工作与生活空间,用于发现、构建并与会随着您一起成长的 Agent 队友协作。<br/>
在 LobeHub 中,我们将 **Agent 视为工作单元**,提供一个让人类与 Agent 共同进化的基础设施。
LobeHub 帮你把专属 Agent 组织成 7×24 不打烊的高效队伍:
自动为你招募适配的 AI 队友、调度任务排班、汇总生成工作报告,
你始终掌控全局,从此不用再时刻在线盯守,真正解放自己的时间。
[English](./README.md) · **简体中文** · [官网][official-site] · [更新日志][changelog] · [文档][docs] · [博客][blog] · [反馈问题][github-issues-link]
@@ -24,7 +27,6 @@ LobeHub 是一个工作与生活空间,用于发现、构建并与会随着您
[![][github-stars-shield]][github-stars-link]
[![][github-issues-shield]][github-issues-link]
[![][github-license-shield]][github-license-link]<br>
[![][sponsor-shield]][sponsor-link]
**分享 LobeHub 给你的好友**
@@ -35,9 +37,9 @@ LobeHub 是一个工作与生活空间,用于发现、构建并与会随着您
[![][share-weibo-shield]][share-weibo-link]
[![][share-mastodon-shield]][share-mastodon-link]
<sup>Agent teammates that grow with you</sup>
<sup>你的首席 Agent 运营官</sup>
[![][github-trending-shield]][github-trending-url]
<a href="https://www.producthunt.com/products/lobehub?embed=true&amp;utm_source=badge-top-post-badge&amp;utm_medium=badge&amp;utm_campaign=badge-lobehub-2" target="_blank" rel="noopener noreferrer"><img alt="LobeHub - Your Chief Agent Operator for multi-agent work | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=1147569&amp;theme=light&amp;period=daily&amp;t=1779247564355"></a> <a href="https://trendshift.io/repositories/19224" target="_blank"><img src="https://trendshift.io/api/badge/repositories/19224" alt="lobehub%2Flobehub | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[![][github-hello-shield]][github-hello-url]
</div>
@@ -49,30 +51,10 @@ LobeHub 是一个工作与生活空间,用于发现、构建并与会随着您
- [👋🏻 开始使用 & 交流](#-开始使用--交流)
- [✨ 特性一览](#-特性一览)
- [运营:你制定策略,我们负责运行 Agent。](#运营你制定策略我们负责运行-agent)
- [创建:以 Agent 为工作单元](#创建以-agent-为工作单元)
- [协作:扩展新型协作网络](#协作扩展新型协作网络)
- [进化:人类与 Agent 的共生进化](#进化人类与-agent-的共生进化)
- [MCP](#mcp)
- [发现、连接、扩展](#发现连接扩展)
- [巅峰性能,零干扰](#巅峰性能零干扰)
- [在线知识,按需获取](#在线知识按需获取)
- [思维链 (CoT)](#思维链-cot)
- [分支对话](#分支对话)
- [支持白板 (Artifacts)](#支持白板-artifacts)
- [文件上传 / 知识库](#文件上传--知识库)
- [多模型服务商支持](#多模型服务商支持)
- [支持本地大语言模型 (LLM)](#支持本地大语言模型-llm)
- [模型视觉识别 (Model Visual)](#模型视觉识别-model-visual)
- [TTS & STT 语音会话](#tts--stt-语音会话)
- [Text to Image 文生图](#text-to-image-文生图)
- [插件系统 (Tools Calling)](#插件系统-tools-calling)
- [助手市场 (GPTs)](#助手市场-gpts)
- [支持本地 / 远程数据库](#支持本地--远程数据库)
- [支持多用户管理](#支持多用户管理)
- [渐进式 Web 应用 (PWA)](#渐进式-web-应用-pwa)
- [移动设备适配](#移动设备适配)
- [自定义主题](#自定义主题)
- [`*` 更多特性](#-更多特性)
- [🛳 开箱即用](#-开箱即用)
- [`A` 使用 Vercel、Zeabur 、Sealos 或 阿里云计算巢 部署](#a-使用-vercelzeabur-sealos-或-阿里云计算巢-部署)
- [`B` 使用 Docker 部署](#b-使用-docker-部署)
@@ -93,7 +75,7 @@ LobeHub 是一个工作与生活空间,用于发现、构建并与会随着您
<br/>
<https://github.com/user-attachments/assets/6710ad97-03d0-4175-bd75-adff9b55eca2>
<https://github.com/user-attachments/assets/0a33365f-b786-48b5-9ed6-f8af7927bccb>
## 👋🏻 开始使用 & 交流
@@ -102,9 +84,9 @@ LobeHub 是一个工作与生活空间,用于发现、构建并与会随着您
不论普通用户与专业开发者,LobeHub 旨在成为所有人的 AI Agent 实验场。LobeHub 目前正在积极开发中,有任何需求或者问题,欢迎提交 [issues][issues-link]
| [![](https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1065874&theme=light&t=1769347414733)](https://www.producthunt.com/products/lobehub?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-lobehub) | 我们已在 Product Hunt 上线!我们很高兴将 LobeHub 推向世界。如果您相信人类与 Agent 共同进化的未来,请支持我们的旅程。 |
| :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :------------------------------------------------------------------------------------------------------------------- |
| [![][discord-shield-badge]][discord-link] | 加入我们的 Discord 社区!这是你可以与开发者和其他 LobeHub 热衷用户交流的地方 |
| [![](https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1065874&theme=light&t=1769347414733)](https://www.producthunt.com/products/lobehub?launch=lobehub-2&embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-lobehub) | 我们已在 Product Hunt 上线!我们很高兴将 LobeHub 推向世界。如果您相信人类与 Agent 共同进化的未来,请支持我们的旅程。 |
| :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------- |
| [![][discord-shield-badge]][discord-link] | 加入我们的 Discord 社区!这是你可以与开发者和其他 LobeHub 热衷用户交流的地方 |
> \[!IMPORTANT]
>
@@ -127,7 +109,26 @@ LobeHub 是一个工作与生活空间,用于发现、构建并与会随着您
LobeHub 是一个工作与生活空间,用于发现、构建并与会随着您一起成长的 Agent 队友协作。在 LobeHub 中,我们将 **Agent 视为工作单元**,提供一个让人类与 Agent 共同进化的基础设施。
![](https://hub-apac-1.lobeobjects.space/blog/assets/2204cde2228fb3f583f3f2c090bc49fb.webp)
![](https://github.com/user-attachments/assets/89d1c402-a62b-4794-82ea-17e5ee1a6165)
### 运营:你制定策略,我们负责运行 Agent。
雇用、排程并汇报你整个 AI 团队的工作
- **更高生产力,更少工具**:将你所有的 Agent 集中在一个平台。
- **IM 网关**: Agent 连接到您每天使用的技能。
![](https://github.com/user-attachments/assets/7b08d6d9-9dff-4b06-a919-324630554509)
[![][back-to-top]](#readme-top)
<div align="right">
[![][back-to-top]](#readme-top)
</div>
![](https://github.com/user-attachments/assets/81e89324-fc66-4024-99a3-aa8e16ec8184)
### 创建:以 Agent 为工作单元
@@ -136,6 +137,8 @@ LobeHub 是一个工作与生活空间,用于发现、构建并与会随着您
- **统一智能**:无缝访问任何模型与任何模态 —— 全部由您掌控。
- **1 万 + 技能**:通过超过 10,000 个工具和与 MCP 兼容的插件,将 Agent 连接到您每天使用的技能。
![](https://github.com/user-attachments/assets/949b8166-486d-4750-ad7a-cfe7bfcb84e3)
[![][back-to-top]](#readme-top)
<div align="right">
@@ -155,6 +158,8 @@ LobeHub 引入了 **Agent Groups**,让您可以像对待真实队友一样与
- **项目(Project)**:按项目组织工作,保持一切结构化且易于跟踪。
- **工作区(Workspace**:供团队与 Agent 协作的共享空间,确保明确的所有权和组织内的可见性。
![](https://github.com/user-attachments/assets/e51526c6-e09c-4a5a-9cec-dcd3fd68a3a8)
[![][back-to-top]](#readme-top)
<div align="right">
@@ -172,105 +177,7 @@ LobeHub 引入了 **Agent Groups**,让您可以像对待真实队友一样与
- **持续学习**:您的 Agent 会从您的工作方式中学习,调整其行为以在恰当时刻采取行动。
- **白盒记忆**:我们相信透明性。您的 Agent 使用结构化、可编辑的记忆,让您完全掌控它们记住的内容。
<div align="right">
[![][back-to-top]](#readme-top)
</div>
<details>
<summary>更多特性</summary>
[![](https://github.com/user-attachments/assets/1be85d36-3975-4413-931f-27e05e440995)](https://lobehub.com/mcp)
### MCP
通过启用与外部工具、数据源和服务的平滑、安全和动态交互,释放你的 AI 的全部潜力。基于 MCP(模型上下文协议)的插件系统打破了 AI 与数字生态系统之间的壁垒,实现了前所未有的连接性和功能性。
将对话转化为强大的工作流程,连接数据库、API、文件系统等。体验真正理解并与你的世界互动的 AI Agent。
[![][back-to-top]](#readme-top)
![][image-feat-mcp-market]
### 发现、连接、扩展
浏览不断增长的 MCP 插件库,轻松扩展你的 AI 能力并简化工作流程。访问 [lobehub.com/mcp](https://lobehub.com/mcp) 探索 MCP 市场,提供精选的集成集合,增强你的 AI 与各种工具和服务协作的能力。
从生产力工具到开发环境,发现扩展 AI 覆盖范围和效率的新方式。与社区连接,找到满足特定需求的完美插件。
[![][back-to-top]](#readme-top)
![][image-feat-desktop]
### 巅峰性能,零干扰
获得完整的 LobeHub 体验,摆脱浏览器限制 —— 轻量级、专注且随时就绪。我们的桌面应用程序为你的 AI 交互提供专用环境,确保最佳性能和最小干扰。
体验更快的响应时间、更好的资源管理和与 AI 助手的更稳定连接。桌面应用专为要求 AI 工具最佳性能的用户设计。
[![][back-to-top]](#readme-top)
![][image-feat-web-search]
### 在线知识,按需获取
通过实时联网访问,你的 AI 与世界保持同步 —— 新闻、数据、趋势等。保持信息更新,获取最新可用信息,使你的 AI 能够提供准确和最新的回复。
访问实时信息,验证事实,探索当前事件,无需离开对话。你的 AI 成为通向世界知识的门户,始终保持最新和全面。
[![][back-to-top]](#readme-top)
[![][image-feat-cot]][docs-feat-cot]
### [思维链 (CoT)][docs-feat-cot]
体验前所未有的 AI 推理过程。通过创新的思维链(CoT)可视化功能,您可以实时观察复杂问题是如何一步步被解析的。这项突破性的功能为 AI 的决策过程提供了前所未有的透明度,让您能够清晰地了解结论是如何得出的。
通过将复杂的推理过程分解为清晰的逻辑步骤,您可以更好地理解和验证 AI 的解题思路。无论您是在调试问题、学习知识,还是单纯对 AI 推理感兴趣,思维链可视化都能将抽象思维转化为一种引人入胜的互动体验。
[![][back-to-top]](#readme-top)
[![][image-feat-branch]][docs-feat-branch]
### [分支对话][docs-feat-branch]
为您带来更自然、更灵活的 AI 对话方式。通过分支对话功能,您的讨论可以像人类对话一样自然延伸。在任意消息处创建新的对话分支,让您在保留原有上下文的同时,自由探索不同的对话方向。
两种强大模式任您选择:
- **延续模式**:无缝延展当前讨论,保持宝贵的对话上下文
- **独立模式**:基于任意历史消息,开启全新话题探讨
这项突破性功能将线性对话转变为动态的树状结构,让您能够更深入地探索想法,实现更高效的互动体验。
[![][back-to-top]](#readme-top)
[![][image-feat-artifacts]][docs-feat-artifacts]
### [支持白板 (Artifacts)][docs-feat-artifacts]
体验集成于 LobeHub 的 Claude Artifacts 能力。这项革命性功能突破了 AI 人机交互的边界,让您能够实时创建和可视化各种格式的内容。
以前所未有的灵活度进行创作与可视化:
- 生成并展示动态 SVG 图形
- 实时构建与渲染交互式 HTML 页面
- 输出多种格式的专业文档
[![][back-to-top]](#readme-top)
[![][image-feat-knowledgebase]][docs-feat-knowledgebase]
### [文件上传 / 知识库][docs-feat-knowledgebase]
LobeHub 支持文件上传与知识库功能,你可以上传文件、图片、音频、视频等多种类型的文件,以及创建知识库,方便用户管理和查找文件。同时在对话中使用文件和知识库功能,实现更加丰富的对话体验。
<https://github.com/user-attachments/assets/faa8cf67-e743-4590-8bf6-ebf6ccc34175>
> \[!TIP]
>
> 查阅 [📘 LobeHub 知识库上线 —— 此刻起,跬步千里](https://lobehub.com/zh/blog/knowledge-base) 了解详情。
![](https://github.com/user-attachments/assets/5c6e16f0-7f47-4baf-9aeb-3a00deb8ff5b)
<div align="right">
@@ -278,262 +185,6 @@ LobeHub 支持文件上传与知识库功能,你可以上传文件、图片、
</div>
[![][image-feat-privoder]][docs-feat-provider]
### [多模型服务商支持][docs-feat-provider]
在 LobeHub 的不断发展过程中,我们深刻理解到在提供 AI 会话服务时模型服务商的多样性对于满足社区需求的重要性。因此,我们不再局限于单一的模型服务商,而是拓展了对多种模型服务商的支持,以便为用户提供更为丰富和多样化的会话选择。
通过这种方式,LobeHub 能够更灵活地适应不同用户的需求,同时也为开发者提供了更为广泛的选择空间。
#### 已支持的模型服务商
我们已经实现了对以下模型服务商的支持:
<!-- PROVIDER LIST -->
<details><summary><kbd>See more providers (+-10)</kbd></summary>
</details>
> 📊 Total providers: [<kbd>**0**</kbd>](https://lobechat.com/discover/providers)
<!-- PROVIDER LIST -->
同时,我们也在计划支持更多的模型服务商,以进一步丰富我们的服务商库。如果你希望让 LobeHub 支持你喜爱的服务商,欢迎加入我们的 [💬 社区讨论](https://github.com/lobehub/lobehub/discussions/6157)。
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-local]][docs-feat-local]
### [支持本地大语言模型 (LLM)][docs-feat-local]
为了满足特定用户的需求,LobeHub 还基于 [Ollama](https://ollama.ai) 支持了本地模型的使用,让用户能够更灵活地使用自己的或第三方的模型。
> \[!TIP]
>
> 查阅 [📘 在 LobeHub 中使用 Ollama][docs-usage-ollama] 获得更多信息
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-vision]][docs-feat-vision]
### [模型视觉识别 (Model Visual)][docs-feat-vision]
LobeHub 已经支持 OpenAI 最新的 [`gpt-4-vision`](https://platform.openai.com/docs/guides/vision) 支持视觉识别的模型,这是一个具备视觉识别能力的多模态应用。
用户可以轻松上传图片或者拖拽图片到对话框中,助手将能够识别图片内容,并在此基础上进行智能对话,构建更智能、更多元化的聊天场景。
这一特性打开了新的互动方式,使得交流不再局限于文字,而是可以涵盖丰富的视觉元素。无论是日常使用中的图片分享,还是在特定行业内的图像解读,助手都能提供出色的对话体验。
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-tts]][docs-feat-tts]
### [TTS & STT 语音会话][docs-feat-tts]
LobeHub 支持文字转语音(Text-to-SpeechTTS)和语音转文字(Speech-to-Text,STT)技术,这使得我们的应用能够将文本信息转化为清晰的语音输出,用户可以像与真人交谈一样与我们的对话助手进行交流。
用户可以从多种声音中选择,给助手搭配合适的音源。 同时,对于那些倾向于听觉学习或者想要在忙碌中获取信息的用户来说,TTS 提供了一个极佳的解决方案。
在 LobeHub 中,我们精心挑选了一系列高品质的声音选项 (OpenAI Audio, Microsoft Edge Speech),以满足不同地域和文化背景用户的需求。用户可以根据个人喜好或者特定场景来选择合适的语音,从而获得个性化的交流体验。
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-t2i]][docs-feat-t2i]
### [Text to Image 文生图][docs-feat-t2i]
支持最新的文本到图片生成技术,LobeHub 现在能够让用户在与助手对话中直接调用文生图工具进行创作。
通过利用 [`DALL-E 3`](https://openai.com/dall-e-3)、[`MidJourney`](https://www.midjourney.com/) 和 [`Pollinations`](https://pollinations.ai/) 等 AI 工具的能力, 助手们现在可以将你的想法转化为图像。
同时可以更私密和沉浸式地完成你的创作过程。
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-plugin]][docs-feat-plugin]
### [插件系统 (Tools Calling)][docs-feat-plugin]
LobeHub 的插件生态系统是其核心功能的重要扩展,它极大地增强了 ChatGPT 的实用性和灵活性。
<video controls src="https://github.com/lobehub/lobehub/assets/28616219/f29475a3-f346-4196-a435-41a6373ab9e2" muted="false"></video>
通过利用插件,ChatGPT 能够实现实时信息的获取和处理,例如自动获取最新新闻头条,为用户提供即时且相关的资讯。
此外,这些插件不仅局限于新闻聚合,还可以扩展到其他实用的功能,如快速检索文档、生成图象、获取电商平台数据,以及其他各式各样的第三方服务。
> 通过文档了解更多 [📘 插件使用][docs-usage-plugin]
<!-- PLUGIN LIST -->
| 最近新增 | 描述 |
| -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| [购物工具](https://lobechat.com/discover/plugin/ShoppingTools)<br/><sup>By **shoppingtools** on **2026-01-12**</sup> | 在 eBay 和 AliExpress 上搜索产品,查找 eBay 活动和优惠券。获取快速示例。<br/>`购物` `e-bay` `ali-express` `优惠券` |
| [SEO 助手](https://lobechat.com/discover/plugin/seo_assistant)<br/><sup>By **webfx** on **2026-01-12**</sup> | SEO 助手可以生成搜索引擎关键词信息,以帮助创建内容。<br/>`seo` `关键词` |
| [视频字幕](https://lobechat.com/discover/plugin/VideoCaptions)<br/><sup>By **maila** on **2025-12-13**</sup> | 将 Youtube 链接转换为转录文本,使其能够提问,创建章节,并总结其内容。<br/>`视频转文字` `you-tube` |
| [天气 GPT](https://lobechat.com/discover/plugin/WeatherGPT)<br/><sup>By **steven-tey** on **2025-12-13**</sup> | 获取特定位置的当前天气信息。<br/>`天气` |
> 📊 Total plugins: [<kbd>**40**</kbd>](https://lobechat.com/discover/plugins)
<!-- PLUGIN LIST -->
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-agent]][docs-feat-agent]
### [助手市场 (GPTs)][docs-feat-agent]
在 LobeHub 的助手市场中,创作者们可以发现一个充满活力和创新的社区,它汇聚了众多精心设计的助手,这些助手不仅在工作场景中发挥着重要作用,也在学习过程中提供了极大的便利。
我们的市场不仅是一个展示平台,更是一个协作的空间。在这里,每个人都可以贡献自己的智慧,分享个人开发的助手。
> \[!TIP]
>
> 通过 [🤖/🏪 提交助手][submit-agents-link] ,你可以轻松地将你的助手作品提交到我们的平台。我们特别强调的是,LobeHub 建立了一套精密的自动化国际化(i18n)工作流程, 它的强大之处在于能够无缝地将你的助手转化为多种语言版本。
> 这意味着,不论你的用户使用何种语言,他们都能无障碍地体验到你的助手。
> \[!IMPORTANT]
>
> 我欢迎所有用户加入这个不断成长的生态系统,共同参与到助手的迭代与优化中来。共同创造出更多有趣、实用且具有创新性的助手,进一步丰富助手的多样性和实用性。
<!-- AGENT LIST -->
| 最近新增 | 描述 |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| [海龟汤主持人](https://lobechat.com/discover/assistant/lateral-thinking-puzzle)<br/><sup>By **[CSY2022](https://github.com/CSY2022)** on **2025-06-19**</sup> | 一个海龟汤主持人,需要自己提供汤面,汤底与关键点(猜中的判定条件)。<br/>`海龟汤` `推理` `互动` `谜题` `角色扮演` |
| [学术写作助手](https://lobechat.com/discover/assistant/academic-writing-assistant)<br/><sup>By **[swarfte](https://github.com/swarfte)** on **2025-06-17**</sup> | 专业的学术研究论文写作和正式文档编写专家<br/>`学术写作` `研究` `正式风格` |
| [美食评论员🍟](https://lobechat.com/discover/assistant/food-reviewer)<br/><sup>By **[renhai-lab](https://github.com/renhai-lab)** on **2025-06-17**</sup> | 美食评价专家<br/>`美食` `评价` `写作` |
| [Minecraft 资深开发者](https://lobechat.com/discover/assistant/java-development)<br/><sup>By **[iamyuuk](https://github.com/iamyuuk)** on **2025-06-17**</sup> | 擅长高级 Java 开发及 Minecraft 开发<br/>`开发` `编程` `minecraft` `java` |
> 📊 Total agents: [<kbd>**505**</kbd> ](https://lobechat.com/discover/assistants)
<!-- AGENT LIST -->
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-database]][docs-feat-database]
### [支持本地 / 远程数据库][docs-feat-database]
LobeHub 支持同时使用服务端数据库和本地数据库。根据您的需求,您可以选择合适的部署方案:
- 本地数据库:适合希望对数据有更多掌控感和隐私保护的用户。LobeHub 采用了 CRDT (Conflict-Free Replicated Data Type) 技术,实现了多端同步功能。这是一项实验性功能,旨在提供无缝的数据同步体验。
- 服务端数据库:适合希望更便捷使用体验的用户。LobeHub 支持 PostgreSQL 作为服务端数据库。关于如何配置服务端数据库的详细文档,请前往 [配置服务端数据库](https://lobehub.com/zh/docs/self-hosting/advanced/server-database)。
无论您选择哪种数据库,LobeHub 都能为您提供卓越的用户体验。
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-auth]][docs-feat-auth]
### [支持多用户管理][docs-feat-auth]
LobeHub 支持多用户管理,提供了灵活的用户认证方案:
- **Better Auth**LobeHub 集成了 `Better Auth`,一个现代化且灵活的身份验证库,支持多种身份验证方式,包括 OAuth、邮件登录、凭证登录、魔法链接等。通过 `Better Auth`,您可以轻松实现用户的注册、登录、会话管理、社交登录、多因素认证 (MFA) 等功能,确保用户数据的安全性和隐私性。
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-pwa]][docs-feat-pwa]
### [渐进式 Web 应用 (PWA)][docs-feat-pwa]
我们深知在当今多设备环境下为用户提供无缝体验的重要性。为此,我们采用了渐进式 Web 应用 [PWA](https://support.google.com/chrome/answer/9658361) 技术,
这是一种能够将网页应用提升至接近原生应用体验的现代 Web 技术。通过 PWA,LobeHub 能够在桌面和移动设备上提供高度优化的用户体验,同时保持轻量级和高性能的特点。
在视觉和感觉上,我们也经过精心设计,以确保它的界面与原生应用无差别,提供流畅的动画、响应式布局和适配不同设备的屏幕分辨率。
> \[!NOTE]
>
> 若您未熟悉 PWA 的安装过程,您可以按照以下步骤将 LobeHub 添加为您的桌面应用(也适用于移动设备):
>
> - 在电脑上运行 Chrome 或 Edge 浏览器 .
> - 访问 LobeHub 网页 .
> - 在地址栏的右上角,单击 <kbd>安装</kbd> 图标 .
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-mobile]][docs-feat-mobile]
### [移动设备适配][docs-feat-mobile]
针对移动设备进行了一系列的优化设计,以提升用户的移动体验。目前,我们正在对移动端的用户体验进行版本迭代,以实现更加流畅和直观的交互。如果您有任何建议或想法,我们非常欢迎您通过 GitHub Issues 或者 Pull Requests 提供反馈。
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-theme]][docs-feat-theme]
### [自定义主题][docs-feat-theme]
作为设计工程师出身,LobeHub 在界面设计上充分考虑用户的个性化体验,因此引入了灵活多变的主题模式,其中包括日间的亮色模式和夜间的深色模式。
除了主题模式的切换,还提供了一系列的颜色定制选项,允许用户根据自己的喜好来调整应用的主题色彩。无论是想要沉稳的深蓝,还是希望活泼的桃粉,或者是专业的灰白,用户都能够在 LobeHub 中找到匹配自己风格的颜色选择。
> \[!TIP]
>
> 默认配置能够智能地识别用户系统的颜色模式,自动进行主题切换,以确保应用界面与操作系统保持一致的视觉体验。对于喜欢手动调控细节的用户,LobeHub 同样提供了直观的设置选项,针对聊天场景也提供了对话气泡模式和文档模式的选择。
<div align="right">
<div align="right">
[![][back-to-top]](#readme-top)
</div>
</div>
### `*` 更多特性
除了上述功能特性以外,LobeHub 所具有的设计和技术能力将为你带来更多使用保障:
- [x] 💎 **精致 UI 设计**:经过精心设计的界面,具有优雅的外观和流畅的交互效果,支持亮暗色主题,适配移动端。支持 PWA,提供更加接近原生应用的体验。
- [x] 🗣️ **流畅的对话体验**:流式响应带来流畅的对话体验,并且支持完整的 Markdown 渲染,包括代码高亮、LaTex 公式、Mermaid 流程图等。
- [x] 💨 **快速部署**:使用 Vercel 平台或者我们的 Docker 镜像,只需点击一键部署按钮,即可在 1 分钟内完成部署,无需复杂的配置过程。
- [x] 🔒 **隐私安全**:所有数据保存在用户浏览器本地,保证用户的隐私安全。
- [x] 🌐 **自定义域名**:如果用户拥有自己的域名,可以将其绑定到平台上,方便在任何地方快速访问对话助手。
</details>
> ✨ 随着产品迭代持续更新,我们将会带来更多更多令人激动的功能!
<div align="right">
@@ -867,28 +518,10 @@ This project is [LobeHub Community License](./LICENSE) licensed.
[docs-dev-guide]: https://lobehub.com/docs/development/start
[docs-docker]: https://lobehub.com/zh/docs/self-hosting/server-database/docker-compose
[docs-env-var]: https://lobehub.com/docs/self-hosting/environment-variables
[docs-feat-agent]: https://lobehub.com/docs/usage/features/agent-market
[docs-feat-artifacts]: https://lobehub.com/docs/usage/features/artifacts
[docs-feat-auth]: https://lobehub.com/docs/usage/features/auth
[docs-feat-branch]: https://lobehub.com/docs/usage/features/branching-conversations
[docs-feat-cot]: https://lobehub.com/docs/usage/features/cot
[docs-feat-database]: https://lobehub.com/docs/usage/features/database
[docs-feat-knowledgebase]: https://lobehub.com/blog/knowledge-base
[docs-feat-local]: https://lobehub.com/docs/usage/features/local-llm
[docs-feat-mobile]: https://lobehub.com/docs/usage/features/mobile
[docs-feat-plugin]: https://lobehub.com/docs/usage/features/plugin-system
[docs-feat-provider]: https://lobehub.com/docs/usage/features/multi-ai-providers
[docs-feat-pwa]: https://lobehub.com/docs/usage/features/pwa
[docs-feat-t2i]: https://lobehub.com/docs/usage/features/text-to-image
[docs-feat-theme]: https://lobehub.com/docs/usage/features/theme
[docs-feat-tts]: https://lobehub.com/docs/usage/features/tts
[docs-feat-vision]: https://lobehub.com/docs/usage/features/vision
[docs-function-call]: https://lobehub.com/zh/blog/openai-function-call
[docs-plugin-dev]: https://lobehub.com/docs/usage/plugins/development
[docs-self-hosting]: https://lobehub.com/docs/self-hosting/start
[docs-upstream-sync]: https://lobehub.com/docs/self-hosting/advanced/upstream-sync
[docs-usage-ollama]: https://lobehub.com/docs/usage/providers/ollama
[docs-usage-plugin]: https://lobehub.com/docs/usage/plugins/basic
[fossa-license-link]: https://app.fossa.com/projects/git%2Bgithub.com%2Flobehub%2Flobehub
[fossa-license-shield]: https://app.fossa.com/api/projects/git%2Bgithub.com%2Flobehub%2Flobehub.svg?type=large
[github-action-release-link]: https://github.com/lobehub/lobehub/actions/workflows/release.yml
@@ -911,29 +544,8 @@ This project is [LobeHub Community License](./LICENSE) licensed.
[github-releasedate-link]: https://github.com/lobehub/lobehub/releases
[github-releasedate-shield]: https://img.shields.io/github/release-date/lobehub/lobehub?labelColor=black&style=flat-square
[github-stars-link]: https://github.com/lobehub/lobehub/stargazers
[github-stars-shield]: https://github.com/user-attachments/assets/3216e25b-186f-4a54-9cb4-2f124aec0471
[github-trending-shield]: https://trendshift.io/api/badge/repositories/2256
[github-trending-url]: https://trendshift.io/repositories/2256
[image-banner]: https://github.com/user-attachments/assets/0fe626a3-0ddc-4f67-b595-3c5b3f1701e0
[image-feat-agent]: https://github.com/user-attachments/assets/b3ab6e35-4fbc-468d-af10-e3e0c687350f
[image-feat-artifacts]: https://github.com/user-attachments/assets/7f95fad6-b210-4e6e-84a0-7f39e96f3a00
[image-feat-auth]: https://github.com/user-attachments/assets/80bb232e-19d1-4f97-98d6-e291f3585e6d
[image-feat-branch]: https://github.com/user-attachments/assets/92f72082-02bd-4835-9c54-b089aad7fd41
[image-feat-cot]: https://github.com/user-attachments/assets/f74f1139-d115-4e9c-8c43-040a53797a5e
[image-feat-database]: https://github.com/user-attachments/assets/f1697c8b-d1fb-4dac-ba05-153c6295d91d
[image-feat-desktop]: https://github.com/user-attachments/assets/a7bac8d3-ea96-4000-bb39-fadc9b610f96
[image-feat-knowledgebase]: https://github.com/user-attachments/assets/7da7a3b2-92fd-4630-9f4e-8560c74955ae
[image-feat-local]: https://github.com/user-attachments/assets/1239da50-d832-4632-a7ef-bd754c0f3850
[image-feat-mcp-market]: https://github.com/user-attachments/assets/bb114f9f-24c5-4000-a984-c10d187da5a0
[image-feat-mobile]: https://github.com/user-attachments/assets/32cf43c4-96bd-4a4c-bfb6-59acde6fe380
[image-feat-plugin]: https://github.com/user-attachments/assets/66a891ac-01b6-4e3f-b978-2eb07b489b1b
[image-feat-privoder]: https://github.com/user-attachments/assets/e553e407-42de-4919-977d-7dbfcf44a821
[image-feat-pwa]: https://github.com/user-attachments/assets/9647f70f-b71b-43b6-9564-7cdd12d1c24d
[image-feat-t2i]: https://github.com/user-attachments/assets/708274a7-2458-494b-a6ec-b73dfa1fa7c2
[image-feat-theme]: https://github.com/user-attachments/assets/b47c39f1-806f-492b-8fcb-b0fa973937c1
[image-feat-tts]: https://github.com/user-attachments/assets/50189597-2cc3-4002-b4c8-756a52ad5c0a
[image-feat-vision]: https://github.com/user-attachments/assets/18574a1f-46c2-4cbc-af2c-35a86e128a07
[image-feat-web-search]: https://github.com/user-attachments/assets/cfdc48ac-b5f8-4a00-acee-db8f2eba09ad
[github-stars-shield]: https://img.shields.io/github/stars/lobehub/lobehub?color=ffcb47&labelColor=black&style=flat-square
[image-banner]: https://github.com/user-attachments/assets/5f78ae58-ed4f-4d38-8037-96109fbba58c
[image-star]: https://github.com/user-attachments/assets/c3b482e7-cef5-4e94-bef9-226900ecfaab
[issues-link]: https://img.shields.io/github/issues/lobehub/lobehub.svg?style=flat
[lobe-chat-plugins]: https://github.com/lobehub/lobe-chat-plugins
@@ -969,8 +581,6 @@ This project is [LobeHub Community License](./LICENSE) licensed.
[share-whatsapp-shield]: https://img.shields.io/badge/-share%20on%20whatsapp-black?labelColor=black&logo=whatsapp&logoColor=white&style=flat-square
[share-x-link]: https://x.com/intent/tweet?hashtags=chatbot%2CchatGPT%2CopenAI&text=%E6%8E%A8%E8%8D%90%E4%B8%80%E4%B8%AA%20GitHub%20%E5%BC%80%E6%BA%90%E9%A1%B9%E7%9B%AE%20%F0%9F%A4%AF%20LobeHub%20-%20%E5%BC%80%E6%BA%90%E7%9A%84%E3%80%81%E5%8F%AF%E6%89%A9%E5%B1%95%E7%9A%84%EF%BC%88Function%20Calling%EF%BC%89%E9%AB%98%E6%80%A7%E8%83%BD%E8%81%8A%E5%A4%A9%E6%9C%BA%E5%99%A8%E4%BA%BA%E6%A1%86%E6%9E%B6%E3%80%82%0A%E5%AE%83%E6%94%AF%E6%8C%81%E4%B8%80%E9%94%AE%E5%85%8D%E8%B4%B9%E9%83%A8%E7%BD%B2%E7%A7%81%E4%BA%BA%20ChatGPT%2FLLM%20%E7%BD%91%E9%A1%B5%E5%BA%94%E7%94%A8%E7%A8%8B%E5%BA%8F&url=https%3A%2F%2Fgithub.com%2Flobehub%2Flobehub
[share-x-shield]: https://img.shields.io/badge/-share%20on%20x-black?labelColor=black&logo=x&logoColor=white&style=flat-square
[sponsor-link]: https://opencollective.com/lobehub 'Become ❤ LobeHub Sponsor'
[sponsor-shield]: https://img.shields.io/badge/-Sponsor%20LobeHub-f04f88?logo=opencollective&logoColor=white&style=flat-square
[submit-agents-link]: https://github.com/lobehub/lobe-chat-agents
[submit-agents-shield]: https://img.shields.io/badge/🤖/🏪_submit_agent-%E2%86%92-c4f042?labelColor=black&style=for-the-badge
[submit-plugin-link]: https://github.com/lobehub/lobe-chat-plugins
+1 -4
View File
@@ -1,6 +1,6 @@
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
.\" Manual command details come from the Commander command tree.
.TH LH 1 "" "@lobehub/cli 0.0.14" "User Commands"
.TH LH 1 "" "@lobehub/cli 0.0.21" "User Commands"
.SH NAME
lh \- LobeHub CLI \- manage and connect to LobeHub services
.SH SYNOPSIS
@@ -68,9 +68,6 @@ Manage agent groups
.B bot
Manage bot integrations
.TP
.B cron
Manage agent cron jobs
.TP
.B generate
Generate content (text, image, video, speech) Alias: gen.
.TP
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lobehub/cli",
"version": "0.0.14",
"version": "0.0.21",
"type": "module",
"bin": {
"lh": "./dist/index.js",
+1 -1
View File
@@ -318,7 +318,7 @@ export function registerAgentCommand(program: Command) {
}
// 1. Exec agent to get operationId
const input: Record<string, any> = { prompt: options.prompt };
const input: Record<string, any> = { prompt: options.prompt, trigger: 'cli' };
if (options.agentId) input.agentId = options.agentId;
if (deviceId) input.deviceId = deviceId;
if (options.slug) input.slug = options.slug;
+206
View File
@@ -6,6 +6,7 @@ import { getTrpcClient } from '../api/client';
import { confirm, outputJson, printBoxTable, printTable, timeAgo } from '../utils/format';
import { log } from '../utils/logger';
import { registerBotMessageCommands } from './botMessage';
import { registerBotMessengersCommands } from './botMessengers';
// ── Access policy helpers ──────────────────────────────
@@ -269,6 +270,204 @@ function registerAllowlistCommand(bot: Command, opts: AllowlistGroupOptions) {
});
}
// ── Watch keywords subcommand factory ──────────────────
interface WatchKeywordEntry {
instruction?: string;
keyword: string;
}
/**
* Normalise `settings.watchKeywords` into the canonical
* `{keyword, instruction?}[]` shape. Mirrors `extractWatchKeywordEntries`
* in `src/server/services/bot/platforms/const.ts` so the CLI accepts the
* same legacy on-disk shapes (`string`, `string[]`, `{keyword, …}[]`)
* the runtime is forgiving about including the rare comma/whitespace
* separated string from a hand-pasted upgrade.
*/
function normalizeWatchKeywords(raw: unknown): WatchKeywordEntry[] {
const push = (out: Map<string, WatchKeywordEntry>, keyword: unknown, instruction?: unknown) => {
if (typeof keyword !== 'string') return;
const normalised = keyword.trim().toLowerCase();
if (!normalised) return;
const trimmedInstruction =
typeof instruction === 'string' && instruction.trim() ? instruction.trim() : undefined;
const existing = out.get(normalised);
if (!existing) {
out.set(normalised, { instruction: trimmedInstruction, keyword: normalised });
return;
}
if (!existing.instruction && trimmedInstruction) existing.instruction = trimmedInstruction;
};
const collected = new Map<string, WatchKeywordEntry>();
if (typeof raw === 'string') {
for (const piece of raw.split(/[\s,]+/)) push(collected, piece);
} else if (Array.isArray(raw)) {
for (const entry of raw) {
if (typeof entry === 'string') {
push(collected, entry);
continue;
}
if (entry && typeof entry === 'object' && 'keyword' in entry) {
const obj = entry as { instruction?: unknown; keyword?: unknown };
push(collected, obj.keyword, obj.instruction);
}
}
}
return [...collected.values()];
}
/**
* Build a `list / add / remove / clear` subcommand group around
* `settings.watchKeywords`. Shape differs from the user/channel allowlists
* (`{keyword, instruction?}` vs `{id, name?}`), so we duplicate the
* scaffolding instead of squeezing both shapes through one factory the
* help text, column headers, and `--instruction` flag are all keyword-
* specific and would just bloat the unified version.
*/
function registerWatchKeywordsCommand(bot: Command) {
const group = bot
.command('watch-keywords')
.description(
'Manage watch keywords (non-mention channel triggers; the optional instruction is prepended to the user message before being sent to the AI)',
);
const readEntries = (bot: any): WatchKeywordEntry[] =>
normalizeWatchKeywords((bot.settings as Record<string, unknown> | null)?.watchKeywords);
const buildPayload = (bot: any, nextEntries: WatchKeywordEntry[]) => ({
id: bot.id,
settings: {
...(bot.settings as Record<string, unknown>),
watchKeywords: nextEntries,
},
});
group
.command('list <botId>')
.description('List watch-keyword entries')
.option('--json', 'Output JSON')
.action(async (botId: string, options: { json?: boolean }) => {
const client = await getTrpcClient();
const b = await findBot(client, botId);
const entries = readEntries(b);
if (options.json) {
outputJson(entries);
return;
}
if (entries.length === 0) {
console.log(`${pc.dim('No watch-keyword entries.')}`);
return;
}
printTable(
entries.map((e) => [e.keyword, e.instruction ?? pc.dim('-')]),
['KEYWORD', 'INSTRUCTION'],
);
});
group
.command('add <botId> <keyword>')
.description('Add a watch keyword (with optional instruction prefix)')
.option(
'--instruction <text>',
'Prompt prepended to the user message when this keyword fires (omit for "just wake the bot")',
)
.action(async (botId: string, keyword: string, options: { instruction?: string }) => {
const trimmedKeyword = keyword.trim().toLowerCase();
if (!trimmedKeyword) {
log.error('Keyword cannot be empty.');
process.exit(1);
return;
}
const trimmedInstruction = options.instruction?.trim();
const client = await getTrpcClient();
const b = await findBot(client, botId);
const entries = readEntries(b);
const existing = entries.find((e) => e.keyword === trimmedKeyword);
if (existing) {
// Upsert instruction on duplicate keyword — operators commonly
// re-run `add` to tweak the prompt without remembering to remove first.
if (trimmedInstruction && existing.instruction !== trimmedInstruction) {
existing.instruction = trimmedInstruction;
await client.agentBotProvider.update.mutate(buildPayload(b, entries) as any);
console.log(
`${pc.green('✓')} Updated instruction for ${pc.bold(trimmedKeyword)} (${entries.length} entr${entries.length === 1 ? 'y' : 'ies'})`,
);
return;
}
log.info(`${trimmedKeyword} is already on watchKeywords — nothing to do.`);
return;
}
const next = [
...entries,
trimmedInstruction
? { instruction: trimmedInstruction, keyword: trimmedKeyword }
: { keyword: trimmedKeyword },
];
await client.agentBotProvider.update.mutate(buildPayload(b, next) as any);
console.log(
`${pc.green('✓')} Added ${pc.bold(trimmedKeyword)}${trimmedInstruction ? ' (with instruction)' : ''} to watchKeywords (now ${next.length} entr${next.length === 1 ? 'y' : 'ies'})`,
);
});
group
.command('remove <botId> <keyword>')
.description('Remove a watch keyword')
.action(async (botId: string, keyword: string) => {
const trimmedKeyword = keyword.trim().toLowerCase();
const client = await getTrpcClient();
const b = await findBot(client, botId);
const entries = readEntries(b);
const next = entries.filter((e) => e.keyword !== trimmedKeyword);
if (next.length === entries.length) {
log.info(`${trimmedKeyword} is not on watchKeywords — nothing to do.`);
return;
}
await client.agentBotProvider.update.mutate(buildPayload(b, next) as any);
console.log(
`${pc.green('✓')} Removed ${pc.bold(trimmedKeyword)} from watchKeywords (${next.length} entr${next.length === 1 ? 'y' : 'ies'} left)`,
);
});
group
.command('clear <botId>')
.description('Clear all watch keywords')
.option('--yes', 'Skip confirmation prompt')
.action(async (botId: string, options: { yes?: boolean }) => {
const client = await getTrpcClient();
const b = await findBot(client, botId);
const entries = readEntries(b);
if (entries.length === 0) {
log.info('watchKeywords is already empty — nothing to do.');
return;
}
if (!options.yes) {
const confirmed = await confirm(
`Clear all ${entries.length} watch-keyword entr${entries.length === 1 ? 'y' : 'ies'} from this bot?`,
);
if (!confirmed) {
console.log('Cancelled.');
return;
}
}
await client.agentBotProvider.update.mutate(buildPayload(b, []) as any);
console.log(`${pc.green('✓')} Cleared watchKeywords on bot ${pc.bold(botId)}`);
});
}
// ── Command Registration ─────────────────────────────────
export function registerBotCommand(program: Command) {
@@ -277,6 +476,9 @@ export function registerBotCommand(program: Command) {
// Register message subcommand group
registerBotMessageCommands(bot);
// Register messengers subcommand group (System Bot installations + account links)
registerBotMessengersCommands(bot);
// ── platforms ───────────────────────────────────────────
bot
@@ -608,6 +810,10 @@ export function registerBotCommand(program: Command) {
name: 'group-allowlist',
});
// ── watch-keywords () ────────────────────────
registerWatchKeywordsCommand(bot);
// ── remove ────────────────────────────────────────────
bot
+417
View File
@@ -0,0 +1,417 @@
import { mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { registerBotMessageCommands } from './botMessage';
const { mockTrpcClient } = vi.hoisted(() => ({
mockTrpcClient: {
botMessage: {
replyToThread: { mutate: vi.fn() },
sendDirectMessage: { mutate: vi.fn() },
sendMessage: { mutate: vi.fn() },
},
},
}));
const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({
getTrpcClient: vi.fn(),
}));
vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient }));
vi.mock('../utils/logger', () => ({
log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() },
setVerbose: vi.fn(),
}));
describe('bot message send --attachment', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
mockTrpcClient.botMessage.sendMessage.mutate.mockReset();
mockTrpcClient.botMessage.sendMessage.mutate.mockResolvedValue({ messageId: 'm-1' });
mockTrpcClient.botMessage.sendDirectMessage.mutate.mockReset();
mockTrpcClient.botMessage.sendDirectMessage.mutate.mockResolvedValue({
channelId: 'dm-1',
messageId: 'm-dm-1',
});
mockTrpcClient.botMessage.replyToThread.mutate.mockReset();
mockTrpcClient.botMessage.replyToThread.mutate.mockResolvedValue({ messageId: 'm-tr-1' });
});
afterEach(() => {
exitSpy.mockRestore();
consoleSpy.mockRestore();
});
function createProgram() {
const program = new Command();
program.exitOverride();
const bot = program.command('bot');
registerBotMessageCommands(bot);
return program;
}
it('passes a remote URL through as fetchUrl', async () => {
const program = createProgram();
await program.parseAsync([
'node',
'test',
'bot',
'message',
'send',
'bot-1',
'--target',
'ch-1',
'--message',
'hi',
'--attachment',
'https://cdn.example.com/foo.png',
]);
expect(mockTrpcClient.botMessage.sendMessage.mutate).toHaveBeenCalledWith(
expect.objectContaining({
attachments: [
expect.objectContaining({
fetchUrl: 'https://cdn.example.com/foo.png',
mimeType: 'image/png',
name: 'foo.png',
type: 'image',
}),
],
botId: 'bot-1',
channelId: 'ch-1',
content: 'hi',
}),
);
});
it('base64-encodes a local file path', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'lh-cli-attach-'));
const filePath = path.join(dir, 'tiny.txt');
await writeFile(filePath, 'hello');
const program = createProgram();
await program.parseAsync([
'node',
'test',
'bot',
'message',
'send',
'bot-1',
'--target',
'ch-1',
'--message',
'm',
'--attachment',
filePath,
]);
const call = mockTrpcClient.botMessage.sendMessage.mutate.mock.calls[0][0];
expect(call.attachments).toHaveLength(1);
expect(call.attachments[0]).toMatchObject({
mimeType: 'text/plain',
name: 'tiny.txt',
type: 'file',
});
expect(call.attachments[0].data).toBe(Buffer.from('hello').toString('base64'));
expect(call.attachments[0].fetchUrl).toBeUndefined();
});
it('accepts multiple --attachment flags', async () => {
const program = createProgram();
await program.parseAsync([
'node',
'test',
'bot',
'message',
'send',
'bot-1',
'--target',
'ch-1',
'--message',
'm',
'--attachment',
'https://cdn.example.com/a.png',
'--attachment',
'https://cdn.example.com/b.pdf',
]);
const call = mockTrpcClient.botMessage.sendMessage.mutate.mock.calls[0][0];
expect(call.attachments).toHaveLength(2);
expect(call.attachments[0]).toMatchObject({ type: 'image', name: 'a.png' });
expect(call.attachments[1]).toMatchObject({ type: 'file', name: 'b.pdf' });
});
it('omits attachments field when no flag is given', async () => {
const program = createProgram();
await program.parseAsync([
'node',
'test',
'bot',
'message',
'send',
'bot-1',
'--target',
'ch-1',
'--message',
'm',
]);
const call = mockTrpcClient.botMessage.sendMessage.mutate.mock.calls[0][0];
expect(call.attachments).toBeUndefined();
});
});
describe('bot message dm --attachment', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
mockTrpcClient.botMessage.sendDirectMessage.mutate.mockReset();
mockTrpcClient.botMessage.sendDirectMessage.mutate.mockResolvedValue({
channelId: 'dm-1',
messageId: 'm-dm-1',
});
});
afterEach(() => {
exitSpy.mockRestore();
consoleSpy.mockRestore();
});
function createProgram() {
const program = new Command();
program.exitOverride();
const bot = program.command('bot');
registerBotMessageCommands(bot);
return program;
}
it('sends a DM with a remote-URL attachment', async () => {
const program = createProgram();
await program.parseAsync([
'node',
'test',
'bot',
'message',
'dm',
'bot-1',
'--user-id',
'u-1',
'--message',
'hi',
'--attachment',
'https://cdn.example.com/foo.png',
]);
expect(mockTrpcClient.botMessage.sendDirectMessage.mutate).toHaveBeenCalledWith(
expect.objectContaining({
attachments: [
expect.objectContaining({
fetchUrl: 'https://cdn.example.com/foo.png',
type: 'image',
}),
],
botId: 'bot-1',
content: 'hi',
userId: 'u-1',
}),
);
});
it('omits attachments when no flag is given', async () => {
const program = createProgram();
await program.parseAsync([
'node',
'test',
'bot',
'message',
'dm',
'bot-1',
'--user-id',
'u-1',
'--message',
'plain',
]);
const call = mockTrpcClient.botMessage.sendDirectMessage.mutate.mock.calls[0][0];
expect(call.attachments).toBeUndefined();
});
});
describe('bot message thread reply --attachment', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
mockTrpcClient.botMessage.replyToThread.mutate.mockReset();
mockTrpcClient.botMessage.replyToThread.mutate.mockResolvedValue({ messageId: 'm-tr-1' });
});
afterEach(() => {
exitSpy.mockRestore();
consoleSpy.mockRestore();
});
function createProgram() {
const program = new Command();
program.exitOverride();
const bot = program.command('bot');
registerBotMessageCommands(bot);
return program;
}
it('replies to a thread with attachments', async () => {
const program = createProgram();
await program.parseAsync([
'node',
'test',
'bot',
'message',
'thread',
'reply',
'bot-1',
'--thread-id',
'th-1',
'--message',
'reply',
'--attachment',
'https://cdn.example.com/a.png',
]);
expect(mockTrpcClient.botMessage.replyToThread.mutate).toHaveBeenCalledWith(
expect.objectContaining({
attachments: [
expect.objectContaining({
fetchUrl: 'https://cdn.example.com/a.png',
type: 'image',
}),
],
botId: 'bot-1',
content: 'reply',
threadId: 'th-1',
}),
);
});
});
describe('bot message send via System Bot messenger install (@id)', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
mockTrpcClient.botMessage.sendMessage.mutate.mockReset();
mockTrpcClient.botMessage.sendMessage.mutate.mockResolvedValue({ messageId: 'm-mi-1' });
mockTrpcClient.botMessage.sendDirectMessage.mutate.mockReset();
mockTrpcClient.botMessage.sendDirectMessage.mutate.mockResolvedValue({ messageId: 'm-mi-2' });
mockTrpcClient.botMessage.replyToThread.mutate.mockReset();
mockTrpcClient.botMessage.replyToThread.mutate.mockResolvedValue({ messageId: 'm-mi-3' });
});
afterEach(() => {
exitSpy.mockRestore();
consoleSpy.mockRestore();
});
function createProgram() {
const program = new Command();
program.exitOverride();
const bot = program.command('bot');
registerBotMessageCommands(bot);
return program;
}
it('@-prefixed positional arg routes to messengerInstallationId on send', async () => {
const program = createProgram();
await program.parseAsync([
'node',
'test',
'bot',
'message',
'send',
'@inst_abc',
'--target',
'C1',
'--message',
'hi',
]);
const call = mockTrpcClient.botMessage.sendMessage.mutate.mock.calls[0][0];
expect(call.messengerInstallationId).toBe('inst_abc');
expect(call.botId).toBeUndefined();
expect(call.channelId).toBe('C1');
});
it('@-prefixed routes on dm', async () => {
const program = createProgram();
await program.parseAsync([
'node',
'test',
'bot',
'message',
'dm',
'@inst_xyz',
'--user-id',
'U1',
'--message',
'hi',
]);
const call = mockTrpcClient.botMessage.sendDirectMessage.mutate.mock.calls[0][0];
expect(call.messengerInstallationId).toBe('inst_xyz');
expect(call.botId).toBeUndefined();
});
it('@-prefixed routes on thread reply', async () => {
const program = createProgram();
await program.parseAsync([
'node',
'test',
'bot',
'message',
'thread',
'reply',
'@inst_thr',
'--thread-id',
'T1',
'--message',
'r',
]);
const call = mockTrpcClient.botMessage.replyToThread.mutate.mock.calls[0][0];
expect(call.messengerInstallationId).toBe('inst_thr');
});
it('plain (non-@) positional stays as botId', async () => {
const program = createProgram();
await program.parseAsync([
'node',
'test',
'bot',
'message',
'send',
'uuid-bot-id',
'--target',
'C1',
'--message',
'hi',
]);
const call = mockTrpcClient.botMessage.sendMessage.mutate.mock.calls[0][0];
expect(call.botId).toBe('uuid-bot-id');
expect(call.messengerInstallationId).toBeUndefined();
});
});
+216 -18
View File
@@ -1,3 +1,6 @@
import { readFile } from 'node:fs/promises';
import { basename, extname } from 'node:path';
import { DEFAULT_BOT_HISTORY_LIMIT } from '@lobechat/const';
import type { Command } from 'commander';
import pc from 'picocolors';
@@ -6,6 +9,111 @@ import { getTrpcClient } from '../api/client';
import { confirm, outputJson, printTable, truncate } from '../utils/format';
import { log } from '../utils/logger';
type AttachmentInput = {
data?: string;
fetchUrl?: string;
mimeType?: string;
name?: string;
type: 'image' | 'file' | 'video' | 'audio';
};
const MIME_EXT_MAP: Record<string, string> = {
'.bmp': 'image/bmp',
'.gif': 'image/gif',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.m4a': 'audio/mp4',
'.mp3': 'audio/mpeg',
'.mp4': 'video/mp4',
'.ogg': 'audio/ogg',
'.pdf': 'application/pdf',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.txt': 'text/plain',
'.wav': 'audio/wav',
'.webm': 'video/webm',
'.webp': 'image/webp',
};
const inferMime = (path: string): string | undefined => MIME_EXT_MAP[extname(path).toLowerCase()];
const inferAttachmentType = (mimeType?: string): AttachmentInput['type'] => {
if (!mimeType) return 'file';
if (mimeType.startsWith('image/')) return 'image';
if (mimeType.startsWith('video/')) return 'video';
if (mimeType.startsWith('audio/')) return 'audio';
return 'file';
};
/**
* Resolve a list of `--attachment` flag values into `AttachmentInput[]`. Each
* entry is either a URL or a local file path. Returns `undefined` when no
* flags were passed so callers can omit the field on the wire entirely (the
* TRPC schema treats absent vs empty differently). Bails the process on
* load failures a silently-dropped attachment would be worse than a
* loud error here.
*/
const resolveAttachmentFlags = async (flags: string[]): Promise<AttachmentInput[] | undefined> => {
if (flags.length === 0) return undefined;
const out: AttachmentInput[] = [];
for (const raw of flags) {
try {
out.push(await parseAttachmentArg(raw));
} catch (error) {
log.error(`Failed to load attachment "${raw}": ${(error as Error).message}`);
process.exit(1);
}
}
return out;
};
/**
* Parse a single `--attachment <value>` argument. Accepted forms:
* - `https://…` / `http://…` fetchUrl, type inferred from extension
* - any other string treated as a local file path;
* bytes are read + base64-encoded
*/
const parseAttachmentArg = async (raw: string): Promise<AttachmentInput> => {
if (/^https?:\/\//.test(raw)) {
const pathname = new URL(raw).pathname;
const mimeType = inferMime(pathname);
return {
fetchUrl: raw,
mimeType,
name: basename(pathname) || undefined,
type: inferAttachmentType(mimeType),
};
}
const bytes = await readFile(raw);
const mimeType = inferMime(raw);
return {
data: bytes.toString('base64'),
mimeType,
name: basename(raw),
type: inferAttachmentType(mimeType),
};
};
/**
* Resolve the `<botIdOrAtKey>` positional argument into a `{ botId? |
* messengerInstallationId? }` shape that matches the TRPC send procedures'
* `exactly-one-of` constraint.
*
* Convention: a value prefixed with `@` is treated as a System Bot
* messenger installation id (e.g. `@inst_abc123`); anything else is a
* per-agent bot id. The `@` was chosen because `agent_bot_providers`.id is
* always a UUID no UUID starts with `@`, so the prefix unambiguously
* disambiguates without breaking the existing UUID-only call sites.
*/
const resolveSendTargetArg = (
value: string,
): { botId?: string; messengerInstallationId?: string } => {
if (value.startsWith('@')) {
return { messengerInstallationId: value.slice(1) };
}
return { botId: value };
};
export function registerBotMessageCommands(bot: Command) {
const message = bot
.command('message')
@@ -14,20 +122,40 @@ export function registerBotMessageCommands(bot: Command) {
// ── send ────────────────────────────────────────────────
message
.command('send <botId>')
.description('Send a message to a channel')
.command('send <botIdOrAtKey>')
.description(
'Send a message to a channel. Pass a per-agent bot id, or "@<messenger-install-id>" ' +
'to send through a System Bot messenger installation (see `lh bot messengers list`).',
)
.requiredOption('--target <channelId>', 'Target channel / conversation ID')
.requiredOption('--message <text>', 'Message content')
.option(
'--attachment <pathOrUrl>',
'Attach a file by local path or remote URL (repeatable). ' +
'Local paths are base64-encoded; http(s) URLs are passed as fetchUrl.',
collectOptions,
[],
)
.option('--reply-to <messageId>', 'Reply to a specific message')
.option('--json', 'Output JSON')
.action(
async (
botId: string,
options: { json?: boolean; message: string; replyTo?: string; target: string },
botIdOrAtKey: string,
options: {
attachment: string[];
json?: boolean;
message: string;
replyTo?: string;
target: string;
},
) => {
const attachments = await resolveAttachmentFlags(options.attachment);
const target = resolveSendTargetArg(botIdOrAtKey);
const client = await getTrpcClient();
const result = await client.botMessage.sendMessage.mutate({
botId,
...target,
attachments,
channelId: options.target,
content: options.message,
replyTo: options.replyTo,
@@ -39,8 +167,56 @@ export function registerBotMessageCommands(bot: Command) {
}
const r = result as any;
const suffix = attachments?.length ? ` with ${attachments.length} attachment(s)` : '';
console.log(
`${pc.green('✓')} Message sent${r.messageId ? ` (${pc.dim(r.messageId)})` : ''}`,
`${pc.green('✓')} Message sent${r.messageId ? ` (${pc.dim(r.messageId)})` : ''}${suffix}`,
);
},
);
// ── dm (direct message) ─────────────────────────────────
message
.command('dm <botIdOrAtKey>')
.description(
'Send a direct message to a platform user. Pass a per-agent bot id, or ' +
'"@<messenger-install-id>" for a System Bot install.',
)
.requiredOption('--user-id <id>', 'Target user ID on the platform')
.requiredOption('--message <text>', 'Message content')
.option(
'--attachment <pathOrUrl>',
'Attach a file by local path or remote URL (repeatable). ' +
'Local paths are base64-encoded; http(s) URLs are passed as fetchUrl.',
collectOptions,
[],
)
.option('--json', 'Output JSON')
.action(
async (
botIdOrAtKey: string,
options: { attachment: string[]; json?: boolean; message: string; userId: string },
) => {
const attachments = await resolveAttachmentFlags(options.attachment);
const target = resolveSendTargetArg(botIdOrAtKey);
const client = await getTrpcClient();
const result = await client.botMessage.sendDirectMessage.mutate({
...target,
attachments,
content: options.message,
userId: options.userId,
});
if (options.json) {
outputJson(result);
return;
}
const r = result as any;
const suffix = attachments?.length ? ` with ${attachments.length} attachment(s)` : '';
console.log(
`${pc.green('✓')} DM sent${r.messageId ? ` (${pc.dim(r.messageId)})` : ''}${suffix}`,
);
},
);
@@ -450,21 +626,43 @@ export function registerBotMessageCommands(bot: Command) {
});
thread
.command('reply <botId>')
.description('Reply to a thread')
.command('reply <botIdOrAtKey>')
.description(
'Reply to a thread. Pass a per-agent bot id, or "@<messenger-install-id>" ' +
'for a System Bot install.',
)
.requiredOption('--thread-id <id>', 'Thread ID')
.requiredOption('--message <text>', 'Reply content')
.action(async (botId: string, options: { message: string; threadId: string }) => {
const client = await getTrpcClient();
const result = await client.botMessage.replyToThread.mutate({
botId,
content: options.message,
threadId: options.threadId,
});
.option(
'--attachment <pathOrUrl>',
'Attach a file by local path or remote URL (repeatable). ' +
'Local paths are base64-encoded; http(s) URLs are passed as fetchUrl.',
collectOptions,
[],
)
.action(
async (
botIdOrAtKey: string,
options: { attachment: string[]; message: string; threadId: string },
) => {
const attachments = await resolveAttachmentFlags(options.attachment);
const target = resolveSendTargetArg(botIdOrAtKey);
const r = result as any;
console.log(`${pc.green('✓')} Reply sent${r.messageId ? ` (${pc.dim(r.messageId)})` : ''}`);
});
const client = await getTrpcClient();
const result = await client.botMessage.replyToThread.mutate({
...target,
attachments,
content: options.message,
threadId: options.threadId,
});
const r = result as any;
const suffix = attachments?.length ? ` with ${attachments.length} attachment(s)` : '';
console.log(
`${pc.green('✓')} Reply sent${r.messageId ? ` (${pc.dim(r.messageId)})` : ''}${suffix}`,
);
},
);
// ── channel (subcommand group) ──────────────────────────
+365
View File
@@ -0,0 +1,365 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type * as FormatModule from '../utils/format';
import { registerBotMessengersCommands } from './botMessengers';
const { mockTrpcClient } = vi.hoisted(() => ({
mockTrpcClient: {
messenger: {
availablePlatforms: { query: vi.fn() },
getMyLink: { query: vi.fn() },
listMyInstallations: { query: vi.fn() },
listMyLinks: { query: vi.fn() },
setActiveAgent: { mutate: vi.fn() },
unlink: { mutate: vi.fn() },
uninstallInstallation: { mutate: vi.fn() },
},
},
}));
const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({
getTrpcClient: vi.fn(),
}));
vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient }));
vi.mock('../utils/logger', () => ({
log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() },
setVerbose: vi.fn(),
}));
// `confirm` always answers yes — we test uninstall/unlink under the explicit
// `--yes` flag too, but for the prompt path we want a deterministic answer.
vi.mock('../utils/format', async () => {
const actual = await vi.importActual<typeof FormatModule>('../utils/format');
return { ...actual, confirm: vi.fn(async () => true) };
});
describe('bot messengers', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
let consoleSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
for (const fn of [
mockTrpcClient.messenger.availablePlatforms.query,
mockTrpcClient.messenger.getMyLink.query,
mockTrpcClient.messenger.listMyInstallations.query,
mockTrpcClient.messenger.listMyLinks.query,
mockTrpcClient.messenger.setActiveAgent.mutate,
mockTrpcClient.messenger.unlink.mutate,
mockTrpcClient.messenger.uninstallInstallation.mutate,
]) {
fn.mockReset();
}
});
afterEach(() => {
exitSpy.mockRestore();
consoleSpy.mockRestore();
errorSpy.mockRestore();
});
function createProgram() {
const program = new Command();
program.exitOverride();
const bot = program.command('bot');
registerBotMessengersCommands(bot);
return program;
}
function renderedOutput(): string {
return consoleSpy.mock.calls.map((c) => String(c[0])).join('\n');
}
// ── installations ──────────────────────────────────────
describe('list', () => {
it('renders the installation table with SEND ARG hint', async () => {
mockTrpcClient.messenger.listMyInstallations.query.mockResolvedValueOnce([
{
applicationId: 'A1',
id: 'inst_abc',
installedAt: '2026-01-15T00:00:00Z',
platform: 'slack',
tenantId: 'T1',
tenantName: 'Acme Corp',
},
]);
await createProgram().parseAsync(['node', 'test', 'bot', 'messengers', 'list']);
const out = renderedOutput();
expect(mockTrpcClient.messenger.listMyInstallations.query).toHaveBeenCalled();
expect(out).toContain('inst_abc');
expect(out).toContain('Acme Corp');
// The hint should explain how to use the id with the send commands
expect(out).toContain('@<INSTALLATION ID>');
});
it('reports empty state with install guidance', async () => {
mockTrpcClient.messenger.listMyInstallations.query.mockResolvedValueOnce([]);
await createProgram().parseAsync(['node', 'test', 'bot', 'messengers', 'list']);
const out = renderedOutput();
expect(out).toContain('No System Bot installations connected.');
expect(out).toContain('Settings → Messenger');
});
it('--json passes through the payload', async () => {
const payload = [{ id: 'inst_only', platform: 'discord', tenantId: 'g1' }];
mockTrpcClient.messenger.listMyInstallations.query.mockResolvedValueOnce(payload);
await createProgram().parseAsync(['node', 'test', 'bot', 'messengers', 'list', '--json']);
expect(renderedOutput()).toContain('"id": "inst_only"');
});
});
describe('view', () => {
it('prints details for a matching install', async () => {
mockTrpcClient.messenger.listMyInstallations.query.mockResolvedValueOnce([
{
applicationId: 'A1',
id: 'inst_match',
installedAt: '2026-01-15T00:00:00Z',
platform: 'slack',
scope: 'chat:write,users:read',
tenantId: 'T1',
tenantName: 'Acme Corp',
},
]);
await createProgram().parseAsync(['node', 'test', 'bot', 'messengers', 'view', 'inst_match']);
const out = renderedOutput();
expect(out).toContain('inst_match');
expect(out).toContain('slack');
expect(out).toContain('Acme Corp');
expect(out).toContain('chat:write,users:read');
});
it('exits non-zero when install missing', async () => {
mockTrpcClient.messenger.listMyInstallations.query.mockResolvedValueOnce([]);
await createProgram().parseAsync([
'node',
'test',
'bot',
'messengers',
'view',
'inst_missing',
]);
expect(errorSpy).toHaveBeenCalled();
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('--json missing install emits JSON null + exit 1 (scriptable)', async () => {
mockTrpcClient.messenger.listMyInstallations.query.mockResolvedValueOnce([]);
await createProgram().parseAsync([
'node',
'test',
'bot',
'messengers',
'view',
'inst_missing',
'--json',
]);
// No human-readable error log; the JSON-pipe consumer gets `null`.
expect(errorSpy).not.toHaveBeenCalled();
expect(consoleSpy.mock.calls.map((c) => String(c[0])).join('\n')).toContain('null');
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe('uninstall', () => {
it('--yes skips confirm and calls the mutation', async () => {
mockTrpcClient.messenger.uninstallInstallation.mutate.mockResolvedValueOnce({
success: true,
});
await createProgram().parseAsync([
'node',
'test',
'bot',
'messengers',
'uninstall',
'inst_abc',
'--yes',
]);
expect(mockTrpcClient.messenger.uninstallInstallation.mutate).toHaveBeenCalledWith({
installationId: 'inst_abc',
});
expect(renderedOutput()).toContain('revoked');
});
it('confirms before calling when --yes is omitted', async () => {
mockTrpcClient.messenger.listMyInstallations.query.mockResolvedValueOnce([
{ id: 'inst_abc', platform: 'slack', tenantId: 'T1', tenantName: 'Acme' },
]);
mockTrpcClient.messenger.uninstallInstallation.mutate.mockResolvedValueOnce({
success: true,
});
await createProgram().parseAsync([
'node',
'test',
'bot',
'messengers',
'uninstall',
'inst_abc',
]);
// Mocked confirm returns true → mutation still fires
expect(mockTrpcClient.messenger.uninstallInstallation.mutate).toHaveBeenCalled();
});
});
describe('platforms', () => {
it('renders the platforms table', async () => {
mockTrpcClient.messenger.availablePlatforms.query.mockResolvedValueOnce([
{ appId: 'A123', id: 'slack', name: 'Slack' },
{ botUsername: 'lobehub_bot', id: 'telegram', name: 'Telegram' },
]);
await createProgram().parseAsync(['node', 'test', 'bot', 'messengers', 'platforms']);
const out = renderedOutput();
expect(out).toContain('slack');
expect(out).toContain('A123');
expect(out).toContain('lobehub_bot');
});
it('handles empty platform list gracefully', async () => {
mockTrpcClient.messenger.availablePlatforms.query.mockResolvedValueOnce([]);
await createProgram().parseAsync(['node', 'test', 'bot', 'messengers', 'platforms']);
expect(renderedOutput()).toContain('No System Bot platforms');
});
});
// ── links ──────────────────────────────────────────────
describe('links list', () => {
it('renders the links table', async () => {
mockTrpcClient.messenger.listMyLinks.query.mockResolvedValueOnce([
{
activeAgentId: 'agent_1',
platform: 'slack',
platformUserId: 'U1',
platformUsername: 'alice',
tenantId: 'T1',
},
]);
await createProgram().parseAsync(['node', 'test', 'bot', 'messengers', 'links', 'list']);
const out = renderedOutput();
expect(out).toContain('agent_1');
expect(out).toContain('alice');
});
it('reports empty state', async () => {
mockTrpcClient.messenger.listMyLinks.query.mockResolvedValueOnce([]);
await createProgram().parseAsync(['node', 'test', 'bot', 'messengers', 'links', 'list']);
expect(renderedOutput()).toContain('No account links yet');
});
});
describe('links view', () => {
it('shows the link detail', async () => {
mockTrpcClient.messenger.getMyLink.query.mockResolvedValueOnce({
activeAgentId: 'agent_2',
platform: 'slack',
platformUserId: 'U2',
platformUsername: 'bob',
tenantId: 'T2',
});
await createProgram().parseAsync([
'node',
'test',
'bot',
'messengers',
'links',
'view',
'slack',
'--tenant',
'T2',
]);
expect(mockTrpcClient.messenger.getMyLink.query).toHaveBeenCalledWith({
platform: 'slack',
tenantId: 'T2',
});
expect(renderedOutput()).toContain('agent_2');
});
it('exits non-zero on missing link', async () => {
mockTrpcClient.messenger.getMyLink.query.mockResolvedValueOnce(null);
await createProgram().parseAsync([
'node',
'test',
'bot',
'messengers',
'links',
'view',
'discord',
]);
expect(errorSpy).toHaveBeenCalled();
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe('links set-agent', () => {
it('passes agentId through to setActiveAgent', async () => {
mockTrpcClient.messenger.setActiveAgent.mutate.mockResolvedValueOnce({ success: true });
await createProgram().parseAsync([
'node',
'test',
'bot',
'messengers',
'links',
'set-agent',
'slack',
'--agent',
'agent_xyz',
'--tenant',
'T1',
]);
expect(mockTrpcClient.messenger.setActiveAgent.mutate).toHaveBeenCalledWith({
agentId: 'agent_xyz',
platform: 'slack',
tenantId: 'T1',
});
});
it('clears the agent when --agent none is passed', async () => {
mockTrpcClient.messenger.setActiveAgent.mutate.mockResolvedValueOnce({ success: true });
await createProgram().parseAsync([
'node',
'test',
'bot',
'messengers',
'links',
'set-agent',
'telegram',
'--agent',
'none',
]);
expect(mockTrpcClient.messenger.setActiveAgent.mutate).toHaveBeenCalledWith({
agentId: null,
platform: 'telegram',
tenantId: undefined,
});
});
});
describe('links unlink', () => {
it('passes platform + tenant to the unlink mutation with --yes', async () => {
mockTrpcClient.messenger.unlink.mutate.mockResolvedValueOnce({ success: true });
await createProgram().parseAsync([
'node',
'test',
'bot',
'messengers',
'links',
'unlink',
'slack',
'--tenant',
'T1',
'--yes',
]);
expect(mockTrpcClient.messenger.unlink.mutate).toHaveBeenCalledWith({
platform: 'slack',
tenantId: 'T1',
});
});
});
});
+305
View File
@@ -0,0 +1,305 @@
/**
* `lh bot messengers ...` manages the user's System Bot installations
* (Slack workspaces, Discord guilds, Telegram), distinct from per-agent bots.
*
* Mirrors `bot ...` (per-agent CRUD) and `bot message ...` (send/read), but
* operates on `messenger_installations` (workspace-scoped) and
* `messenger_account_links` (per-user routing). Subcommands talk directly to
* `lambdaClient.messenger.*` there's no shared CLI-side service layer for
* this domain yet.
*
* **uninstall vs unlink** (recurring confusion surface in command help):
* - `uninstall <installationId>` revokes the install for the **whole
* workspace**. Other users in that workspace can no longer use the bot.
* - `links unlink <platform>` only removes the **current user's** account
* binding. Workspace stays installed; colleagues are unaffected.
*/
import type { Command } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../api/client';
import { confirm, outputJson, printTable } from '../utils/format';
const PLATFORMS = ['telegram', 'slack', 'discord'] as const;
type MessengerPlatform = (typeof PLATFORMS)[number];
const validatePlatform = (value: string): MessengerPlatform => {
if (!(PLATFORMS as readonly string[]).includes(value)) {
throw new Error(`Unknown messenger platform: ${value}. Valid values: ${PLATFORMS.join(', ')}.`);
}
return value as MessengerPlatform;
};
export function registerBotMessengersCommands(bot: Command) {
const messengers = bot
.command('messengers')
.description(
'Manage System Bot messenger installations (Slack workspaces, Discord guilds, Telegram) ' +
'and per-user account links',
);
// ── installations ──────────────────────────────────────
messengers
.command('list')
.description('List all System Bot installations the current user has connected.')
.option('--json', 'Output JSON')
.action(async (options: { json?: boolean }) => {
const client = await getTrpcClient();
const installations = await client.messenger.listMyInstallations.query();
if (options.json) {
outputJson(installations);
return;
}
if (installations.length === 0) {
console.log('No System Bot installations connected.');
console.log(
`\nRun ${pc.dim('lh bot messengers platforms')} to see what's available, then install via ` +
`${pc.dim('Settings → Messenger')} (OAuth requires a browser).`,
);
return;
}
const rows = installations.map((i: any) => [
i.id || '',
i.platform || '',
i.tenantName || i.tenantId || '(global)',
i.applicationId || '',
i.installedAt ? new Date(i.installedAt).toISOString().slice(0, 10) : '',
]);
printTable(rows, ['INSTALLATION ID', 'PLATFORM', 'TENANT', 'APP ID', 'INSTALLED']);
console.log(
`\nUse ${pc.dim('@<INSTALLATION ID>')} as the positional argument on ` +
`${pc.dim('lh bot message send/dm/thread reply')} to route through a System Bot install.`,
);
});
messengers
.command('view <installationId>')
.description('Show detail for one installation.')
.option('--json', 'Output JSON')
.action(async (installationId: string, options: { json?: boolean }) => {
const client = await getTrpcClient();
const installations = (await client.messenger.listMyInstallations.query()) as any[];
const install = installations.find((i) => i.id === installationId);
if (!install) {
// Under `--json`, scripts need a parseable output even on miss —
// emit `null` to stdout (rather than a human-readable error), then
// exit non-zero so error handling still works in pipelines.
if (options.json) {
outputJson(null);
} else {
console.error(pc.red(`Installation not found: ${installationId}`));
}
process.exit(1);
return;
}
if (options.json) {
outputJson(install);
return;
}
console.log(`${pc.bold('Installation')} ${pc.dim(install.id)}`);
console.log(` Platform: ${install.platform}`);
console.log(` Tenant: ${install.tenantName || install.tenantId || '(global)'}`);
if (install.tenantId && install.tenantName) {
console.log(` Tenant ID: ${install.tenantId}`);
}
console.log(` Application ID: ${install.applicationId}`);
if (install.scope) console.log(` OAuth Scope: ${install.scope}`);
if (install.installedAt) {
console.log(` Installed: ${new Date(install.installedAt).toISOString()}`);
}
if (install.enterpriseId) {
console.log(` Enterprise ID: ${install.enterpriseId}`);
}
if (install.isEnterpriseInstall) {
console.log(` Enterprise: yes`);
}
});
messengers
.command('uninstall <installationId>')
.description(
'Revoke a workspace install. AFFECTS EVERY USER IN THAT WORKSPACE — for Slack this freezes ' +
'the bot; for Discord it removes the audit entry (a guild admin must remove the bot ' +
'separately). To disconnect only your own account, use `bot messengers links unlink`.',
)
.option('--yes', 'Skip confirmation prompt')
.action(async (installationId: string, options: { yes?: boolean }) => {
const client = await getTrpcClient();
if (!options.yes) {
const installations = (await client.messenger.listMyInstallations.query()) as any[];
const install = installations.find((i) => i.id === installationId);
const label = install
? `${install.platform} (${install.tenantName || install.tenantId || 'global'})`
: installationId;
const ok = await confirm(
`${pc.yellow('⚠')} Uninstall ${pc.bold(label)} — this revokes the install for the whole workspace. Continue?`,
);
if (!ok) {
console.log('Aborted.');
return;
}
}
await client.messenger.uninstallInstallation.mutate({ installationId });
console.log(`${pc.green('✓')} Installation ${pc.dim(installationId)} revoked.`);
});
messengers
.command('platforms')
.description('List the platforms available for System Bot OAuth install.')
.option('--json', 'Output JSON')
.action(async (options: { json?: boolean }) => {
const client = await getTrpcClient();
const platforms = await client.messenger.availablePlatforms.query();
if (options.json) {
outputJson(platforms);
return;
}
if (platforms.length === 0) {
console.log('No System Bot platforms are configured on this deployment.');
return;
}
const rows = platforms.map((p: any) => [
p.id || '',
p.name || '',
p.appId || '',
p.botUsername || '',
]);
printTable(rows, ['ID', 'NAME', 'APP ID', 'BOT USERNAME']);
console.log(
`\nInstalls are initiated via ${pc.dim('Settings → Messenger')} in the web UI ` +
'(OAuth needs a browser).',
);
});
// ── account links ──────────────────────────────────────
const links = messengers
.command('links')
.description('Manage per-user account links — routing of inbound IM to your agents');
links
.command('list')
.description('List all your account links across platforms and tenants.')
.option('--json', 'Output JSON')
.action(async (options: { json?: boolean }) => {
const client = await getTrpcClient();
const linkRows = await client.messenger.listMyLinks.query();
if (options.json) {
outputJson(linkRows);
return;
}
if (linkRows.length === 0) {
console.log('No account links yet. Complete verify-im on a platform first.');
return;
}
const rows = linkRows.map((l: any) => [
l.platform || '',
l.tenantId || '(global)',
l.activeAgentId || pc.dim('(unset)'),
l.platformUsername || l.platformUserId || '',
]);
printTable(rows, ['PLATFORM', 'TENANT', 'ACTIVE AGENT', 'PLATFORM USER']);
});
links
.command('view <platform>')
.description('Show one account link.')
.option('--tenant <id>', 'Tenant scope (Slack workspace id). Omit for global-bot platforms.')
.option('--json', 'Output JSON')
.action(async (platform: string, options: { json?: boolean; tenant?: string }) => {
const client = await getTrpcClient();
const platformValidated = validatePlatform(platform);
const link = await client.messenger.getMyLink.query({
platform: platformValidated,
tenantId: options.tenant,
});
if (!link) {
console.error(
pc.red(
`No link found for ${platform}${options.tenant ? ` (tenant ${options.tenant})` : ''}`,
),
);
process.exit(1);
return;
}
if (options.json) {
outputJson(link);
return;
}
console.log(`${pc.bold('Link')} ${pc.dim(link.platform)}`);
if (link.tenantId) console.log(` Tenant ID: ${link.tenantId}`);
console.log(` Platform User ID: ${link.platformUserId}`);
if (link.platformUsername) {
console.log(` Platform User: ${link.platformUsername}`);
}
console.log(` Active Agent: ${link.activeAgentId ?? pc.dim('(unset)')}`);
});
links
.command('set-agent <platform>')
.description('Change which agent receives inbound IM on a platform link.')
.requiredOption('--agent <id>', 'Agent id to route to, or "none" to clear the active agent.')
.option('--tenant <id>', 'Tenant scope (Slack workspace id). Omit for global-bot platforms.')
.action(async (platform: string, options: { agent: string; tenant?: string }) => {
const client = await getTrpcClient();
const platformValidated = validatePlatform(platform);
const agentId = options.agent === 'none' ? null : options.agent;
await client.messenger.setActiveAgent.mutate({
agentId,
platform: platformValidated,
tenantId: options.tenant,
});
const scope = options.tenant ? ` (tenant ${options.tenant})` : '';
const target = agentId === null ? 'cleared' : `set to agent ${pc.dim(agentId)}`;
console.log(`${pc.green('✓')} Active agent for ${platform}${scope} ${target}.`);
});
links
.command('unlink <platform>')
.description(
'Remove your account link for a platform. Workspace install is unaffected — colleagues ' +
'can still use the bot.',
)
.option('--tenant <id>', 'Tenant scope (Slack workspace id). Omit for global-bot platforms.')
.option('--yes', 'Skip confirmation prompt')
.action(async (platform: string, options: { tenant?: string; yes?: boolean }) => {
const client = await getTrpcClient();
const platformValidated = validatePlatform(platform);
if (!options.yes) {
const ok = await confirm(
`Unlink your account from ${pc.bold(platform)}${options.tenant ? ` (tenant ${options.tenant})` : ''}?`,
);
if (!ok) {
console.log('Aborted.');
return;
}
}
await client.messenger.unlink.mutate({
platform: platformValidated,
tenantId: options.tenant,
});
console.log(`${pc.green('✓')} Unlinked.`);
});
}
+4 -5
View File
@@ -55,7 +55,7 @@ export function registerBriefCommand(program: Command) {
typeBadge(b.type, b.priority),
truncate(b.title, 40),
truncate(b.summary, 50),
b.taskId ? pc.dim(b.taskId) : b.cronJobId ? pc.dim(b.cronJobId) : '-',
b.taskId ? pc.dim(b.taskId) : '-',
b.resolvedAt ? pc.green('resolved') : b.readAt ? pc.dim('read') : 'new',
timeAgo(b.createdAt),
]);
@@ -102,7 +102,6 @@ export function registerBriefCommand(program: Command) {
console.log(`${pc.dim('Type:')} ${b.type} ${pc.dim('Created:')} ${timeAgo(b.createdAt)}`);
if (b.agentId) console.log(`${pc.dim('Agent:')} ${b.agentId}`);
if (b.taskId) console.log(`${pc.dim('Task:')} ${b.taskId}`);
if (b.cronJobId) console.log(`${pc.dim('CronJob:')} ${b.cronJobId}`);
if (b.topicId) console.log(`${pc.dim('Topic:')} ${b.topicId}`);
console.log(`\n${b.summary}`);
@@ -121,14 +120,14 @@ export function registerBriefCommand(program: Command) {
for (const a of actions) {
const cmd =
a.type === 'comment'
? `lh brief resolve ${b.id} --action ${a.key} -m "内容"`
? `lh brief resolve ${b.id} --action ${a.key} -m "message"`
: `lh brief resolve ${b.id} --action ${a.key}`;
console.log(` ${a.label} ${pc.dim(cmd)}`);
}
} else {
console.log(pc.dim('Actions:'));
console.log(pc.dim(` lh brief resolve ${b.id} # 确认通过`));
console.log(pc.dim(` lh brief resolve ${b.id} --reply "修改意见" # 反馈修改`));
console.log(pc.dim(` lh brief resolve ${b.id} # Approve`));
console.log(pc.dim(` lh brief resolve ${b.id} --reply "revision notes" # Request revision`));
}
} else if ((b as any).resolvedComment) {
console.log(`${pc.dim('Comment:')} ${(b as any).resolvedComment}`);
-172
View File
@@ -1,172 +0,0 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { registerCronCommand } from './cron';
const { mockTrpcClient } = vi.hoisted(() => ({
mockTrpcClient: {
agentCronJob: {
batchUpdateStatus: { mutate: vi.fn() },
create: { mutate: vi.fn() },
delete: { mutate: vi.fn() },
findById: { query: vi.fn() },
getStats: { query: vi.fn() },
list: { query: vi.fn() },
resetExecutions: { mutate: vi.fn() },
update: { mutate: vi.fn() },
},
},
}));
const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({
getTrpcClient: vi.fn(),
}));
vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient }));
vi.mock('../utils/logger', () => ({
log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() },
setVerbose: vi.fn(),
}));
describe('cron command', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
for (const method of Object.values(mockTrpcClient.agentCronJob)) {
for (const fn of Object.values(method)) {
(fn as ReturnType<typeof vi.fn>).mockReset();
}
}
});
afterEach(() => {
exitSpy.mockRestore();
consoleSpy.mockRestore();
});
function createProgram() {
const program = new Command();
program.exitOverride();
registerCronCommand(program);
return program;
}
describe('list', () => {
it('should list cron jobs', async () => {
mockTrpcClient.agentCronJob.list.query.mockResolvedValue({
data: [{ enabled: true, id: 'c1', name: 'Test Job', schedule: '* * * * *' }],
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'cron', 'list']);
expect(mockTrpcClient.agentCronJob.list.query).toHaveBeenCalled();
});
it('should filter by agent-id', async () => {
mockTrpcClient.agentCronJob.list.query.mockResolvedValue({ data: [] });
const program = createProgram();
await program.parseAsync(['node', 'test', 'cron', 'list', '--agent-id', 'a1']);
expect(mockTrpcClient.agentCronJob.list.query).toHaveBeenCalledWith(
expect.objectContaining({ agentId: 'a1' }),
);
});
});
describe('view', () => {
it('should view cron job details', async () => {
mockTrpcClient.agentCronJob.findById.query.mockResolvedValue({
data: { enabled: true, id: 'c1', name: 'Test', schedule: '* * * * *' },
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'cron', 'view', 'c1']);
expect(mockTrpcClient.agentCronJob.findById.query).toHaveBeenCalledWith({ id: 'c1' });
});
});
describe('create', () => {
it('should create a cron job', async () => {
mockTrpcClient.agentCronJob.create.mutate.mockResolvedValue({ data: { id: 'c1' } });
const program = createProgram();
await program.parseAsync([
'node',
'test',
'cron',
'create',
'--agent-id',
'a1',
'-s',
'* * * * *',
'-n',
'My Job',
]);
expect(mockTrpcClient.agentCronJob.create.mutate).toHaveBeenCalledWith(
expect.objectContaining({ agentId: 'a1', cronPattern: '* * * * *', name: 'My Job' }),
);
});
});
describe('delete', () => {
it('should delete a cron job', async () => {
mockTrpcClient.agentCronJob.delete.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'cron', 'delete', 'c1', '--yes']);
expect(mockTrpcClient.agentCronJob.delete.mutate).toHaveBeenCalledWith({ id: 'c1' });
});
});
describe('toggle', () => {
it('should batch enable cron jobs', async () => {
mockTrpcClient.agentCronJob.batchUpdateStatus.mutate.mockResolvedValue({
data: { updatedCount: 2 },
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'cron', 'toggle', 'c1', 'c2', '--enable']);
expect(mockTrpcClient.agentCronJob.batchUpdateStatus.mutate).toHaveBeenCalledWith({
enabled: true,
ids: ['c1', 'c2'],
});
});
});
describe('reset', () => {
it('should reset execution count', async () => {
mockTrpcClient.agentCronJob.resetExecutions.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync(['node', 'test', 'cron', 'reset', 'c1', '--max', '100']);
expect(mockTrpcClient.agentCronJob.resetExecutions.mutate).toHaveBeenCalledWith({
id: 'c1',
newMaxExecutions: 100,
});
});
});
describe('stats', () => {
it('should get stats', async () => {
mockTrpcClient.agentCronJob.getStats.query.mockResolvedValue({
data: { totalJobs: 5, totalExecutions: 100 },
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'cron', 'stats']);
expect(mockTrpcClient.agentCronJob.getStats.query).toHaveBeenCalled();
});
});
});
-271
View File
@@ -1,271 +0,0 @@
import type { Command } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../api/client';
import { confirm, outputJson, printTable, timeAgo, truncate } from '../utils/format';
import { log } from '../utils/logger';
export function registerCronCommand(program: Command) {
const cron = program.command('cron').description('Manage agent cron jobs');
// ── list ──────────────────────────────────────────────
cron
.command('list')
.description('List cron jobs')
.option('--agent-id <id>', 'Filter by agent ID')
.option('--enabled', 'Only show enabled jobs')
.option('--disabled', 'Only show disabled jobs')
.option('-L, --limit <n>', 'Page size', '20')
.option('--offset <n>', 'Offset', '0')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(
async (options: {
agentId?: string;
disabled?: boolean;
enabled?: boolean;
json?: string | boolean;
limit?: string;
offset?: string;
}) => {
const client = await getTrpcClient();
const input: Record<string, any> = {};
if (options.agentId) input.agentId = options.agentId;
if (options.enabled) input.enabled = true;
if (options.disabled) input.enabled = false;
if (options.limit) input.limit = Number.parseInt(options.limit, 10);
if (options.offset) input.offset = Number.parseInt(options.offset, 10);
const result = await client.agentCronJob.list.query(input as any);
const items = (result as any).data ?? [];
if (options.json !== undefined) {
const fields = typeof options.json === 'string' ? options.json : undefined;
outputJson(items, fields);
return;
}
if (items.length === 0) {
console.log('No cron jobs found.');
return;
}
const rows = items.map((j: any) => [
j.id || '',
truncate(j.name || '', 30),
j.schedule || '',
j.enabled ? pc.green('enabled') : pc.dim('disabled'),
`${j.executionCount ?? 0}/${j.maxExecutions ?? '∞'}`,
j.updatedAt ? timeAgo(j.updatedAt) : '',
]);
printTable(rows, ['ID', 'NAME', 'SCHEDULE', 'STATUS', 'EXECUTIONS', 'UPDATED']);
},
);
// ── view ──────────────────────────────────────────────
cron
.command('view <id>')
.description('View cron job details')
.option('--json [fields]', 'Output JSON')
.action(async (id: string, options: { json?: string | boolean }) => {
const client = await getTrpcClient();
const result = await client.agentCronJob.findById.query({ id });
const job = (result as any).data;
if (options.json !== undefined) {
const fields = typeof options.json === 'string' ? options.json : undefined;
outputJson(job, fields);
return;
}
if (!job) {
log.error('Cron job not found.');
process.exit(1);
}
console.log(`${pc.bold('ID:')} ${job.id}`);
console.log(`${pc.bold('Name:')} ${job.name || ''}`);
console.log(`${pc.bold('Agent ID:')} ${job.agentId || ''}`);
console.log(`${pc.bold('Schedule:')} ${job.schedule || ''}`);
console.log(
`${pc.bold('Status:')} ${job.enabled ? pc.green('enabled') : pc.dim('disabled')}`,
);
console.log(
`${pc.bold('Executions:')} ${job.executionCount ?? 0}/${job.maxExecutions ?? '∞'}`,
);
if (job.prompt) console.log(`${pc.bold('Prompt:')} ${truncate(job.prompt, 80)}`);
if (job.createdAt) console.log(`${pc.bold('Created:')} ${timeAgo(job.createdAt)}`);
if (job.updatedAt) console.log(`${pc.bold('Updated:')} ${timeAgo(job.updatedAt)}`);
});
// ── create ────────────────────────────────────────────
cron
.command('create')
.description('Create a cron job')
.requiredOption('--agent-id <id>', 'Agent ID')
.requiredOption('-s, --schedule <cron>', 'Cron schedule expression')
.option('-n, --name <name>', 'Job name')
.option('-p, --prompt <prompt>', 'Prompt text')
.option('--max-executions <n>', 'Maximum number of executions')
.option('--json', 'Output JSON')
.action(
async (options: {
agentId: string;
json?: boolean;
maxExecutions?: string;
name?: string;
prompt?: string;
schedule: string;
}) => {
const client = await getTrpcClient();
const input: Record<string, any> = {
agentId: options.agentId,
cronPattern: options.schedule,
};
if (options.name) input.name = options.name;
if (options.prompt) input.content = options.prompt;
if (options.maxExecutions) input.maxExecutions = Number.parseInt(options.maxExecutions, 10);
const result = await client.agentCronJob.create.mutate(input as any);
if (options.json) {
console.log(JSON.stringify(result, null, 2));
return;
}
const data = (result as any).data;
console.log(`${pc.green('✓')} Created cron job ${pc.bold(data?.id || '')}`);
},
);
// ── edit ───────────────────────────────────────────────
cron
.command('edit <id>')
.description('Update a cron job')
.option('-n, --name <name>', 'Job name')
.option('-s, --schedule <cron>', 'Cron schedule expression')
.option('-p, --prompt <prompt>', 'Prompt text')
.option('--max-executions <n>', 'Maximum number of executions')
.option('--enable', 'Enable the job')
.option('--disable', 'Disable the job')
.action(
async (
id: string,
options: {
disable?: boolean;
enable?: boolean;
maxExecutions?: string;
name?: string;
prompt?: string;
schedule?: string;
},
) => {
const data: Record<string, any> = {};
if (options.name) data.name = options.name;
if (options.schedule) data.cronPattern = options.schedule;
if (options.prompt) data.content = options.prompt;
if (options.maxExecutions) data.maxExecutions = Number.parseInt(options.maxExecutions, 10);
if (options.enable) data.enabled = true;
if (options.disable) data.enabled = false;
if (Object.keys(data).length === 0) {
log.error(
'No changes specified. Use --name, --schedule, --prompt, --enable, or --disable.',
);
process.exit(1);
}
const client = await getTrpcClient();
await client.agentCronJob.update.mutate({ data, id } as any);
console.log(`${pc.green('✓')} Updated cron job ${pc.bold(id)}`);
},
);
// ── delete ────────────────────────────────────────────
cron
.command('delete <id>')
.description('Delete a cron job')
.option('--yes', 'Skip confirmation prompt')
.action(async (id: string, options: { yes?: boolean }) => {
if (!options.yes) {
const confirmed = await confirm('Are you sure you want to delete this cron job?');
if (!confirmed) {
console.log('Cancelled.');
return;
}
}
const client = await getTrpcClient();
await client.agentCronJob.delete.mutate({ id });
console.log(`${pc.green('✓')} Deleted cron job ${pc.bold(id)}`);
});
// ── toggle ────────────────────────────────────────────
cron
.command('toggle <ids...>')
.description('Batch enable or disable cron jobs')
.option('--enable', 'Enable the jobs')
.option('--disable', 'Disable the jobs')
.action(async (ids: string[], options: { disable?: boolean; enable?: boolean }) => {
if (!options.enable && !options.disable) {
log.error('Specify --enable or --disable.');
process.exit(1);
}
const enabled = !!options.enable;
const client = await getTrpcClient();
const result = await client.agentCronJob.batchUpdateStatus.mutate({ enabled, ids });
const count = (result as any).data?.updatedCount ?? ids.length;
console.log(`${pc.green('✓')} ${enabled ? 'Enabled' : 'Disabled'} ${count} cron job(s)`);
});
// ── reset ─────────────────────────────────────────────
cron
.command('reset <id>')
.description('Reset execution count for a cron job')
.option('--max <n>', 'Set new max executions')
.action(async (id: string, options: { max?: string }) => {
const client = await getTrpcClient();
const input: Record<string, any> = { id };
if (options.max) input.newMaxExecutions = Number.parseInt(options.max, 10);
await client.agentCronJob.resetExecutions.mutate(input as any);
console.log(`${pc.green('✓')} Reset execution count for ${pc.bold(id)}`);
});
// ── stats ─────────────────────────────────────────────
cron
.command('stats')
.description('Get cron job execution statistics')
.option('--json', 'Output JSON')
.action(async (options: { json?: boolean }) => {
const client = await getTrpcClient();
const result = await client.agentCronJob.getStats.query();
const stats = (result as any).data;
if (options.json) {
console.log(JSON.stringify(stats, null, 2));
return;
}
if (!stats) {
console.log('No statistics available.');
return;
}
for (const [key, value] of Object.entries(stats as Record<string, any>)) {
console.log(`${pc.bold(key + ':')} ${value}`);
}
});
}
+304
View File
@@ -8,11 +8,20 @@ import { registerHeteroCommand } from './hetero';
const { mockSpawnAgent } = vi.hoisted(() => ({
mockSpawnAgent: vi.fn(),
}));
const { mockGetTrpcClient, mockHeteroFinishMutate, mockHeteroIngestMutate } = vi.hoisted(() => ({
mockGetTrpcClient: vi.fn(),
mockHeteroFinishMutate: vi.fn(),
mockHeteroIngestMutate: vi.fn(),
}));
vi.mock('@lobechat/heterogeneous-agents/spawn', () => ({
spawnAgent: mockSpawnAgent,
}));
vi.mock('../api/client', () => ({
getTrpcClient: mockGetTrpcClient,
}));
vi.mock('../utils/logger', () => ({
log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() },
setVerbose: vi.fn(),
@@ -77,6 +86,17 @@ describe('hetero exec command', () => {
}) as any);
stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
mockSpawnAgent.mockReset();
mockHeteroIngestMutate.mockReset();
mockHeteroFinishMutate.mockReset();
mockGetTrpcClient.mockReset();
mockHeteroIngestMutate.mockResolvedValue({ ack: true });
mockHeteroFinishMutate.mockResolvedValue({ ack: true });
mockGetTrpcClient.mockResolvedValue({
aiAgent: {
heteroFinish: { mutate: mockHeteroFinishMutate },
heteroIngest: { mutate: mockHeteroIngestMutate },
},
});
});
afterEach(() => {
@@ -341,4 +361,288 @@ describe('hetero exec command', () => {
expect(exitSpy).toHaveBeenCalledWith(2);
expect(mockSpawnAgent).not.toHaveBeenCalled();
});
describe('--resume auto-retry on session-not-found', () => {
it('retries without --resume when the error stream event indicates the session is gone', async () => {
// First spawn: exits non-zero, emits a resume-not-found error event
const resumeNotFoundEvent = {
data: {
error: 'No conversation found with session ID cc-stale',
message: 'No conversation found with session ID cc-stale',
},
operationId: 'op-r1',
stepIndex: 0,
timestamp: 1,
type: 'error',
};
mockSpawnAgent
.mockReturnValueOnce(createFakeHandle({ events: [resumeNotFoundEvent], exitCode: 1 }))
// Second spawn: succeeds
.mockReturnValueOnce(createFakeHandle({ exitCode: 0 }));
await runCmd([
'hetero',
'exec',
'--type',
'claude-code',
'--prompt',
'do the thing',
'--resume',
'cc-stale',
'--operation-id',
'op-r1',
]);
// Two spawns: first with --resume, retry without
expect(mockSpawnAgent).toHaveBeenCalledTimes(2);
expect(mockSpawnAgent.mock.calls[0][0]).toMatchObject({ resumeSessionId: 'cc-stale' });
expect(mockSpawnAgent.mock.calls[1][0]).not.toHaveProperty('resumeSessionId');
expect(mockSpawnAgent.mock.calls[1][0].resumeSessionId).toBeUndefined();
// Final exit code comes from the retry (0 → success)
expect(exitSpy).toHaveBeenCalledWith(0);
});
it('retries without --resume when stderr contains a session-not-found message', async () => {
// First spawn: exits non-zero with no events, but stderr has the pattern
mockSpawnAgent
.mockReturnValueOnce(
createFakeHandle({
exitCode: 1,
stderrChunks: ['Error: No conversation found with session ID xyz\n'],
}),
)
.mockReturnValueOnce(createFakeHandle({ exitCode: 0 }));
await runCmd([
'hetero',
'exec',
'--type',
'claude-code',
'--prompt',
'continue',
'--resume',
'xyz',
'--operation-id',
'op-r2',
]);
expect(mockSpawnAgent).toHaveBeenCalledTimes(2);
expect(mockSpawnAgent.mock.calls[1][0].resumeSessionId).toBeUndefined();
expect(exitSpy).toHaveBeenCalledWith(0);
});
it('retries without --resume when the error indicates context overflow', async () => {
const contextOverflowEvent = {
data: {
error: 'prompt is too long: 215168 tokens > 200000 maximum',
message: 'prompt is too long: 215168 tokens > 200000 maximum',
},
operationId: 'op-ctx',
stepIndex: 0,
timestamp: 1,
type: 'error',
};
mockSpawnAgent
.mockReturnValueOnce(createFakeHandle({ events: [contextOverflowEvent], exitCode: 1 }))
.mockReturnValueOnce(createFakeHandle({ exitCode: 0 }));
await runCmd([
'hetero',
'exec',
'--type',
'claude-code',
'--prompt',
'next question',
'--resume',
'cc-longctx',
'--operation-id',
'op-ctx',
]);
expect(mockSpawnAgent).toHaveBeenCalledTimes(2);
expect(mockSpawnAgent.mock.calls[0][0]).toMatchObject({ resumeSessionId: 'cc-longctx' });
expect(mockSpawnAgent.mock.calls[1][0].resumeSessionId).toBeUndefined();
expect(exitSpy).toHaveBeenCalledWith(0);
});
it('does NOT retry on a non-resume error exit', async () => {
// Exit code 1 but no resume-related error message
mockSpawnAgent.mockReturnValueOnce(
createFakeHandle({ exitCode: 1, stderrChunks: ['rate limit exceeded\n'] }),
);
await runCmd([
'hetero',
'exec',
'--type',
'claude-code',
'--prompt',
'hi',
'--resume',
'cc-valid',
]);
expect(mockSpawnAgent).toHaveBeenCalledTimes(1);
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('does NOT retry when --resume is not provided', async () => {
const errorEvent = {
data: { error: 'No conversation found', message: 'No conversation found' },
operationId: 'op-nr',
stepIndex: 0,
timestamp: 1,
type: 'error',
};
mockSpawnAgent.mockReturnValueOnce(createFakeHandle({ events: [errorEvent], exitCode: 1 }));
await runCmd([
'hetero',
'exec',
'--type',
'claude-code',
'--prompt',
'fresh run',
'--operation-id',
'op-nr',
]);
// No --resume → no interception → no retry
expect(mockSpawnAgent).toHaveBeenCalledTimes(1);
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('does NOT suppress the resume-error event from JSONL output', async () => {
const resumeNotFoundEvent = {
data: {
error: 'No conversation found with session ID old',
message: 'No conversation found with session ID old',
},
operationId: 'op-jsonl',
stepIndex: 0,
timestamp: 1,
type: 'error',
};
mockSpawnAgent
.mockReturnValueOnce(createFakeHandle({ events: [resumeNotFoundEvent], exitCode: 1 }))
.mockReturnValueOnce(createFakeHandle({ exitCode: 0 }));
await runCmd([
'hetero',
'exec',
'--type',
'claude-code',
'--prompt',
'do thing',
'--resume',
'old',
'--render',
'jsonl',
]);
// The error event is still emitted to JSONL (for observability) even
// though it was withheld from the ingester.
const lines = stdoutSpy.mock.calls
.map((c) => c[0])
.filter((s): s is string => typeof s === 'string');
const errorLine = lines.find((l) => {
try {
return JSON.parse(l).type === 'error';
} catch {
return false;
}
});
expect(errorLine).toBeDefined();
});
});
it('sends full text snapshots before tools and waits for finish until all server ingests ack', async () => {
const callOrder: string[] = [];
mockHeteroIngestMutate.mockImplementation(async ({ events }: any) => {
const first = events[0];
callOrder.push(`ingest:${first.type}:${first.data?.chunkType ?? 'terminal'}`);
return { ack: true };
});
mockHeteroFinishMutate.mockImplementation(async () => {
callOrder.push('finish');
return { ack: true };
});
mockSpawnAgent.mockReturnValue(
createFakeHandle({
events: [
{
data: { chunkType: 'text', content: 'hello ' },
operationId: 'op-server',
stepIndex: 0,
timestamp: 1,
type: 'stream_chunk',
},
{
data: { chunkType: 'text', content: 'world' },
operationId: 'op-server',
stepIndex: 0,
timestamp: 2,
type: 'stream_chunk',
},
{
data: {
chunkType: 'tools_calling',
toolsCalling: [
{
apiName: 'Bash',
arguments: '{"cmd":"ls"}',
id: 'tc-1',
identifier: 'bash',
type: 'default',
},
],
},
operationId: 'op-server',
stepIndex: 1,
timestamp: 3,
type: 'stream_chunk',
},
{
data: { reason: 'success' },
operationId: 'op-server',
stepIndex: 1,
timestamp: 4,
type: 'agent_runtime_end',
},
],
exitCode: 0,
}),
);
await runCmd([
'hetero',
'exec',
'--type',
'claude-code',
'--prompt',
'hi',
'--topic',
'topic-1',
'--operation-id',
'op-server',
'--render',
'none',
]);
expect(mockHeteroIngestMutate).toHaveBeenCalledTimes(3);
expect(mockHeteroIngestMutate.mock.calls[0][0].events[0].data).toMatchObject({
chunkType: 'text',
content: 'hello world',
snapshotMode: 'replace',
snapshotSeq: 1,
});
expect(callOrder).toEqual([
'ingest:stream_chunk:text',
'ingest:stream_chunk:tools_calling',
'ingest:agent_runtime_end:terminal',
'finish',
]);
});
});
+334 -92
View File
@@ -1,4 +1,5 @@
import { randomUUID } from 'node:crypto';
import { once } from 'node:events';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
@@ -6,17 +7,51 @@ import type {
AgentContentBlock,
AgentImageSource,
AgentPromptInput,
AgentStreamEvent,
} from '@lobechat/heterogeneous-agents/spawn';
import { spawnAgent } from '@lobechat/heterogeneous-agents/spawn';
import type { Command } from 'commander';
import { getTrpcClient } from '../api/client';
import { BatchIngester, NoopIngestSink } from '../utils/BatchIngester';
import { log } from '../utils/logger';
import { TrpcIngestSink } from '../utils/TrpcIngestSink';
const SUPPORTED_AGENT_TYPES = new Set(['claude-code', 'codex']);
/**
* Patterns that indicate a `--resume <sessionId>` run should be retried
* without `--resume`. Two classes of failure:
*
* 1. Session file missing (sandbox recycled): the container is ephemeral
* (~1 h idle TTL), so a new sandbox has an empty `~/.claude/projects/`
* and the stored session id is stale.
*
* 2. Context overflow (long conversation): the resumed session carries all
* accumulated history; when the combined token count exceeds the model's
* context window the API rejects the request immediately after CC
* initialises. Starting fresh (no `--resume`) drops the old history and
* lets CC respond to the new prompt alone.
*
* Checked against:
* - `error` stream events emitted by the CC adapter from CC's result event
* - Accumulated stderr output (fallback when CC exits without a result event)
*/
const RESUME_RETRY_PATTERNS = [
// Session file missing — sandbox was recycled
/no conversation found/i,
/session.*not found/i,
/conversation.*not found/i,
/resume.*not found/i,
// Context overflow — API rejected the resumed session's accumulated history
/prompt.*too long/i,
/context.*too long/i,
/context window.*exceed/i,
/maximum.*context.*length/i,
] as const;
const looksLikeNeedsRetryWithoutResume = (text: string): boolean =>
RESUME_RETRY_PATTERNS.some((p) => p.test(text));
interface ExecOptions {
command?: string;
cwd?: string;
@@ -166,6 +201,85 @@ const resolvePrompt = async (options: ExecOptions): Promise<ResolvedPrompt> => {
return buildPromptFromText(raw, images);
};
class SerialServerIngester {
private accumulatedText = '';
private fatalError: Error | null = null;
private inflight: Promise<void> = Promise.resolve();
private nextSnapshotSeq = 0;
private pendingTextEvent: AgentStreamEvent | undefined;
private timer: ReturnType<typeof setTimeout> | null = null;
constructor(
private readonly sink: TrpcIngestSink,
private readonly snapshotFlushMs = 200,
) {}
push(event: AgentStreamEvent): void {
if (this.fatalError) return;
if (
event.type === 'stream_chunk' &&
event.data?.chunkType === 'text' &&
typeof event.data?.content === 'string'
) {
this.accumulatedText += event.data.content;
this.pendingTextEvent = event;
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(() => {
this.timer = null;
this.queuePendingTextSnapshot();
}, this.snapshotFlushMs);
return;
}
this.queuePendingTextSnapshot();
this.enqueue(async () => {
await this.sink.ingest([event]);
});
}
async drain(): Promise<void> {
this.queuePendingTextSnapshot();
try {
await this.inflight;
} catch {
// `fatalError` is re-thrown below.
}
if (this.fatalError) throw this.fatalError;
}
private enqueue(task: () => Promise<void>) {
this.inflight = this.inflight.then(task).catch((err) => {
this.fatalError = err instanceof Error ? err : new Error(String(err));
throw this.fatalError;
});
}
private queuePendingTextSnapshot() {
if (!this.pendingTextEvent || this.fatalError) return;
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
const baseEvent = this.pendingTextEvent;
this.pendingTextEvent = undefined;
const snapshotEvent: AgentStreamEvent = {
...baseEvent,
data: {
...baseEvent.data,
content: this.accumulatedText,
snapshotMode: 'replace',
snapshotSeq: ++this.nextSnapshotSeq,
},
};
this.enqueue(async () => {
await this.sink.ingest([snapshotEvent]);
});
}
}
const exec = async (options: ExecOptions): Promise<void> => {
if (!SUPPORTED_AGENT_TYPES.has(options.type)) {
log.error(
@@ -209,128 +323,256 @@ const exec = async (options: ExecOptions): Promise<void> => {
// server-ingest mode. The tRPC client reads LOBEHUB_JWT (operation-scoped
// JWT injected by the server) for authentication.
const agentType = options.type as 'claude-code' | 'codex';
let sink: InstanceType<typeof TrpcIngestSink> | InstanceType<typeof NoopIngestSink>;
let sink: TrpcIngestSink | undefined;
let serverIngester: SerialServerIngester | undefined;
if (serverIngest) {
const client = await getTrpcClient();
sink = new TrpcIngestSink(client, agentType, operationId, options.topic!);
} else {
sink = new NoopIngestSink();
sink = new TrpcIngestSink(
client,
agentType,
operationId,
options.topic!,
process.env.LOBEHUB_ASSISTANT_MESSAGE_ID,
);
serverIngester = new SerialServerIngester(sink);
}
const ingester = new BatchIngester(sink);
// `spawnAgent` is async and can reject DURING image normalization — fetch
// failures, missing local --image paths, decode errors. Surface those as a
// clean error + exit code instead of an unhandled promise rejection / stack
// trace, mirroring the validation try/catch above.
let handle: Awaited<ReturnType<typeof spawnAgent>>;
try {
handle = await spawnAgent({
/**
* Spawn one agent process and stream all its events into the server ingester.
*
* When `interceptResumeErrors` is true, any `error`-type event whose
* message matches `RESUME_RETRY_PATTERNS` is withheld from the
* ingester and signals a retry instead. This keeps the server's
* operation state clean: no terminal error event is pushed, so the
* retry's events land on the same operationId without confusing the
* renderer.
*
* Returns:
* code / signal child exit info
* sessionId CC session id from `system.init` (undefined on resume failure)
* ingestError true when a batch could not be flushed after retries
* resumeNotFound true when a resume-not-found error was intercepted
* stderrContent accumulated stderr (only when interceptResumeErrors=true)
*/
const runOneAgent = async (
spawnOpts: Parameters<typeof spawnAgent>[0],
interceptResumeErrors: boolean,
): Promise<{
code: number | null;
ingestError: boolean;
resumeNotFound: boolean;
sessionId: string | undefined;
signal: NodeJS.Signals | null;
stderrContent: string;
}> => {
// `spawnAgent` is async and can reject DURING image normalization — fetch
// failures, missing local --image paths, decode errors.
let handle: Awaited<ReturnType<typeof spawnAgent>>;
try {
handle = await spawnAgent(spawnOpts);
} catch (err) {
log.error('Failed to start agent:', err instanceof Error ? err.message : String(err));
process.exit(1);
}
// Always collect stderr — used for resume-error detection AND for
// surfacing a meaningful error message to the server when CC fails
// without emitting a structured error event. Cap at 8 KB so the
// collector doesn't grow unboundedly on a chatty run.
// Always pipe to process.stderr too so users see auth prompts / warnings.
const STDERR_CAP = 8 * 1024;
let stderrContent = '';
const stderrEnded = once(handle.stderr, 'end').then(() => undefined);
handle.stderr.on('data', (chunk: Buffer) => {
if (stderrContent.length < STDERR_CAP) {
stderrContent += chunk.toString();
}
});
handle.stderr.pipe(process.stderr);
// Ctrl-C → SIGINT to the child's process group.
// Repeated Ctrl-C escalates to SIGKILL.
let interrupted = false;
const onSigint = async () => {
if (interrupted) {
handle.kill('SIGKILL');
return;
}
interrupted = true;
handle.kill('SIGINT');
if (serverIngester && sink) {
try {
await serverIngester.drain();
await sink.finish({ result: 'cancelled' });
} catch {
// best-effort; process is exiting anyway
}
}
};
const onSigterm = async () => {
handle.kill('SIGTERM');
if (serverIngester && sink) {
try {
await serverIngester.drain();
await sink.finish({ result: 'cancelled' });
} catch {
// best-effort
}
}
};
process.on('SIGINT', onSigint);
process.on('SIGTERM', onSigterm);
// Stream events. Each event is optionally written as JSONL and pushed
// into the ingester. When intercepting resume errors, a matching
// `error` event is withheld from the ingester and flags a retry instead.
let resumeNotFound = false;
const ingestError = false;
try {
for await (const event of handle.events) {
if (interceptResumeErrors && event.type === 'error') {
const data = event.data as Record<string, unknown> | undefined;
const msg = String(data?.message ?? data?.error ?? '');
if (looksLikeNeedsRetryWithoutResume(msg)) {
resumeNotFound = true;
// Emit to JSONL for observability but do NOT push to ingester —
// we are about to retry; the server must not see a terminal error.
if (emitJsonl) process.stdout.write(`${JSON.stringify(event)}\n`);
continue;
}
}
if (emitJsonl) process.stdout.write(`${JSON.stringify(event)}\n`);
serverIngester?.push(event);
}
} catch (err) {
log.error(
'Stream error from agent process:',
err instanceof Error ? err.message : String(err),
);
if (serverIngester && sink) {
try {
await serverIngester.drain();
await sink.finish({
error: { message: String(err), type: 'stream_error' },
result: 'error',
});
} catch {
// best-effort
}
}
process.exit(1);
} finally {
process.off('SIGINT', onSigint);
process.off('SIGTERM', onSigterm);
}
const { code, signal } = await handle.exit;
await stderrEnded;
// Fallback stderr detection: CC may exit non-zero without emitting a
// result event (e.g. it writes to stderr and quits immediately).
if (
interceptResumeErrors &&
!resumeNotFound &&
code !== 0 &&
looksLikeNeedsRetryWithoutResume(stderrContent)
) {
resumeNotFound = true;
}
return {
code,
ingestError,
resumeNotFound,
sessionId: handle.sessionId,
signal,
stderrContent,
};
};
// ─── First run (with --resume if provided) ───────────────────────────────
const interceptResume = !!options.resume;
const first = await runOneAgent(
{
agentType: options.type,
command: options.command,
cwd: options.cwd || process.cwd(),
operationId,
prompt: resolved.prompt,
resumeSessionId: options.resume,
});
} catch (err) {
log.error('Failed to start agent:', err instanceof Error ? err.message : String(err));
process.exit(1);
},
interceptResume,
);
// ─── Auto-retry without --resume when the session cannot be used ─────────
//
// Two classes of failure detected via `RESUME_RETRY_PATTERNS`:
// A. Sandbox recycled: container is ephemeral (~1 h idle TTL); new sandbox
// has no CC session files so `--resume <staleId>` is rejected with a
// "no conversation found" error.
// B. Context overflow: the resumed session carries accumulated history that
// pushes the combined token count past the model limit; the API rejects
// the call with a "prompt is too long" error.
//
// In both cases we transparently restart CC without `--resume` so it starts a
// fresh session. The server's `heteroSessionId` is updated with the new id,
// breaking the stale-session loop.
let result = first;
if (first.resumeNotFound) {
log.info('Resume failed (session not found or context overflow) — retrying without --resume');
result = await runOneAgent(
{
agentType: options.type,
command: options.command,
cwd: options.cwd || process.cwd(),
operationId,
prompt: resolved.prompt,
// No resumeSessionId — start fresh
},
false, // no need to intercept resume errors on a fresh run
);
}
// Forward the child's stderr to ours so users see CLI errors / warnings
// (auth prompts, missing-binary errors, etc.) in the terminal.
handle.stderr.pipe(process.stderr);
// ─── Drain + finish ───────────────────────────────────────────────────────
// Ctrl-C → SIGINT to the child's process group so the spawned CLI gets a
// chance to clean up. Repeated Ctrl-C escalates to SIGKILL via the
// standard "double-tap" pattern most CLIs implement themselves.
// In server-ingest mode, drain the ingester and call heteroFinish before
// exiting so the server knows the operation was cancelled.
let interrupted = false;
const onSigint = async () => {
if (interrupted) {
handle.kill('SIGKILL');
return;
}
interrupted = true;
handle.kill('SIGINT');
if (serverIngest) {
try {
await ingester.drain();
await sink.finish({ result: 'cancelled' });
} catch {
// best-effort; process is exiting anyway
}
}
};
process.on('SIGINT', onSigint);
process.on('SIGTERM', async () => {
handle.kill('SIGTERM');
if (serverIngest) {
try {
await ingester.drain();
await sink.finish({ result: 'cancelled' });
} catch {
// best-effort
}
}
});
const { code, signal, sessionId } = result;
// Stream events. Each event is optionally written as JSONL and always
// pushed into the ingester (which batches and sends to the server).
let ingestError = false;
try {
for await (const event of handle.events) {
if (emitJsonl) {
process.stdout.write(`${JSON.stringify(event)}\n`);
}
ingester.push(event);
}
} catch (err) {
log.error('Stream error from agent process:', err instanceof Error ? err.message : String(err));
if (serverIngest) {
try {
await ingester.drain();
await sink.finish({
result: 'error',
error: { message: String(err), type: 'stream_error' },
});
} catch {
// best-effort
}
}
process.exit(1);
} finally {
process.off('SIGINT', onSigint);
}
// Pass the child's exit code through. In server-ingest mode, drain the
// ingester and call heteroFinish before exiting.
const { code, signal } = await handle.exit;
if (serverIngest) {
if (serverIngester && sink) {
try {
await ingester.drain();
await serverIngester.drain();
} catch (err) {
log.error(
'Failed to flush events to server:',
err instanceof Error ? err.message : String(err),
);
ingestError = true;
result = { ...result, ingestError: true };
}
const exitedClean = !ingestError && (code === 0 || signal === 'SIGTERM');
const exitedClean = !result.ingestError && (code === 0 || signal === 'SIGTERM');
// When the run failed, pass stderr as the error detail so the server can
// surface a useful message instead of the generic "Agent execution failed"
// fallback. Trim to the last 1 KB — the tail is most informative and
// keeps the tRPC payload small.
const stderrTail = result.stderrContent.trim();
const finishError =
!exitedClean && stderrTail
? { message: stderrTail.slice(-1024), type: 'AgentRuntimeError' }
: undefined;
try {
await sink.finish({
error: finishError,
result: exitedClean ? 'success' : 'error',
sessionId: handle.sessionId,
sessionId,
});
} catch (err) {
log.error('Failed to send heteroFinish:', err instanceof Error ? err.message : String(err));
}
}
if (code !== null) process.exit(ingestError ? 1 : code);
if (code !== null) process.exit(result.ingestError ? 1 : code);
if (signal === 'SIGINT') process.exit(130);
if (signal === 'SIGTERM') process.exit(143);
if (signal === 'SIGKILL') process.exit(137);
+1 -1
View File
@@ -208,7 +208,7 @@ function readAgentProfile(workspacePath: string): AgentProfile {
// Try to extract **Emoji:** value (single emoji)
const emojiMatch = content.match(/\*{0,2}Emoji:?\*{0,2}\s*(.+)/i);
const rawAvatar = emojiMatch ? emojiMatch[1].trim() : undefined;
// Filter out placeholder text like (待定)(Chinese TBD), _(待定)_, (TBD), N/A, etc.
// Filter out placeholder text like (TBD), _(TBD)_, N/A, and Chinese-language equivalents.
const isPlaceholder =
rawAvatar && /^[_*(].*[)_*]$|^(?:tbd|todo|n\/?a|none|待定|未定)$/i.test(rawAvatar);
const avatar = rawAvatar && !isPlaceholder ? rawAvatar : undefined;
+26 -1
View File
@@ -12,16 +12,38 @@ export function registerNotifyCommand(program: Command) {
.requiredOption('-c, --content <content>', 'Message content')
.option('--agent-id <agentId>', 'Agent ID (overrides topic default)')
.option('--thread-id <threadId>', 'Thread ID for threaded conversations')
.option(
'--role <role>',
'Message role: user (default, triggers agent reply) | assistant (writes directly as agent message)',
'user',
)
.option(
'--message-id <messageId>',
'When --role assistant: update an existing message instead of creating a new one (keeps a single bubble)',
)
.option(
'--continue',
'When --role assistant: trigger a follow-up agent turn after writing the message',
)
.option('--json', 'Output JSON')
.action(
async (options: {
agentId?: string;
content: string;
continue?: boolean;
json?: boolean;
messageId?: string;
role?: 'assistant' | 'user';
threadId?: string;
topic: string;
}) => {
log.debug('notify: topic=%s, agentId=%s', options.topic, options.agentId);
log.debug(
'notify: topic=%s, agentId=%s, role=%s, messageId=%s',
options.topic,
options.agentId,
options.role,
options.messageId,
);
const client = await getTrpcClient();
@@ -29,6 +51,9 @@ export function registerNotifyCommand(program: Command) {
const result = await client.agentNotify.notify.mutate({
agentId: options.agentId,
content: options.content,
continue: options.continue,
messageId: options.messageId,
role: options.role,
threadId: options.threadId,
topicId: options.topic,
});
+1 -1
View File
@@ -145,7 +145,7 @@ export function registerReviewCommands(task: Command) {
rc.command('add <id>')
.description('Add a review rubric')
.requiredOption('-n, --name <name>', 'Rubric name (e.g. "内容准确性")')
.requiredOption('-n, --name <name>', 'Rubric name (e.g. "Content Accuracy")')
.option('--type <type>', 'Rubric type (default: llm-rubric)', 'llm-rubric')
.option('-t, --threshold <n>', 'Pass threshold 0-100 (converted to 0-1)')
.option('-d, --description <text>', 'Criteria description (for llm-rubric type)')
+51
View File
@@ -0,0 +1,51 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
export interface TaskEntry {
agentId?: string;
agentType: 'hermes' | 'openclaw';
operationId: string;
pid: number;
startedAt: string;
taskId: string;
topicId: string;
}
function getRegistryPath(): string {
return path.join(os.homedir(), '.lobehub', 'task-registry.json');
}
function readRegistry(): Record<string, TaskEntry> {
try {
return JSON.parse(fs.readFileSync(getRegistryPath(), 'utf8')) as Record<string, TaskEntry>;
} catch {
return {};
}
}
function writeRegistry(entries: Record<string, TaskEntry>): void {
const dir = path.dirname(getRegistryPath());
fs.mkdirSync(dir, { mode: 0o700, recursive: true });
fs.writeFileSync(getRegistryPath(), JSON.stringify(entries, null, 2), { mode: 0o600 });
}
export function saveTask(entry: TaskEntry): void {
const registry = readRegistry();
registry[entry.taskId] = entry;
writeRegistry(registry);
}
export function getTask(taskId: string): TaskEntry | undefined {
return readRegistry()[taskId];
}
export function removeTask(taskId: string): void {
const registry = readRegistry();
delete registry[taskId];
writeRegistry(registry);
}
export function listTasks(): TaskEntry[] {
return Object.values(readRegistry());
}
+8 -1
View File
@@ -1,3 +1,10 @@
import { createProgram } from './program';
import { log } from './utils/logger';
createProgram().parse(process.argv, { from: 'node' });
void createProgram()
.parseAsync(process.argv, { from: 'node' })
.catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
log.error(message);
process.exit(1);
});
-2
View File
@@ -8,7 +8,6 @@ import { registerBotCommand } from './commands/bot';
import { registerCompletionCommand } from './commands/completion';
import { registerConfigCommand } from './commands/config';
import { registerConnectCommand } from './commands/connect';
import { registerCronCommand } from './commands/cron';
import { registerDeviceCommand } from './commands/device';
import { registerDocCommand } from './commands/doc';
import { registerEvalCommand } from './commands/eval';
@@ -60,7 +59,6 @@ export function createProgram() {
registerAgentCommand(program);
registerAgentGroupCommand(program);
registerBotCommand(program);
registerCronCommand(program);
registerGenerateCommand(program);
registerFileCommand(program);
registerHeteroCommand(program);
@@ -0,0 +1,251 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { removeTask, saveTask } from '../../daemon/taskRegistry';
import { runHeteroTask } from '../heteroTask';
// ─── Mocks ───
const spawnMock = vi.hoisted(() => vi.fn());
const execFileSyncMock = vi.hoisted(() => vi.fn());
vi.mock('node:child_process', () => ({
execFileSync: execFileSyncMock,
spawn: spawnMock,
}));
// task registry — use real implementation backed by a temporary in-memory map
const taskStore: Record<string, any> = {};
vi.mock('../../daemon/taskRegistry', () => ({
getTask: vi.fn((id: string) => taskStore[id]),
listTasks: vi.fn(() => Object.values(taskStore)),
removeTask: vi.fn((id: string) => {
delete taskStore[id];
}),
saveTask: vi.fn((entry: any) => {
taskStore[entry.taskId] = entry;
}),
}));
vi.mock('../../api/client', () => ({
getTrpcClient: vi.fn().mockResolvedValue({
agentNotify: {
notify: { mutate: vi.fn().mockResolvedValue(undefined) },
},
}),
}));
vi.mock('../../utils/logger', () => ({
log: { error: vi.fn(), info: vi.fn(), warn: vi.fn() },
}));
// ─── Helpers ───
function makeMockChild(pid = 9999) {
const listeners: Record<string, Array<(...a: any[]) => void>> = {};
return {
on: vi.fn((event: string, cb: (...a: any[]) => void) => {
(listeners[event] ??= []).push(cb);
}),
pid,
unref: vi.fn(),
_emit: (event: string, ...args: any[]) => listeners[event]?.forEach((cb) => cb(...args)),
};
}
// ─── Tests ───
describe('runHeteroTask (openclaw)', () => {
beforeEach(() => {
vi.clearAllMocks();
// Clear task store
for (const key of Object.keys(taskStore)) delete taskStore[key];
execFileSyncMock.mockReturnValue('/usr/local/bin/lh\n');
});
afterEach(() => {
vi.restoreAllMocks();
});
it('always injects buildNotifyProtocol into the prompt regardless of session history', async () => {
const child = makeMockChild();
spawnMock.mockReturnValue(child);
await runHeteroTask({
agentType: 'openclaw',
operationId: 'op-1',
prompt: 'what time is it',
taskId: 'task-1',
topicId: 'topic-1',
});
expect(spawnMock).toHaveBeenCalledTimes(1);
const [, spawnArgs] = spawnMock.mock.calls[0] as [string, string[]];
const msgIdx = spawnArgs.indexOf('--message');
const messageArg = spawnArgs[msgIdx + 1];
expect(messageArg).toContain('what time is it');
expect(messageArg).toContain('lh notify');
expect(messageArg).toContain('MSG_ID');
});
it('always injects protocol even on the second turn of the same session', async () => {
const child1 = makeMockChild(1111);
const child2 = makeMockChild(2222);
spawnMock.mockReturnValueOnce(child1).mockReturnValueOnce(child2);
// First turn
await runHeteroTask({
agentType: 'openclaw',
operationId: 'op-1',
prompt: 'hello',
taskId: 'task-1',
topicId: 'topic-1',
});
// Simulate process exit so task is removed
child1._emit('close', 0, null);
// Second turn (same topicId)
await runHeteroTask({
agentType: 'openclaw',
operationId: 'op-2',
prompt: 'follow up',
taskId: 'task-2',
topicId: 'topic-1',
});
expect(spawnMock).toHaveBeenCalledTimes(2);
for (const call of spawnMock.mock.calls) {
const args = call[1] as string[];
const msg = args[args.indexOf('--message') + 1];
expect(msg).toContain('lh notify');
}
});
it('kills an existing concurrent process for the same topicId before spawning', async () => {
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
const child1 = makeMockChild(1111);
spawnMock.mockReturnValueOnce(child1);
await runHeteroTask({
agentType: 'openclaw',
operationId: 'op-1',
prompt: 'msg1',
taskId: 'task-1',
topicId: 'topic-same',
});
// task-1 is still "running" (close not fired)
const child2 = makeMockChild(2222);
spawnMock.mockReturnValueOnce(child2);
await runHeteroTask({
agentType: 'openclaw',
operationId: 'op-2',
prompt: 'msg2',
taskId: 'task-2',
topicId: 'topic-same',
});
expect(killSpy).toHaveBeenCalledWith(1111, 'SIGTERM');
expect(spawnMock).toHaveBeenCalledTimes(2);
});
it('does not kill processes for a different topicId', async () => {
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
const child1 = makeMockChild(3333);
spawnMock.mockReturnValueOnce(child1);
await runHeteroTask({
agentType: 'openclaw',
operationId: 'op-1',
prompt: 'a',
taskId: 'task-a',
topicId: 'topic-A',
});
const child2 = makeMockChild(4444);
spawnMock.mockReturnValueOnce(child2);
await runHeteroTask({
agentType: 'openclaw',
operationId: 'op-2',
prompt: 'b',
taskId: 'task-b',
topicId: 'topic-B',
});
expect(killSpy).not.toHaveBeenCalled();
});
it('saves task entry with correct fields after spawn', async () => {
const child = makeMockChild(5555);
spawnMock.mockReturnValue(child);
await runHeteroTask({
agentId: 'agent-1',
agentType: 'openclaw',
operationId: 'op-x',
prompt: 'test',
taskId: 'task-x',
topicId: 'topic-x',
});
expect(saveTask).toHaveBeenCalledWith(
expect.objectContaining({
agentType: 'openclaw',
pid: 5555,
taskId: 'task-x',
topicId: 'topic-x',
}),
);
});
it('passes --session-id and --agent args to openclaw', async () => {
const child = makeMockChild();
spawnMock.mockReturnValue(child);
await runHeteroTask({
agentType: 'openclaw',
operationId: 'op-1',
prompt: 'hello',
taskId: 'task-1',
topicId: 'my-topic-id',
});
const [, spawnArgs] = spawnMock.mock.calls[0] as [string, string[]];
expect(spawnArgs).toContain('--session-id');
expect(spawnArgs[spawnArgs.indexOf('--session-id') + 1]).toBe('my-topic-id');
expect(spawnArgs).toContain('--agent');
expect(spawnArgs).toContain('--local');
});
it('removes task and ignores already-exited process when killing concurrent task', async () => {
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => {
throw new Error('No such process');
});
const child1 = makeMockChild(7777);
spawnMock.mockReturnValueOnce(child1);
await runHeteroTask({
agentType: 'openclaw',
operationId: 'op-1',
prompt: 'msg1',
taskId: 'task-1',
topicId: 'topic-gone',
});
const child2 = makeMockChild(8888);
spawnMock.mockReturnValueOnce(child2);
// Should not throw even though kill fails
await expect(
runHeteroTask({
agentType: 'openclaw',
operationId: 'op-2',
prompt: 'msg2',
taskId: 'task-2',
topicId: 'topic-gone',
}),
).resolves.not.toThrow();
expect(removeTask).toHaveBeenCalledWith('task-1');
killSpy.mockRestore();
});
});
@@ -0,0 +1,61 @@
import { execFileSync } from 'node:child_process';
export interface CheckPlatformCapabilityParams {
platform: 'hermes' | 'openclaw';
}
export interface CheckPlatformCapabilityResult {
available: boolean;
reason?: string;
version?: string;
}
/**
* Probe whether a specific agent platform is available on this device.
* Dispatched by the server via `device.checkCapability` tRPC procedure.
*
* - openclaw: runs `openclaw --version` and parses the output
* - hermes: hits the gateway health endpoint on the configured port
*/
export async function checkPlatformCapability(
params: CheckPlatformCapabilityParams,
): Promise<CheckPlatformCapabilityResult> {
const { platform } = params;
if (platform === 'openclaw') {
try {
const output = execFileSync('openclaw', ['--version'], {
encoding: 'utf8',
timeout: 5000,
}).trim();
// output is typically "openclaw x.y.z"
const version = output.split(/\s+/).at(-1);
return { available: true, version };
} catch (err) {
return {
available: false,
reason: err instanceof Error ? err.message : 'openclaw not found or failed to run',
};
}
}
if (platform === 'hermes') {
try {
const output = execFileSync('hermes', ['--version'], {
encoding: 'utf8',
timeout: 5000,
}).trim();
// output is typically "Hermes Agent vX.Y.Z (...)"
const versionMatch = output.match(/v(\d+\.\d+\.\d+)/);
const version = versionMatch ? versionMatch[1] : output.split(/\s+/).at(-1);
return { available: true, version };
} catch (err) {
return {
available: false,
reason: err instanceof Error ? err.message : 'hermes not found or failed to run',
};
}
}
return { available: false, reason: `Unknown platform: ${platform as string}` };
}
+183
View File
@@ -0,0 +1,183 @@
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { RemoteHeterogeneousAgentType } from '@lobechat/heterogeneous-agents';
export interface GetAgentProfileParams {
/** Agent ID to query (openclaw only). Defaults to the default agent. */
agentId?: string;
platform: RemoteHeterogeneousAgentType;
}
export interface AgentProfileResult {
avatar?: string;
description?: string;
title?: string;
}
// Files to look for a description (tried in order)
const IDENTITY_FILES = ['IDENTITY.md', 'SOUL.md'];
/**
* Try to extract a description from the workspace identity file.
* Looks for Creature / Vibe / Description fields in IDENTITY.md or SOUL.md.
*/
function readDescriptionFromWorkspace(workspacePath: string): string | undefined {
for (const filename of IDENTITY_FILES) {
const filePath = path.join(workspacePath, filename);
if (!fs.existsSync(filePath)) continue;
const content = fs.readFileSync(filePath, 'utf8');
const match = content.match(/\*{0,2}(?:Creature|Vibe|Description):?\*{0,2}\s*(.+)/i);
if (!match) continue;
const value = match[1].trim();
// Skip unfilled template placeholders like _(pick something)_ or (TBD)
if (/^[_*(].*[)*_]$|^(?:tbd|todo|n\/?a|none|待定|未定)$/i.test(value)) continue;
return value;
}
}
interface OpenClawAgentEntry {
id: string;
identityEmoji?: string;
identityName?: string;
isDefault?: boolean;
workspace?: string;
}
function getOpenClawProfile(agentId?: string): AgentProfileResult {
let output: string;
try {
output = execFileSync('openclaw', ['agents', 'list', '--json'], {
encoding: 'utf8',
timeout: 5000,
});
} catch {
return {};
}
let agents: OpenClawAgentEntry[];
try {
agents = JSON.parse(output) as OpenClawAgentEntry[];
} catch {
return {};
}
const agent = agentId
? agents.find((a) => a.id === agentId)
: (agents.find((a) => a.isDefault) ?? agents[0]);
if (!agent) return {};
const title = agent.identityName || undefined;
const avatar = agent.identityEmoji || '🦞'; // OpenClaw brand mascot as default
// Description is not exposed by the CLI — read from the workspace IDENTITY.md
const description = agent.workspace ? readDescriptionFromWorkspace(agent.workspace) : undefined;
return { avatar, description, title };
}
/**
* Read the active Hermes profile name from `hermes profile list` output.
* The active profile is marked with in the first column.
*/
function getActiveHermesProfileName(): string | undefined {
try {
const output = execFileSync('hermes', ['profile', 'list'], {
encoding: 'utf8',
timeout: 5000,
});
const match = output.match(/◆(\S+)/);
return match?.[1];
} catch {
return undefined;
}
}
/**
* Read the filesystem path of a Hermes profile from `hermes profile show <name>`.
*/
function getHermesProfilePath(profileName: string): string | undefined {
try {
const output = execFileSync('hermes', ['profile', 'show', profileName], {
encoding: 'utf8',
timeout: 5000,
});
const match = output.match(/^Path:\s+(.+)/m);
const raw = match?.[1]?.trim();
// Expand leading `~` — Node does not auto-expand home-dir shorthands.
return raw?.replace(/^~(?=\/|$)/, os.homedir());
} catch {
return undefined;
}
}
/**
* Extract a one-line description from a Hermes SOUL.md file.
* Strips HTML comments and Markdown headings, then returns the first
* non-empty line of actual content.
*/
function readHermesSoulDescription(soulPath: string): string | undefined {
try {
const content = fs.readFileSync(soulPath, 'utf8');
// Loop until stable to handle any malformed/nested comment sequences.
let stripped = content;
let previous: string;
do {
previous = stripped;
stripped = stripped
.replaceAll(/<!--[\s\S]*?-->/g, '') // strip complete HTML comments
.replaceAll(/[<>]/g, '') // strip any remaining HTML delimiter chars
.replaceAll(/^#+\s.*$/gm, ''); // strip Markdown headings
} while (stripped !== previous);
const line = stripped
.split('\n')
.map((l) => l.trim())
.find((l) => l.length > 0);
return line || undefined;
} catch {
return undefined;
}
}
function getHermesProfile(): AgentProfileResult {
const profileName = getActiveHermesProfileName();
if (!profileName) return {};
const profilePath = getHermesProfilePath(profileName);
const description = profilePath
? readHermesSoulDescription(path.join(profilePath, 'SOUL.md'))
: undefined;
return {
avatar: '⚡',
description,
title: profileName,
};
}
/**
* Fetch the agent profile (title, avatar, description) from the platform
* installed on this device. Dispatched by the server via `device.getAgentProfile`.
*
* - openclaw: `openclaw agents list --json` for name + emoji, workspace
* IDENTITY.md for description fallback
* - hermes: active profile name + SOUL.md description
*/
export async function getAgentProfile(params: GetAgentProfileParams): Promise<AgentProfileResult> {
const { platform, agentId } = params;
if (platform === 'openclaw') {
return getOpenClawProfile(agentId);
}
if (platform === 'hermes') {
return getHermesProfile();
}
return {};
}
+341
View File
@@ -0,0 +1,341 @@
import { execFileSync, spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { RemoteHeterogeneousAgentType } from '@lobechat/heterogeneous-agents';
import { getTrpcClient } from '../api/client';
import { getTask, listTasks, removeTask, saveTask } from '../daemon/taskRegistry';
import { log } from '../utils/logger';
// ─── Hermes session persistence ───
// Maps topicId → hermes session_id so multi-turn conversations can resume
// the same session across separate `runHeteroTask` invocations.
const LOBEHUB_DIR_NAME = process.env.LOBEHUB_CLI_HOME || '.lobehub';
const HERMES_SESSIONS_FILE = path.join(os.homedir(), LOBEHUB_DIR_NAME, 'hermes-sessions.json');
function getHermesSessionId(topicId: string): string | undefined {
try {
const data = JSON.parse(fs.readFileSync(HERMES_SESSIONS_FILE, 'utf8')) as Record<
string,
string
>;
return data[topicId];
} catch {
return undefined;
}
}
function saveHermesSessionId(topicId: string, sessionId: string): void {
let data: Record<string, string> = {};
try {
data = JSON.parse(fs.readFileSync(HERMES_SESSIONS_FILE, 'utf8')) as Record<string, string>;
} catch {
// File doesn't exist yet — start fresh.
}
data[topicId] = sessionId;
fs.mkdirSync(path.dirname(HERMES_SESSIONS_FILE), { recursive: true });
fs.writeFileSync(HERMES_SESSIONS_FILE, JSON.stringify(data), 'utf8');
}
/** Resolve the absolute path to the `lh` binary to avoid PATH issues in child processes. */
function resolveLhPath(): string {
try {
return execFileSync('which', ['lh'], { encoding: 'utf8' }).trim();
} catch {
return 'lh';
}
}
export interface RunHeteroTaskParams {
agentId?: string;
agentType: RemoteHeterogeneousAgentType;
cwd?: string;
operationId: string;
prompt: string;
taskId: string;
topicId: string;
}
export interface CancelHeteroTaskParams {
signal?: 'SIGINT' | 'SIGKILL' | 'SIGTERM';
taskId: string;
}
async function sendAutoNotify(
topicId: string,
taskId: string,
text: string,
agentId?: string,
): Promise<void> {
try {
const client = await getTrpcClient();
await client.agentNotify.notify.mutate({
agentId,
content: text,
role: 'assistant',
topicId,
});
} catch (err) {
log.error('Failed to send auto-notify:', err instanceof Error ? err.message : String(err));
}
}
/**
* Signal remote hetero task completion to the server so it can publish
* `agent_runtime_end` to the gateway WS and close the frontend subscription.
* Called on clean process exit (code=0, no signal) error exits go through
* `sendAutoNotify` which writes an error message AND triggers completion via
* the `done` flag.
*/
async function sendDoneSignal(topicId: string, agentId?: string): Promise<void> {
try {
const client = await getTrpcClient();
await client.agentNotify.notify.mutate({
agentId,
content: '',
done: true,
role: 'assistant',
topicId,
});
} catch (err) {
log.error('Failed to send done signal:', err instanceof Error ? err.message : String(err));
}
}
/**
* Build the notify protocol injected into the first message of a new hetero-agent session.
* Tells the agent how to push updates back to the LobeHub user via `lh notify`.
*/
function buildNotifyProtocol(lhPath: string, topicId: string): string {
return (
`## Context: This task was dispatched by LobeHub\n\n` +
`This conversation / task was sent to you by the **LobeHub platform** on behalf of a user. You are running as a background agent; the user is waiting for your response inside the LobeHub chat interface.\n\n` +
`**When to call notify**: any time you have something meaningful to tell the user — a key finding, a decision you made, a result, a question, or your final answer. Think of it as speaking directly to the user in the chat window.\n\n` +
`**What to hide**: internal work details such as tool call sequences, file reads, intermediate command output, retries, or low-level reasoning steps. The user cares about outcomes and insights, not your step-by-step mechanics.\n\n` +
`## Sending messages back to the user\n\n` +
`Use the \`${lhPath} notify\` command. All your updates appear as a **single message bubble** in the UI — create it once and update it in place.\n\n` +
`**Step 1 — Open the bubble on your first meaningful update** (captures the messageId):\n` +
`\`\`\`\n` +
`MSG_ID=$(${lhPath} notify --topic ${topicId} --role assistant --content "Starting..." --json | grep -o '"messageId":"[^"]*"' | cut -d'"' -f4)\n` +
`\`\`\`\n\n` +
`**Step 2 — Update the same bubble as you make progress**:\n` +
`\`\`\`\n` +
`${lhPath} notify --topic ${topicId} --role assistant --message-id "$MSG_ID" --content "Still working..."\n` +
`\`\`\`\n\n` +
`**Step 3 — Replace with your complete, final response when done**:\n` +
`\`\`\`\n` +
`${lhPath} notify --topic ${topicId} --role assistant --message-id "$MSG_ID" --content "<your full response here>"\n` +
`\`\`\`\n\n` +
`Rules:\n` +
`- Always use \`--json\` on the first call and capture \`messageId\` from the output.\n` +
`- Always pass \`--message-id\` on every subsequent call so updates overwrite the same bubble.\n` +
`- Write what matters to the user — not implementation steps or internal tool calls.\n` +
`- Call notify at least once when the task is done, even if there were no intermediate updates.`
);
}
export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string> {
const { agentId, agentType, cwd, operationId, prompt, taskId, topicId } = params;
const workDir = cwd || process.cwd();
const lhPath = resolveLhPath();
if (agentType === 'openclaw') {
// openclaw agent --local is one-shot: each invocation processes one message and exits.
// The --session-id links turns into the same conversation history on disk.
// Requires the `openclaw` binary to be on PATH with Node >=22.19.
const openclawAgent = process.env.OPENCLAW_AGENT_ID ?? 'main';
// Always inject the notify protocol so openclaw knows how to report results
// back to the LobeHub UI — even if the previous turn failed and the session
// history was not cleanly committed.
const enrichedPrompt = `${prompt}\n\n${buildNotifyProtocol(lhPath, topicId)}`;
// Kill any existing openclaw process for this topicId before spawning a new one.
// openclaw serialises session writes; a concurrent process holding the session
// lock will cause the new one to exit with code 1.
for (const existing of listTasks()) {
if (existing.topicId === topicId && existing.agentType === 'openclaw') {
try {
process.kill(existing.pid, 'SIGTERM');
} catch {
// Already exited — nothing to do.
}
removeTask(existing.taskId);
}
}
const child = spawn(
'openclaw',
[
'agent',
'--agent',
openclawAgent,
'--session-id',
topicId,
'--message',
enrichedPrompt,
'--local',
],
{
cwd: workDir,
detached: true,
env: { ...process.env },
stdio: 'ignore',
},
);
const pid = child.pid;
if (pid === undefined) {
throw new Error('Failed to get PID for openclaw process');
}
child.unref();
saveTask({
agentId,
agentType,
operationId,
pid,
startedAt: new Date().toISOString(),
taskId,
topicId,
});
log.info(`OpenClaw task started: taskId=${taskId} pid=${pid} agent=${openclawAgent}`);
// On exit: notify the server so it can close the frontend gateway WS subscription.
// - Abnormal exit (signal or non-zero code): write an error message bubble.
// - Clean exit (code=0, no signal): openclaw already sent its final message via
// `lh notify`; just send a done signal to publish `agent_runtime_end`.
child.on('close', (code, signal) => {
removeTask(taskId);
if (code !== 0 || signal !== null) {
const text = signal
? `Task cancelled (signal: ${signal})`
: `Task failed (exit code: ${code})`;
// Send error message first, THEN signal done (sequential).
// Fire-and-forget both, but ensure done is always sent even if notify fails.
void sendAutoNotify(topicId, taskId, text, agentId).finally(() =>
sendDoneSignal(topicId, agentId),
);
} else {
// Clean exit — openclaw already sent its final message; just signal done.
void sendDoneSignal(topicId, agentId);
}
});
return JSON.stringify({ pid, taskId });
}
if (agentType === 'hermes') {
// Kill any existing hermes process for this topicId before spawning a new one.
for (const existing of listTasks()) {
if (existing.topicId === topicId && existing.agentType === 'hermes') {
try {
process.kill(existing.pid, 'SIGTERM');
} catch {
// Already exited — nothing to do.
}
removeTask(existing.taskId);
}
}
// Resume the previous session for this topic if one exists.
const existingSessionId = getHermesSessionId(topicId);
const hermesArgs: string[] = ['chat', '--query', prompt, '--quiet', '--accept-hooks'];
if (existingSessionId) {
hermesArgs.push('--resume', existingSessionId);
}
// Hermes prints "session_id: <id>\n<response>" to stdout in --quiet mode.
// We capture stdout, parse both fields on exit, and relay the response via notify.
const child = spawn('hermes', hermesArgs, {
cwd: workDir,
detached: true,
env: { ...process.env },
stdio: ['ignore', 'pipe', 'ignore'],
});
const pid = child.pid;
if (pid === undefined) throw new Error('Failed to get PID for hermes process');
child.unref();
saveTask({
agentId,
agentType,
operationId,
pid,
startedAt: new Date().toISOString(),
taskId,
topicId,
});
log.info(`Hermes task started: taskId=${taskId} pid=${pid}`);
let stdout = '';
child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString();
});
child.on('close', (code, signal) => {
removeTask(taskId);
if (code !== 0 || signal !== null) {
const text = signal
? `Task cancelled (signal: ${signal})`
: `Task failed (exit code: ${code})`;
void sendAutoNotify(topicId, taskId, text, agentId).finally(() =>
sendDoneSignal(topicId, agentId),
);
return;
}
// Parse "session_id: <id>" from the first line, response from the rest.
const sessionIdMatch = stdout.match(/^session_id:\s*(\S+)/m);
const sessionId = sessionIdMatch?.[1];
const response = stdout.replace(/^session_id:[^\n]*\n?/, '').trim();
if (sessionId) saveHermesSessionId(topicId, sessionId);
if (response) {
void sendAutoNotify(topicId, taskId, response, agentId).finally(() =>
sendDoneSignal(topicId, agentId),
);
} else {
void sendDoneSignal(topicId, agentId);
}
});
return JSON.stringify({ pid, taskId });
}
throw new Error(`Unsupported agentType: ${agentType as string}`);
}
export async function cancelHeteroTask(params: CancelHeteroTaskParams): Promise<string> {
const { signal = 'SIGINT', taskId } = params;
const entry = getTask(taskId);
if (!entry) {
return JSON.stringify({ message: `No task found with taskId: ${taskId}`, success: false });
}
// Both openclaw and hermes: kill by PID and let the child's close handler send the notify.
try {
process.kill(entry.pid, signal);
} catch (err) {
// Process already exited — exit handler won't fire; clean up manually.
log.warn(
`Failed to send ${signal} to pid ${entry.pid}: ${err instanceof Error ? err.message : String(err)}`,
);
removeTask(taskId);
await sendAutoNotify(
entry.topicId,
taskId,
'Task already completed or cancelled',
entry.agentId,
);
}
return JSON.stringify({ pid: entry.pid, signal, taskId });
}

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