Compare commits

...

312 Commits

Author SHA1 Message Date
ONLY-yours b2f936d3d2 🐛 fix(agent-builder): use activeAgentId as tool ctx when scope is agent_builder
The AgentBuilder panel runs with `context.agentId = agentBuilderId` (the
built-in agent builder), but the write tools (updateConfig / updatePrompt /
installPlugin) must operate on the agent **being edited**, not on the builder
agent itself.

`ctx.agentId` in `ClientToolExecutionActionImpl` was always taken from
`operation.context.agentId`, which equals `agentBuilderId` in the
`agent_builder` scope. This was harmless before #14774 because the agent
builder ran in chat mode (tools disabled). After #14774 defaulted
`enableAgentMode` to `true`, the tools started executing and mutated the
builder's own config instead of the target agent's, causing the builder to
respond as a "different / strange agent" on subsequent turns.

Fix: when `scope === 'agent_builder'`, override `toolAgentId` with
`chatStore.activeAgentId` (set by `AgentIdSync` to the URL's `:aid` param —
i.e. the agent the user opened the profile page for).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 20:38:51 +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 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
Innei 746bf4f316 💄 style(intervention): polish confirmation bar layout (#14587) 2026-05-09 22:21:39 +08:00
AmAzing- 58dd297141 chore: Refine homepage banner copy for channels and skills (#14588) 2026-05-09 22:09:18 +08:00
AmAzing- a4e5a20b4d 🛠️ fix: unify SKILL.md frontmatter parsing and edit validation in agent documents (#14566) 2026-05-09 22:04:05 +08:00
LiJian 95f41f8cec 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-09 21:57:50 +08:00
lobehubbot 0516184b45 🔖 chore(release): release version v2.1.57 [skip ci] 2026-05-09 13:36:15 +00:00
lobehubbot f7fbc1c833 Merge remote-tracking branch 'origin/main' into canary 2026-05-09 13:33:21 +00:00
Innei 0f5fb54cb6 🚀 release: 20260509 (#14563)
# 🚀 LobeHub Release (20260509)

**Release Date:** May 9, 2026  
**Since v2.1.56:** 236 merged PRs · 19 contributors

> Agent Task System reaches general availability, the Agent Signal
pipeline runs nightly self-review with skill-aware policies, the
heterogeneous-agent runtime crosses replica boundaries, inline documents
become a first-class context source, and bot platforms expand across
Messager, Line, and Telegram.

---

##  Highlights

- **Agent Task System (GA)** — End-to-end task execution platform:
templates, tracking, comment tools, parent reassignment, scheduled cron,
and dependency-ordered batch runs. (#14540, #14515, #14517, #14272,
#14246, #14418, #14403, #14488)
- **Agent Signal nightly self-review** — Wired self-review loop with
prompt + DB support, exponential-backoff retry on receipt listing,
skill-aware policy, and improved skill-intent detection. (#14543,
#14542, #14281, #14409, #14526, #14437)
- **Inline documents in KB tool** — BM25 search and `docs_*` read for
inline document grounding; agent documents usable as VFS. (#14494,
#14222)
- **Inline agent cards in chat** — `lobeAgents` markdown tag renders
agent profile cards inline; clickable card after `createAgent`. (#14495,
#14493)
- **Heterogeneous agent runtime** — Cloud hetero exec pipeline steps 3+4
land, persistence recovers across Vercel replicas, server-side
ingest/finish handler, and `lh hetero exec` CLI. (#14486, #14539,
#14444, #14431)
- **Bot platforms expand** — Messager, Line, DM pair policy, and
messenger DB tables; Telegram API path restored. (#14442, #14207,
#14211, #14496, #14519)
- **Visual analysis tool** — New visual understanding tool, with trigger
tracking and flattened schema. (#14378, #14399, #14550)
- **DeepSeek V4 Pro as OSS default** — OSS deployments ship with
DeepSeek V4 Pro by default; DeepSeek Anthropic runtime supported.
(#14555, #14312)

---

## 🏗️ Core Agent & Architecture

### Agent Task System

- **Task System GA** — End-to-end execution platform now available.
(#14540)
- **Templates, comments, reparenting** — Template tracking, comment
tools, and parent reassignment. (#14515, #14517, #14488)
- **Cron + dependency-ordered runs** — Scheduled status with cron editor
and dependency-ordered subtask batches. (#14246, #14418, #14272)
- **Inspector + chip UI + batch tasks** — Task Inspector/Render
registry, batch `createTasks`/`runTasks`, and chip-based agent-documents
inspector. (#14403, #14404)
- **Recommend templates regardless of brief count** — Recommendations no
longer suppressed when briefs are sparse. (#14508)
- **Scheduling resilience** — Manual run no longer eats next scheduled
tick; recurring tasks survive brief resolution. (#14304, #14348)
- **Brief synthesis** — Auto-synthesize topic briefs; brief actions
revamp; mute resolved-brief icon on home. (#14324, #14228, #14452)
- **Task list & detail polish** — Topic operation ID exposed; task
drawer Gateway reconnect. (#14282)

### Agent Signal pipeline

- **Nightly self-review wired** — Prompt + DB support for the
self-review loop. (#14543)
- **Self-review activities push to briefs** — Activities during nightly
self-reflection now create briefs. (#14437)
- **Skill management policy** — New policy for Skill management running
inside Agent Signal. (#14281)
- **Skill intent detection & routing** — Improved detection plus direct
intent handling when `hintIsSkill`. (#14409, #14526)
- **Document tool outcome rendering** — Decision view restores missing
document tool outcomes. (#14534)
- **Exponential backoff retry** — Listing signal receipts retries with
jittered backoff. (#14542)
- **Easier-to-use signals** — Structural simplification +
recent-activities surface for receipts. (#14290, #14326, #14407)

### Heterogeneous agent runtime

- **Cloud hetero exec pipeline (steps 3 + 4)** — Refactor lands the next
two stages of the cloud hetero agent execution pipeline. (#14486)
- **Persistence recovery on Vercel** — Hetero state recovered across
replica boundaries. (#14539)
- **Server-side ingest/finish + persistence** — `aiAgent.heteroIngest` /
`heteroFinish` handlers. (#14444)
- **`lh hetero exec` CLI** — Standalone heterogeneous agent runs from
CLI. (#14431)
- **Gateway round-trip loading** — `execAgentTask` keeps the input box
in loading state through the full round-trip. (#14503)
- **Provider SDK type routing** — Provider routing now respects SDK
type. (#14520)
- **DeepSeek reasoning preserved** — `reasoning_content` preserved in
OpenAI-compatible runtime for DeepSeek models. (#14546)

### Knowledge & inline docs

- **KB tool BM25 + docs read** — BM25 search and `docs_*` read
integrated for inline documents. (#14494)
- **Agent documents as VFS** — FS-compatible output for agent documents.
(#14222)
- **`lobeAgents` markdown tag** — Inline agent cards rendered from a
markdown tag. (#14495)
- **Clickable agent card after `createAgent`** — Mentions and
recommendations become clickable. (#14493)
- **ExplorerTree** — Generic tree component built on `@pierre/trees` for
reusable explorer surfaces. (#14094)
- **Local file mention snapshots** — Mentions can now snapshot local
files. (#14278)

### Architecture

- **Agent Hono routes** — New agent routes added on Hono. (#14535)
- **`/api/agent` migrated to Hono** — Remaining `/api/agent` routes
finish their migration. (#14478)
- **Agent marketplace merged into web-onboarding** — Reduces package
fragmentation. (#14514)
- **Producer pipeline extracted** — Shared package for the producer
pipeline. (#14425)
- **`agentDispatcher.selectRuntimeType`** — New runtime selection
abstraction. (#14428)
- **pnpm v11 migration** — Workspace consolidated. (#14316)
- **Browser-compatible frontmatter parser** — Replaces `gray-matter`.
(#14435)

---

## 📱 Platforms & Integrations

- **Messager support** — New messager package wired into the chat
surface. (#14442)
- **Messenger DB tables** — IM bot integration gains its persistence
layer. (#14496)
- **Line bot** — Initial Line support and downstream optimization.
(#14207, #14448)
- **DM pair policy** — Group/DM pair-based delivery. (#14211)
- **Telegram API restored** — Missing Telegram API path reconnected.
(#14519)
- **xAI Responses tools stabilized** — Plus unsupported parameter
handling. (#14462, #14445)
- **Volcengine websearch via ResponseAPI** — Built-in websearch for
Volcengine. (#14216)

---

## 🤖 Models & Providers

- **DeepSeek V4 Pro default for OSS** — OSS distribution defaults to
DeepSeek V4 Pro. (#14555)
- **DeepSeek Anthropic runtime** — Anthropic-shape runtime support for
DeepSeek. (#14312)
- **GPT-5.5 / GPT-5.5 Pro** — New OpenAI tier. (#14142)
- **Grok 4.20 / Grok 4.3 / LobeHub-hosted Grok 4.3** — (#14253, #14382,
#14446)
- **Gemma 4 + provider settings normalization** — (#13313)
- **gpt-image-2 + step-image-edit-2** — (#14253, #14329)
- **Model bank refresh + original-pricing display** — Batch model
updates and pricing surfaces. (#14070, #14391)
- **Hunyuan migrated to TokenHub for Hy3 Preview** — (#14108)
- **Reject lobehub model ids no longer in the bank** — (#14261)
- **Hide runtime-only aliases** — Runtime-only model aliases no longer
leak into the model picker. (#14552)

---

## 🖥️ User Experience

### Onboarding

- **Shared prefix steps** — Language and privacy extracted as shared
prefix steps. (#14538)
- **Identity intervention card simplified** — Plus tool result renders
cleanup. (#14505, #14506)
- **Welcome polish + web-onboarding tool UI** — (#14475)
- **Templates fetched from market API** — (#14286)
- **Virtual model id for default onboarding model** — (#14311)
- **Skip / mode-switch footer behind feature flag** — Footer guarded for
desktop and web initialization. (#14560)

### Home & navigation

- **Home recents performance** — Recents refresh periodically and inline
task status; brief and task-template fetch overhead trimmed. (#14518,
#14516)
- **Home refactor + skill-connect recommendations** — Restructured home
with skill-connect recommendation system. (#14266, #14214)
- **Tasks in agent sidebar** — Tasks moved from welcome card into the
sidebar list. (#14500)
- **Sidebar collapse persists** — Home sidebar collapse state stored.
(#14473)
- **Agent-specific topic grouping** — Plus improved empty state and
agent identity in topic search. (#14225)
- **MentionMenu scroll fix** — Mention menu no longer clips inside chat
input. (#14533)

### Conversation & chat

- **Follow-up chips fill input** — Clicking a follow-up chip now fills
the input instead of sending immediately. (#14536)
- **Quick-reply chips below assistant messages** — (#14350)
- **Inline single-tool assistant group + leading sentence promotion** —
(#14244)
- **Assistant-group rendering** — Per-segment content overrides flow
into MessageContent. (#14504)
- **Tool call timer fix** — Timer no longer resets when tool calls
collapse or expand. (#14513)
- **Streaming re-render reduction** — Reference stabilization and
self-subscribing components. (#14470)
- **Topic chat drawer feedback input** — (#14392)

### Skills, agents, devtools

- **Managed skill folders** — Agent view displays managed skill folders
and aligns delete confirmations. (#14553)
- **Review tab + bulk git diffs** — New Review tab with bulk diffs;
gating uses effective working directory. (#14334, #14512)
- **Devtools gallery rebuild** — Plus Review polish, queue-tray images.
(#14423)
- **Agent mock devtools** — Playback & fixture viewer. (#14436)

### Desktop & CLI

- **App tray visibility setting** — (#14463)
- **Notification settings in desktop** — (#14491)
- **Multimodal input across CLI / shared spawn / desktop** — (#14433)
- **CLI bot + userId guide** — (#14258)

---

## 🔧 Tooling

- **Visual analysis tool** — New visual understanding tool with
flattened schema. (#14378, #14550)
- **GitHub marketplace tool UI** — (#14420)
- **Drop "Local" prefix and `____builtin` suffix from tool names** —
(#14364, #14289)
- **Sanitize provider tool names** — Avoids invalid characters from
external providers. (#14510)
- **Generation moderation context** — Moderation context passed through
the generation pipeline. (#14541)
- **Visual analysis trigger tracking** — (#14399)
- **Claude thinking signature sanitization** — History signatures
sanitized when replaying Claude conversations. (#14499)
- **Responses input media sanitization** — Assistant media sanitized in
Responses input. (#14497)

---

## 🔒 Security & Reliability

- **Security:** Removed the `/webapi/proxy` route and dead URL-manifest
plugin code to shrink the SSRF surface. (#14549)
- **Security:** Sessions revoked after password reset. (#14424)
- **Reliability:** Added `prompt_cache_key` to OpenAI chat requests for
stable cache hits. (#14349)
- **Reliability:** `onFinish` now fires even when the browser tab is
backgrounded mid-SSE stream. (#14461)
- **Reliability:** Better-auth session refetch preserves user fields
rather than overwriting them. (#14531)
- **Reliability:** User-memory queries sanitize backticks; user-memory
errors now explicitly injected so failures stay visible. (#14524,
#14525)
- **Reliability:** Auth captcha retries handled; input loading unsticks
on `auth_failed` and recoverable `auth_expired`. (#14346, #14419)
- **Reliability:** Trace snapshot finalized on error path. (#14440)
- **Reliability:** Drop `switchTopic` race under rapid sidebar clicks.
(#14115)
- **Reliability:** PDF chunking logic fixed to prevent vectorization
failure. (#14327)
- **Performance:** Marketplace fork uses a batched API for parallel
installs. (#14537)
- **Performance:** Review tab open latency cut ~9× on large dirty trees.
(#14338)

---

## 👥 Contributors

Huge thanks to **18 contributors** who shipped **236 merged PRs** this
cycle.

@hezhijie0327 · @sxjeru · @yueyinqiu · @octo-patch · @hardy-one ·
@Coooolfan · @CanYuanA · @BillionClaw · @arvinxx · @tjx666 · @Innei ·
@Neko · @AmAzing129 · @Rdmclin2 · @LiJian · @sudongyuer · @rivertwilight
· @cy948

Plus @lobehubbot for i18n and translation maintenance.

---

**Full Changelog**:
https://github.com/lobehub/lobe-chat/compare/v2.1.56...release/weekly-20260509
2026-05-09 21:30:37 +08:00
Innei feaaaba2a9 💄 style(settings): remove image avatar from lab input markdown rendering item (#14582) 2026-05-09 21:15:02 +08:00
YuTengjing 21f6f94bed 🐛 fix: polish task agent manager (#14569) 2026-05-09 20:58:29 +08:00
AmAzing- b180c03e04 feat: migrate Notion to LobeHub Market (#14578)
Migrate Notion to LobeHub Market
2026-05-09 20:55:26 +08:00
Arvin Xu 0d39dff2d5 🐛 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-09 20:47:21 +08:00
LiJian 6fb24adbd2 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-09 20:39:14 +08:00
Innei a09991af8c 📝 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-09 20:32:44 +08:00
YuTengjing 4c76d2430f 🐛 fix: remove signin captcha flow (#14573) 2026-05-09 19:49:04 +08:00
Innei 8ed31dfca4 🐛 fix(docker): replace pnpm init with static package.json in /deps (#14576)
`pnpm init` writes `devEngines.packageManager: { version: "^11.0.9" }`
into the generated package.json. corepack@latest rejects ranges in this
field with "Invalid package manager specification ... expected a semver
version", causing the subsequent `pnpm add pg drizzle-orm` to exit 1.

Skip init and write a minimal package.json directly so corepack has
nothing to validate.
2026-05-09 19:36:09 +08:00
YuTengjing c374892fea 🐛 fix: add temporary email auth error locale (#14564) 2026-05-09 18:50:32 +08:00
Rdmclin2 4617468e87 🐛 fix: add bot callback service (#14570)
fix: add bot callback service
2026-05-09 17:45:34 +07:00
LiJian 4c3a71a2c3 🐛 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-09 18:19:31 +08:00
Arvin Xu 7892e553ea 💄 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-09 18:13:24 +08:00
YuTengjing 793a8deb43 💄 style: update auth captcha retry copy (#14561) 2026-05-09 17:35:03 +08:00
Rdmclin2 e56ccf6a5c 🐛 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-09 16:31:21 +07:00
Innei 9756daba2d 🐛 fix(onboarding): guard skip/mode-switch footer with feature flag, desktop & init checks (#14560)
- Only show the skip-and-switch footer when all conditions are met:
  AGENT_ONBOARDING_ENABLED, not desktop, server config initialized,
  and runtime enableAgentOnboarding flag is on
- Fix typo: swichMode → switchMode
- Expand tests with hoisted mocks covering each visibility condition
2026-05-09 17:09:12 +08:00
AmAzing- 2b165ec722 🎨 Refine Agent Signal receipt cards (#14558)
*  Refine agent document skill trees and delete confirms

* 🐛 fix: improve receipt card accessibility
2026-05-09 16:41:57 +08:00
YuTengjing 8105fc0b16 feat: set OSS default model to DeepSeek V4 Pro (#14555) 2026-05-09 16:36:02 +08:00
YuTengjing 2d3332200a 🐛 fix: hide runtime-only model aliases (#14552) 2026-05-09 15:53:15 +08:00
Arvin Xu cb8645f65a 🐛 fix(security): remove /webapi/proxy and dead URL-manifest plugin code (#14549)
* 🐛 fix(security): remove /webapi/proxy and dead URL-manifest plugin code

Closes #14530. The /webapi/proxy endpoint was an unauthenticated open
HTTP proxy. All client callers were dead except NewAPI provider's
browser-side pricing fetch, which now silently falls back to no-pricing
since `parsePricingResponse` already handles non-OK responses.

Removes:
- /webapi/proxy route + API_ENDPOINTS.proxy
- toolService.getToolManifest (+ packages/utils/src/toolManifest.ts)
- src/features/PluginDevModal/UrlManifestForm.tsx
- uploadService.getImageFileByUrlWithCORS
- non-MCP branch in customPlugin reinstall (silently returns for
  legacy URL-manifest plugin data)

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

* 🔥 chore(model-runtime): drop /webapi/proxy hop in NewAPI pricing fetch

The browser branch routed pricing requests through /webapi/proxy to bypass
CORS. Now that the proxy is removed, fetch the upstream pricing endpoint
directly — if CORS or any other error blocks it, fall through to the
existing null fallback (NewAPI just renders without enriched pricing).

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

*  test(model-runtime): drop console.debug assertion in NewAPI pricing fetch

The pricing-network-error case used to assert that console.debug was
called; with the log removed, just assert the graceful fallback (no
pricing on the resulting model). Also tightens an adjacent
branch-coverage test that ESLint flagged for a useless assignment.

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 14:50:31 +08:00
YuTengjing cef69e9b72 🐛 fix: flatten visual analysis tool schema (#14550) 2026-05-09 14:42:53 +08:00
LiJian d0b938a0cb 🐛 fix: recover hetero persistence state across Vercel replicas (#14539)
* 🐛 fix: recover hetero persistence state across Vercel replicas

Three-part fix for multi-replica split-brain on Vercel serverless:

1. Flush accumulated content to DB after every ingest batch so a
   replica switch mid-accumulation doesn't lose text chunks.
2. Persist `heteroCurrentMsgId` to topic.metadata on every step
   boundary so new replicas restore the correct currentAssistantMessageId.
3. Restore toolMsgIdByCallId from DB on state creation so tool_results
   landing on a different replica than their tool_use are still matched.

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

* fix: add the test fixed

* fix: slove the some topic problem

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 14:36:48 +08:00
AmAzing- af319af936 🐛 fix(agent): display managed skill folders and align delete confirms (#14553)
* 🐛 fix: display managed skill folders and align delete confirms

* 🐛 fix: allow recovery for orphan managed skill bundles

*  test: cover agent document group recovery paths

* 🐛 fix: render empty state for hidden skill indexes

*  test: relax agent signal hydration timeout
2026-05-09 14:32:46 +08:00
Innei 4ebd8f7f7c ♻️ refactor(onboarding): extract language and privacy as shared prefix steps (#14538)
* ♻️ refactor(onboarding): extract language and privacy as shared prefix steps

Move the language-selection and privacy/telemetry consent out of the classic
flow into a shared prefix that runs at /onboarding before branching into either
the agent or classic experience. Welcome decoration is merged with language
selection on a single screen, dropping the total step count by one.

Shared-prefix completion is derived from raw stored settings
(s.settings.general.responseLanguage and telemetry), so no new schema fields
are introduced and existing consumers that rely on the merged-default
telemetry value are unaffected.

Branch routing remains automatic (feature flag + isDesktop check) and is now
encapsulated in deriveOnboardingBranchPath. Both branch routes guard against
entering before the shared prefix is complete.

MAX_ONBOARDING_STEPS drops from 5 to 3 (FullName, Interests, ProSettings).

* ♻️ refactor(onboarding): use original Telemetry + ResponseLanguage as shared steps

Revert the merged welcome+language design. The shared prefix now reuses the
original two classic steps as-is:
- Step 1: TelemetryStep (welcome decoration + privacy/telemetry consent)
- Step 2: ResponseLanguageStep (language selection)

Also suppress the mode-switch + skip footer on the bare /onboarding path so
it only appears once the user has entered the agent or classic branch.

* 🐛 fix(onboarding): persist shared-prefix step in URL to survive locale-triggered remounts

Use react-router's useSearchParams to keep the active shared step in the URL
(?step=2). Local useState was lost when switching language for the first time
because i18next's first-time resource load triggers a remount up the tree;
the URL param survives any remount.

* 🐛 fix(onboarding): unblock branch redirect when user accepts default telemetry

Derive commonStepsCompleted from responseLanguage alone. setSettings strips
fields whose value matches DEFAULT_COMMON_SETTINGS, so accepting the default
telemetry: true left s.settings.general.telemetry undefined and the derive
selector never flipped to true — the redirect to the branch never fired.

Step 2 (language) implies step 1 was completed because the flow is sequential,
so checking responseLanguage alone is sufficient and robust against the
default-strip behavior.

* 🐛 fix(onboarding): redirect after step 2 by deriving completion from responseLanguage only

setSettings strips fields that match defaultSettings, so writing
telemetry=true (the default) never persists to s.settings.general.
That made commonStepsCompleted permanently false even after the user
finished both steps, blocking the redirect to the branch flow.

Drop telemetry from the derive check. Step 1 completion is already
tracked via the URL ?step=2 marker; step 2 completion is the only
event that needs to flip commonStepsCompleted, signalled by writing
responseLanguage (which always differs from the default since
DEFAULT_COMMON_SETTINGS has no responseLanguage entry).

* 🔨 chore(scripts): add reset-onboarding script for redoing the flow

Takes an email, clears users.onboarding, agent_onboarding, full_name,
interests and removes responseLanguage + telemetry from
user_settings.general so the user re-enters the shared-prefix
onboarding from step 1.

Usage:
  pnpm workflow:reset-onboarding <email>
  bunx tsx scripts/resetOnboarding/index.ts <email>

* 🐛 fix(signup): add refs for email and password inputs to improve focus handling

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

* 🐛 fix(onboarding): skip responseLanguage auto-fill while onboarding is in progress

useInitUserState's onSuccess callback auto-fills general.responseLanguage
from navigator.language whenever the field is missing. For new users
this fired immediately after signup, which made commonStepsCompleted
(which derives from responseLanguage being set) flip to true on first
load, and CommonOnboardingPage's early-redirect skipped past the shared
prefix straight into /onboarding/agent.

Gate the auto-fill on onboarding.finishedAt or agentOnboarding.finishedAt
being set, so legacy users who finished onboarding without
responseLanguage still get the safety-net detection, but in-progress
users keep the field undefined until they explicitly choose it on the
language step.

* 🐛 fix(onboarding): refresh welcome message locale until conversation starts

ensureWelcomeMessage previously only created the welcome on first call
and skipped on subsequent ones, leaving stale welcomes locked to the
locale that was active when the topic was first created. After the
shared-prefix refactor users pick their language earlier than they
used to, so the welcome that was generated during the auto-detect
phase never gets re-translated.

Now the welcome content is rewritten in-place to match the current
responseLanguage as long as no user reply has been recorded yet
(message count <= 1). Once the conversation has started, the welcome
is left as part of the chat history.

* 🐛 fix(onboarding): update welcome message handling to render client-side and avoid persisting during onboarding

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

* Refactor onboarding user profile handling: remove responseLanguage field

- Removed responseLanguage from SaveUserQuestionInput and related schemas.
- Updated onboarding logic to no longer save or request responseLanguage.
- Adjusted related components and services to reflect the removal of responseLanguage.
- Enhanced user info handling to include displayName and fullName from OAuth.
- Updated tests to align with the new onboarding structure.

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

* refactor(onboarding): update locale handling to use i18n's resolved language

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

* 🐛 fix(onboarding): remap legacy 5-step classic currentStep on shared-prefix mount

Mid-flow legacy users with persisted currentStep authored under the old
5-step classic flow (Telemetry, FullName, Interests, Language, ProSettings)
would silently skip required profile steps after the renumbering: old
step 2 (FullName) rendered Interests, old step 3 (Interests) rendered
ProSettings. Apply a one-time remap (2->1, 3->2, >=4->MAX) when Common
mounts, gated by isUserStateInit and onboarding.finishedAt absence so it
fires only for in-flight legacy users. Idempotent for new-schema values.

* refactor(onboarding): implement AGENT_ONBOARDING_ENABLED master switch for onboarding flow

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

* refactor(onboarding): standardize AGENT_ONBOARDING_ENABLED naming in tests

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

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-05-09 14:31:50 +08:00
Arvin Xu de698eef92 feat: Agent Task System available (#14540)
* 🔥 chore: remove agent_task feature flag and graduate task feature

Drop the agent_task / enableAgentTask gate that was guarding the agent
task rollout. The feature is now permanently enabled, so all flag
checks, disabled-state redirects, and disabled-only fallback UI
(SuggestQuestions, CommunityAgents) are removed.

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

* 🐛 fix(brief): create regular task instead of cron job from template card

The "Add task" button on DailyBrief recommendation cards was creating an
agentCronJob (scheduled recurring job). Switch to taskService.create via
the createTask store action so it creates a one-off inbox task and
refreshes the task list, matching user expectation that the click adds
a task rather than a schedule.

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

*  feat(task): support schedule fields on task.create

The brief recommendation card needs to create a recurring scheduled
task in one shot (template carries `cronPattern`). Extend `task.create`
to accept `automationMode`, `schedulePattern`, `scheduleTimezone`, and
thread them through the service + store action. The model already
accepts these via NewTask, and the central schedule-dispatch sweep
picks the task up once status is dispatchable.

TaskTemplateCard now creates a schedule-mode task with the template's
cron pattern and the user's local timezone, restoring the recurring
behavior previously provided by AgentCronJob.

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

* 💄 i18n(home): shorten brief.title from "Daily brief" to "Brief"

Daily-frequency tasks are no longer the only source feeding the section
(scheduled, manual, and on-demand briefs all flow through it now), so
the more general label fits better.

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

* 💄 style(task-list): show skeleton instead of blank while task list loads

Both the list view (TaskList) and kanban view (KanbanBoard / KanbanColumn)
returned null until isInit, leaving the page empty during the first SWR
fetch. Render a TaskItemSkeleton (default + compact variants) to keep the
layout stable and signal that data is loading.

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

* 💄 style(git-status): toggle review panel on diff-stat click

Clicking the diff-stat chip used to always open the review panel — if
the panel was already showing review, the click was a no-op. Switch to
a toggle: clicking again with the review tab active closes the panel,
matching the implicit expectation that the chip is the entry/exit
control for that view.

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

*  test(brief): update TaskTemplateCard test for createTask flow

Card now calls useTaskStore.createTask with schedule fields instead of
agentCronJobService.create. Replace the agentCronJob service mock with
a useTaskStore mock exposing createTask, and assert the schedule-mode
payload (automationMode + schedulePattern + scheduleTimezone) on the
success path.

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

* 💄 style(brief): jump to task detail after creating from template

The success toast asked users to look in the inbox agent for the new
scheduled task; navigating directly to the task detail is a clearer
landing for what they just confirmed. Drop the toast and route to
`/task/<identifier>` once createTask resolves.

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 13:07:15 +08:00
YuTengjing c03e79c118 🐛 fix: pass generation moderation context (#14541) 2026-05-09 11:27:11 +08:00
Arvin Xu aef7158f4a 🐛 fix(model-runtime): preserve reasoning_content for deepseek models in OpenAI-compat layer (#14546)
DeepSeek thinking-mode (deepseek-reasoner / deepseek-v4-*) rejects follow-up
turns when assistant history messages omit reasoning_content. Until now this
was only enforced in the dedicated DeepSeek runtime's handlePayload; users
routing deepseek model ids through any other OpenAI-compatible runtime hit a
400 with "The reasoning_content in the thinking mode must be passed back to
the API."

Move the safety net into convertOpenAIMessages so any OpenAI-compatible call
with a deepseek-named model derives reasoning_content from reasoning.content
and forces an empty placeholder for thinking-eligible models.

Fixes LOBE-8290

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:53:18 +08:00
Neko be42e056e6 feat(agent-signal,prompts,database): nightly self-review wired, improved (#14543) 2026-05-09 07:16:54 +08:00
Neko b47e32436e ️ perf(agent-signal,app): exp backoff retry of listing signal receipts (#14542) 2026-05-09 04:25:17 +08:00
Neko 85b412270b 🐛 fix(agent-signal,server): missing document tool outcome rendering into decision agent (#14534)
Emit agent document tool outcome events from client-triggered agent document tools with tool attribution so hinted skill documents can be observed by Agent Signal.

Hydrate client runtime completion back to the completed assistant message for pre-created assistant turns, allowing same-turn hinted document receipts to match the originating user message.

Harden agent document snapshot reads by falling back to markdown content when stale editor data cannot be projected for decision evidence.
2026-05-09 04:08:06 +08:00
Arvin Xu 0e216dec8e 💄 style: fill input on follow-up chip click instead of sending (#14536)
* 💄 style: fill input on follow-up chip click instead of sending

Mirrors the NameSuggestions pattern so users can edit a suggested
follow-up before sending, matching onboarding interaction conventions.

*  test: update FollowUpChips click test for input-fill behavior

Mock updateInputMessage + editor (setDocument/focus) instead of
sendMessage and assert the new fill-input flow.

* 💄 style: move branching action into the message "..." menu

Surface "branching" inside the dropdown menu (right after copy) for
assistant, assistantGroup, and user messages, instead of as an inline
toolbar icon gated behind dev mode. Drops the dev-mode bar override and
renames the now-only ACP-related selector binding to isHeteroAgent.
2026-05-09 01:33:52 +08:00
sxjeru 1d2db96a38 🐛 fix: add prompt_cache_key for OpenAI chat requests (#14349) 2026-05-09 01:15:34 +08:00
Innei 4dade3196f ️ perf(market): batch fork API for parallel marketplace install (#14537)
Rewrite the onboarding marketplace install pipeline from a serial per-agent
loop to a parallel pipeline anchored on a batched fork call. Multi-select
in the picker now finishes in roughly four parallel rounds instead of
~5N sequential round-trips.

- forkAgent tRPC now takes { items: AgentForkBatchInput[] } and returns
  per-item AgentForkBatchResult (discriminated union, best-effort: a single
  failure does not abort the batch). The upstream market endpoint stays
  per-id, fanned out via Promise.all on the server.
- installMarketplaceAgents fans out dedupe, detail fetch, and createAgent
  steps via Promise.all/allSettled and consolidates into one batched fork.
- ForkAndChat (community single-fork action) wraps its call as a 1-item
  batch and unwraps the per-item result.
2026-05-09 01:02:49 +08:00
LiJian f934e2ff46 ♻️ refactor: implement cloud hetero agent exec pipeline (step 3 + step 4) (#14486)
* refactor: add the cloud hetero execAgent Runtime way

*  feat: support session resume for heterogeneous agents (Claude Code / Codex)

- Expose `sessionId` getter on `SpawnAgentHandle` (read from `AgentStreamPipeline`)
- Pass `sessionId` to `IngestSink.finish()` so CLI reports it via `heteroFinish`
- Server stores `heteroSessionId` in topic metadata after each turn
- Server reads and passes `resumeSessionId` as `--resume` on subsequent turns
- Remove debug `console.log` statements from aiAgent service and sandboxRunner

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

* fix: slove some bugs

* fix: add the is dev back

* 🐛 fix: add async to handleAgentRunRequest in gatewayConnectionSrv

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 00:28:27 +08:00
Innei 1bc8d59922 💄 fix(chat-input): fix MentionMenu scroll area clipping caused by container padding (#14533)
💄 fix(chat-input): fix MentionMenu scroll area clipping with negative margin padding trick
2026-05-09 00:10:05 +08:00
Arvin Xu 8fab0b014e 💄 style: polish onboarding interventions and add tool result renders (#14506)
*  feat: add collapse toggle to onboarding mode switch toolbar

The dev-mode actions pill at the bottom-right of the onboarding page
covered the operation area below it. Add a chevron toggle so users can
collapse the pill down to a single icon button. Collapsed state is
persisted in localStorage so it survives reloads.

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

*  feat: make name and avatar editable in onboarding intervention card

Lets the user override the agent's proposed identity in-place before
approving — pick a different emoji from the avatar picker, type into
the name field, and the edits flow through registerBeforeApprove ->
onArgsChange so the actual save uses the user's values.

Other changes:
- Title is now derived from the live edit state, so adding a missing
  field flips the wording from "I'll update my name" to "I'll update my
  name and avatar" without staleness
- Subtitle hint ("如果不满意,可以直接修改名字或头像") tells the user
  the card is interactive
- Test covers the edit-flush path: edits to name + emoji are observed
  via onArgsChange when the framework triggers the beforeApprove flush

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

* 💄 style: redesign intervention approval card as codex-style options

Drops the inline approve / reject button row in favor of a numbered
two-option layout with a single Submit at the bottom-right, mirroring
Codex's approval picker. The reject row's content is the reason input
itself (placeholder doubles as the row label) so users can type a
follow-up instruction in place; reason flows through to the existing
rejectAndContinueToolCall(messageId, reason) action.

Behavior:
- Default selection is approve; arrow keys (↑/↓) and 1/2 switch options
- Enter submits when no input is focused; reject input has its own
  Enter / ↑ handlers so typing doesn't hijack the picker
- Window-level shortcuts skip while any input/textarea/contenteditable
  is focused, so the main chat composer is never affected
- approvalMode='allow-list' adds a "Don't ask again for similar actions"
  checkbox under option 1, replacing the old split-button dropdown

Also tighten the onboarding intervention editHint copy from
"如果不满意,可以直接修改名字或头像" to "你可以直接在下方修改名字或头像"
(positive framing instead of conditional).

i18n changes (default + en-US + zh-CN):
- Add optionApprove, rememberSimilar, submit
- Repurpose rejectReasonPlaceholder as the inline reject row's placeholder
- Drop now-unused approveAndRemember, approveOnce, rejectAndContinue,
  rejectTitle keys

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

* 💄 style: tighten PickAgents card layout

- Move avatar and title into a single row (cardHeader) so the agent
  template title sits next to the avatar instead of below it; description
  stays as a multi-line block beneath
- Switch card border from colorBorderSecondary to colorFillSecondary so
  the card outline is visible when sitting on the elevated picker panel
- Mirror the row layout in the loading Skeleton so the shimmer matches

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

*  feat(agent-marketplace): add Inspector for showAgentMarketplace and submitAgentPick

The marketplace tool was previously falling back to the generic raw-args
"等 N 个参数" header. Add per-API Inspectors:

- showAgentMarketplace: title + up to 3 localized category chips
  (sourced from existing CATEGORY_LABEL_I18N_KEYS in tool namespace);
  overflow shown as +N
- submitAgentPick: title + selected agent count

Wire AgentMarketplaceInspectors into builtin-tools/src/inspectors.ts
under AgentMarketplaceManifest.identifier and export from the package's
agentMarketplace/client surface.

i18n adds (default + en-US + zh-CN tool namespace):
- agentMarketplace.inspector.pickCount plurals
- agentMarketplace.inspector.moreCategories plurals

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

* 💄 style: rename showAgentMarketplace label to "Assemble agent team"

The agent narrates intent ("组建 Agent 团队" / "Assemble agent team")
rather than describing a UI surface ("打开助手市场" / "Open agent
marketplace"), which reads more naturally in the inspector header
during onboarding.

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

* 💄 style: hide chat/page view switcher in agent conversation header

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

*  feat(agent-marketplace): render picked agent cards from pluginState

Adds a SubmitAgentPick Render that shows a grid of agent cards (avatar +
title + description + "already in library" tag) instead of the raw text
content the LLM consumes. Also wires the framework so custom-interaction
handlers can return structured pluginState alongside toolResultContent.

Framework changes:
- submitToolInteraction(options) now accepts a pluginState field. After
  writing toolResultContent, the chat store calls
  optimisticUpdatePluginState so the message's structured state is
  available to render components (matching how server-executed builtin
  tools persist state)
- Cloud-side wrapper in Conversation/store/slices/tool/action.ts
  forwards the new field
- customInteractionHandlers.ts SubmitToolInteractionOptions adds
  pluginState; handleAgentMarketplaceSubmit returns the install
  summaries via pluginState (same shape that built the LLM-facing text)

Marketplace changes:
- InstallMarketplaceAgentSummary gains an avatar field; the install
  helper threads marketAgent.avatar through
- New Render/SubmitAgentPick reads pluginState.summaries to draw a
  responsive card grid (already-in-library entries dimmed + tagged)
- Wire AgentMarketplaceRenders through the package's
  agentMarketplace/client surface and register under
  AgentMarketplaceManifest.identifier in builtin-tools/src/renders.ts

Workflow display labels (collapsed grouped tool row):
- Add showAgentMarketplace ("Assembled agent team" / "组建了 Agent 团队")
  and submitAgentPick ("Picked agents" / "选好了助手") to
  TOOL_API_DISPLAY_NAMES so the collapsed group no longer falls back to
  "Show Agent Marketplace" / "Submit Agent Pick" via toTitleCase

i18n adds (default + en-US + zh-CN):
- tool.agentMarketplace.render.alreadyInLibrary plurals + alreadyInLibraryTag
- chat.workflow.toolDisplayName.{showAgentMarketplace,submitAgentPick}

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

*  feat(web-onboarding): add UpdateDocument render with hunk diff

Replace the raw "Updated persona document (id). Applied N hunk(s)."
text with a structured per-hunk diff view rendered from args.hunks
(no executor state changes — args already carry the patches).

For each hunk render a mode label + line range chip and paint the
affected text:
- replace: removed (red border) → added (green border)
- delete: removed only
- insertAt: green block + L<line> chip
- replaceLines: green block + line range chip
- deleteLines: line range chip only (no body)

The total hunk count piggy-backs on the first hunk's label row instead
of getting its own header (the inspector header chip already shows
total + doc type, so a separate render-side header would be redundant).

i18n adds builtins.lobe-web-onboarding.updateDocument.hunkMode.{replace,
delete,deleteLines,insertAt,replaceLines} across default + en-US +
zh-CN.

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 00:08:24 +08:00
Rdmclin2 507909dc2c feat: add agent hono routes (#14535)
feat: add agent hono routes
2026-05-08 22:31:47 +07:00
YuTengjing 4721d14a81 🐛 fix: trim brief / task-template fetch overhead on home (#14516) 2026-05-08 23:06:22 +08:00
YuTengjing e1a5b27db0 feat(task): add comment tools and reparent support (#14515) 2026-05-08 22:42:10 +08:00
Innei 03621d0664 feat(explorer-tree): add generic ExplorerTree component built on @pierre/trees (#14094)
*  feat(explorer-tree): introduce generic ExplorerTree component

Scaffold a reusable tree component at `src/features/ExplorerTree/`
built on top of `@pierre/trees`. The component exposes a typed
`ExplorerTreeNode<TData>[]` input (tree or flat+parentId),
path-driven identity hidden behind an adapter, and a minimal
imperative handle (startRenaming, focus, select, setExpanded,
getSelectedIds).

Wired v1 capabilities:
- multi-select (default* + onChange), uncontrolled + ref
- DnD abstracted as `onMove(MoveEvent)` with canDrag/canDrop gates
- declarative right-click menu via `getContextMenuItems` rendered
  through the library's `renderContextMenu` slot
- inline rename via `canRename`/`onCommitRename`/`onRenameError`
- trailing row decorations via `getRowDecoration`
- built-in icon set driven by file extensions

Old `src/features/FileTree/` is tagged `@deprecated` so consumers
can migrate gradually (SkillStore, LibraryHierarchy, WorkingSidebar).
No consumers migrated in this PR — that is tracked as a follow-up.

Design spec: docs/superpowers/specs/2026-04-23-explorer-tree-design.md

* 📝 docs: add ResourceManager ExplorerTree refactor design

* ♻️ refactor(explorer-tree): use id-based tree contracts

* ♻️ refactor(explorer-tree): narrow transitional tree types

* ♻️ refactor(explorer-tree): align transitional prop contracts

* ♻️ refactor(explorer-tree): remove future-only transitional types

* ♻️ refactor(explorer-tree): support controlled id state

* 🐛 fix(explorer-tree): suppress controlled sync feedback

* 🐛 fix(explorer-tree): reconcile controlled ids on stable paths

*  feat(resource): add tree snapshot derivation

*  feat(resource): add tree mutation helpers

* 🐛 fix(resource): harden tree mutation rollback boundaries

*  feat(resource): add tree controller

* 🐛 fix(resource): guard tree controller request ordering

*  feat(resource): add tree route and bridge modules

* 🐛 fix(resource): harden tree route bridge boundaries

* ♻️ refactor(explorer-tree): expose row host events

* ♻️ refactor(resource): wire hierarchy to ExplorerTree

* ♻️ refactor(resource): remove global tree store

* 🐛 fix(resource): revalidate tree mutations by source parent

* 🐛 fix(spa): prebundle explorer tree dependency

* ♻️ refactor(sharedRendererConfig): remove unused dependencies '@pierre/trees' and '@pierre/trees/react'

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

* ♻️ revert(resource): remove business integration, keep ExplorerTree component only

Revert all ResourceManager business integration while preserving the
generic ExplorerTree component implementation:

- Restore ResourceManager component files to canary state
- Restore src/store/tree/ (deleted by integration commit)
- Remove src/features/ResourceManager/tree/ (controller, mutations, bridge)
- Keep src/features/ExplorerTree/ (generic component)
- Keep @pierre/trees dependency in package.json

*  feat(agent): integrate ExplorerTree into agent documents section

- Replace flat document list with ExplorerTree for 'documents' filter tab
- Convert flat AgentDocument[] to tree nodes via parentId/fileType
- Add tree node click handler (navigate/open) and context menu (delete)
- Fix height chain: ResourcesSection flex:1 -> AgentDocumentsGroup -> ExplorerTree
- Style ExplorerTree via --trees-*-override CSS vars (transparent bg, relaxed density, theme tokens)

* ♻️ refactor(resource-manager): remove outdated ExplorerTree design document

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

*  feat(agent-documents): wire context menu and DnD via base-ui imperative API

- Replace nested antd Menu surface with @lobehub/ui showContextMenu, capturing right-click on the tree host directly so menu actions (rename, create, delete) survive base-ui focus restoration
- Fix DnD root drop by routing canDrop through directoryPath instead of hoveredPath, so dragging a nested file onto empty root no longer treats the hovered file row as the target zone

* ♻️ refactor(DocumentExplorerToolbar): adjust padding styles for better layout

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

*  feat(useDocumentTreeOps): integrate confirmModal for delete confirmation

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

* 🐛 fix(ExplorerTree): cast through unknown to satisfy antd MenuItem types

*  feat(AgentDocumentsGroup.test): add mock for DocumentExplorerTree and update tests for document count

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

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-05-08 22:34:20 +08:00
YuTengjing fcc5aa181a 🐛 fix: preserve user fields on better-auth session refetch (#14531) 2026-05-08 22:14:05 +08:00
Rdmclin2 4d934f8275 🐛 fix: telegram api lost (#14519)
* fix: bot message callback

* fix: add telegram timeout error

* Potential fix for pull request finding 'CodeQL / Incomplete multi-character sanitization'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Potential fix for pull request finding 'CodeQL / Double escaping or unescaping'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-05-08 20:40:32 +07:00
Neko c760171f49 🐛 fix(agent-signal,types,prompts,server): should handle skill intent directly when hintIsSkill on, and reroute the source signal, or otherwise it will be hard to have skill triggers (#14526) 2026-05-08 20:14:07 +08:00
YuTengjing c7b7717faa 🐛 fix: support provider sdk type routing (#14520) 2026-05-08 20:03:08 +08:00
YuTengjing 385afbcc57 ️ perf: refresh home recents periodically and inline task status (#14518) 2026-05-08 19:32:42 +08:00
Neko d051ac008c 🐛 fix(database,userMemories): should sanitize for ` or otherwise memory search can easily fail (#14524) 2026-05-08 19:30:31 +08:00
Neko 9b2832bba9 🐛 fix(server,userMemories): should have user memory errors explicitly injected (#14525) 2026-05-08 19:30:17 +08:00
Innei 9b5cea7391 ♻️ refactor: merge agent-marketplace into web-onboarding package (#14514)
* ♻️ refactor: merge agent-marketplace into web-onboarding package

Move the standalone `builtin-tool-agent-marketplace` package into
`builtin-tool-web-onboarding/src/agentMarketplace/` as a sub-module
to reduce package sprawl and consolidate related onboarding tooling.

Also adds locale-aware fetching for onboarding agent templates:
- Accept optional `locale` param in `getOnboardingFull` TRPC endpoint
- Pass normalized i18next locale from the client fetcher
- Add unit test for locale resolution

* ♻️ refactor: integrate FollowUpChips into ChatItem and update GroupMessage components

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

* fix: address Codex review feedback for PR #14514

- Make getOnboardingFull input schema optional with default to preserve
  backward compatibility for callers that invoke .query() without arguments
- Parameterize SWR cache key by resolved locale to prevent cross-locale
  cache pollution in the PickAgents marketplace component

* chore: remove accidentally pushed .kagura directory and add to .gitignore

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-05-08 19:08:39 +08:00
Rdmclin2 f7f8bc625f 🐛 fix: tsc error (#14521)
fix: tsc error
2026-05-08 17:34:03 +07:00
YuTengjing 83bc73c2ae feat: add task template tracking (#14517) 2026-05-08 17:48:46 +08:00
Rdmclin2 75fd477bff feat: support messager (#14442)
* feat: support messagers

* chore: refactor lobeai to messager prefix

* feat: reigister messager platforms

* feat: support slack messager

* fix: verify im route redirect

* fix: link page style

* chore: optimize agent select and /agents commands

* feat:support lab switch

* feat: use same  agent select

* chore: add runtime error info

* chore: optimize error text

* feat: add slack messagger installation implementation

* chore: add more scope

* feat: add slack messager account link

* fix: open slack in a new link

* feat: optimze messager link page

* feat: optimize messager locales and bot options

* chore: optimize messager

* fix: slack integration detail

* fix: avoid taking over and fix slash commands

* chore: optimize slack app setup

* chore: update slack manifest and setup

* feat: support discrod platform

* feat: discord messger slash commands and agent picker

* chore: update discord messager

* feat: support db bot provider credentials

* chore: remove message router ensure  connected

* chore: remove notes field

* chore: add applicationId and credentails

* chore: squash db migations

* chore: remove installedAt and linkedAt field

* chore: remove messager releated env variables

* chore: remove old skill bot skill

* feat: add operationId when throwing error

* chore: abstract platform clients and registery

* chore: fix link modal message i18n and add platform definition name field

* feat: add integration detail

* feat: add platfom definition i18n files

* chore: abstract messenger router platform branches

Collapse parallel Slack/Discord slash & action paths in MessengerRouter
into a single command registry + binder hooks (replyPrivately,
extractActionFromEvent, acknowledgeCallback). Wire Discord /start by
resolving DM via openDM(authorUserId) so a public-channel slash invocation
posts the link privately.

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

* chore: update installation and oauth process for discord and slack

* fix: telegram local button

* chore: remove messager docs

* feat: add discord installation process

* chore: remove discord bot username

* chore: adjust discord integration detail

* feat: extract platfom specific implementation

* chore: handle connection flow and redirect

* feat: add platform router for messager

* chore: move messager to agents group

* chore: update i18n files

* chore: update messager table sql

* chore: update messager sql

* fix: link with tenantId

* chore: move messger verify page to features/Messager

* chore: refactor messager verify page

* Potential fix for pull request finding 'Property access on null or undefined'

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>

* fix: Rebind by platform user when confirming messenger link

* chore: remove unnecessary journals

* chore: update i18n files

* fix: lint error and i18n

* fix: test cases

* chore: add lost test cases

* chore: try cpus 2

* chore: try remove optimize package import

* chore: fallback define config

* chore: try to reduce OOM

* chore: fallback

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-05-08 16:27:16 +07:00
1839 changed files with 123881 additions and 33716 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
-298
View File
@@ -1,298 +0,0 @@
---
name: bot
description: 'Bot platform architecture (Discord, Slack, Telegram, Feishu/Lark, QQ, WeChat). Use when working on inbound webhooks, Chat SDK message routing, agent execution from chat platforms, queue-mode callbacks, gateway lifecycle (websocket/polling), bot provider CRUD/credentials, or platform-specific clients/adapters/schemas. Triggers on bot, channel, webhook, mention, Chat SDK, agent bot provider, gateway, bot-callback, qstash bot.'
---
# Bot System
> **Last updated: 2026-04-08.** Implementation evolves quickly — this doc is a map, not the source of truth. Always read the key files below to verify behavior, especially per-platform quirks. Update this doc when the architecture changes.
LobeChat agents can answer inside external chat platforms. Inbound messages flow through the Chat SDK (`chat` npm package), get routed to the right agent by `(platform, applicationId)`, executed via `AiAgentService`, and replied back through a per-platform `PlatformClient`. There are **two execution modes** (in-memory vs queue/QStash) and **three connection modes** (`webhook`, `websocket`, `polling`).
## Supported Platforms
| Platform | id | Default mode | Markdown | Edit | Notes |
| -------- | ---------- | ------------------------------- | ----------------- | ------ | -------------------------------------------------------------------------------------- |
| Discord | `discord` | `websocket` | yes | yes | Persistent gateway via Chat SDK adapter; reaction-thread quirks; native slash commands |
| Slack | `slack` | `websocket` (Socket Mode) | yes (mrkdwn) | yes | Multi-mode — user can pick `webhook` per provider |
| Telegram | `telegram` | `webhook` | yes (HTML) | yes | `setMyCommands` menu via `registerBotCommands` |
| Feishu | `feishu` | `websocket` (Lark SDK WSClient) | **no** (stripped) | yes | Multi-mode; shared client with Lark |
| Lark | `lark` | `websocket` | **no** | yes | Same client/schema as Feishu, different domain |
| QQ | `qq` | `websocket` | **no** | **no** | All replies are final-only |
| WeChat | `wechat` | `polling` (iLink long-poll) | **no** | **no** | 10-minute gateway window |
`supportsMarkdown=false` ⇒ outbound markdown is stripped to plain text via `stripMarkdown` and the AI is told not to use markdown. `supportsMessageEdit=false` ⇒ no progress edits — only the final reply is sent.
**Multi-mode connection** — Slack/Feishu/Lark/QQ ship as websocket but support `webhook` per-provider via `settings.connectionMode`. The runtime always merges schema defaults into stored settings before resolving the mode (`resolveBotProviderConfig` / `resolveConnectionMode` in `platforms/utils.ts`), so the schema's `field.default` is the source of truth — set it correctly when adding a new multi-mode platform.
## Inbound Flow (one webhook → reply)
```
Platform server
│ POST /api/agent/webhooks/[platform]/[appId]
route.ts ── catch-all `[[...appId]]` route
BotMessageRouter (singleton)
│ • lazy-loads bot per `platform:applicationId`
│ • merges schema defaults + provider.settings (mergeWithDefaults)
│ • builds Chat SDK Chat<any> with createIoRedisState (if Redis available)
│ • registerHandlers: onNewMention / onSubscribedMessage / onNewMessage(/.dm)
│ • registerCommands: /new (reset topic), /stop (interrupt)
chatBot.webhooks[platform](req) ← Chat SDK parses → fires events
AgentBridgeService.handleMention / handleSubscribedMessage
│ • activeThreads guard (no duplicate runs per thread)
│ • adds 👀 reaction (eyes), startTyping
│ • merges debounced/queued skipped messages (mergeSkippedMessages)
│ • extractFiles (buffer → fetchData → url)
│ • formatPrompt (sanitize mention + speaker tag + referenced_message)
├── In-memory mode ──► AiAgentService.execAgent({ stepCallbacks })
│ → onAfterStep edits progress message live
│ → onComplete edits final reply, splits via splitMessage(charLimit)
└── Queue mode (isQueueAgentRuntimeEnabled) ──► execAgent({ stepWebhook, completionWebhook, webhookDelivery: 'qstash' })
→ returns immediately, callbacks land at /api/agent/webhooks/bot-callback
```
The router caches loaded bots in memory. Cache is **invalidated** by `BotMessageRouter.invalidateBot(platform, appId)` whenever the TRPC `update`/`delete` mutations run, so new credentials/settings take effect on the next webhook.
## Execution Modes
### In-memory (default)
`AgentBridgeService.executeWithInMemoryCallbacks` wraps `execAgent` with `stepCallbacks`. Lives in one process — Promise-based wait, 30-min timeout, edits the same `progressMessage` after every step. Topic title is summarized inline via `SystemAgentService`.
### Queue (`isQueueAgentRuntimeEnabled`)
`AgentBridgeService.executeWithWebhooks`:
1. Posts the `renderStart` placeholder, captures `progressMessageId`.
2. Calls `execAgent` with `stepWebhook` and `completionWebhook` pointing at `${INTERNAL_APP_URL ?? APP_URL}/api/agent/webhooks/bot-callback`, plus `webhookDelivery: 'qstash'`.
3. Returns immediately; the bridge `finally` block keeps the active-thread marker held until the `completion` callback fires.
`POST /api/agent/webhooks/bot-callback` (`src/server/agent-hono/handlers/botCallback.ts`) verifies the QStash signature via the `qstashAuth` middleware and hands off to `BotCallbackService.handleCallback`:
- `type: 'step'``handleStep` re-renders `renderStepProgress`, edits `progressMessageId` (skipped if `displayToolCalls=false` or platform `supportsMessageEdit=false`).
- `type: 'completion'``handleCompletion` writes the final reply (or error/interrupted message), removes the 👀 reaction, clears active-thread tracker, fires async `summarizeTopicTitle`.
`BotCallbackService.createMessenger` reloads provider + credentials from DB and rebuilds a `PlatformClient` per call (no in-memory state).
## Commands
Defined in `BotMessageRouter.buildCommands` and registered via two paths:
- **Native slash commands** (Slack/Discord): `bot.onSlashCommand('/<name>', ...)`
- **Text-based fallback** (Telegram/Feishu/QQ/Lark/WeChat): `bot.onNewMessage(/^\/(new|stop)(\s|$|@)/, ...)` plus a per-mention `tryDispatch` so commands work even before subscribe.
Built-in commands:
- `/new` — clears `topicId` in thread state, next message starts a fresh topic.
- `/stop` — interrupts the active execution (calls `AiAgentService.interruptTask` if `operationId` is known; otherwise queues a deferred stop via `requestStop`/`pendingStopThreads`, also aborts the startup phase via `startupControllers`).
To add a command, append to `buildCommands` — it auto-registers everywhere; on Telegram it also surfaces in the `/` menu via `client.registerBotCommands``setMyCommands`.
## Active-thread State (statics on `AgentBridgeService`)
- `activeThreads: Set<threadId>` — prevents duplicate runs per thread (must guard before stale-topic check, otherwise concurrent messages can drop).
- `activeOperations: Map<threadId, operationId>` — needed by `/stop` once `execAgent` returns.
- `startupControllers: Map<threadId, AbortController>` — cancels pre-`operationId` work (topic/tool prep).
- `pendingStopThreads: Set<threadId>``/stop` arrived before `operationId` existed; consumed once available.
In **queue mode**, the bridge `finally` skips cleanup so the marker persists until `BotCallbackService.handleCompletion` calls `clearActiveThread`.
## Topic Lifecycle in Threads
- `handleMention` always treats the message as the start of a new conversation.
- `handleSubscribedMessage` reads `topicId` from `thread.state`. If the topic is stale (`> 4 hours` since `updatedAt`), state is cleared and it retries as a fresh mention.
- If `execAgent` fails with a Postgres FK violation on `topic_id` (cached topic was deleted), the bridge clears state and retries as a mention.
- `subscribe()` is gated by `client.shouldSubscribe(threadId)` — Discord top-level channels return `false` so we don't follow up there.
## Attachments
`AgentBridgeService.extractFiles` resolves attachments in priority order:
1. `att.buffer` — already downloaded by the adapter (WeChat/Feishu inbound).
2. `att.fetchData()` — adapter-provided lazy download with auth (Telegram, Slack, Feishu history). **Required** when URLs are token-protected — naive `fetch(url)` later in `ingestAttachment.ts` has no credentials.
3. `att.url` — public CDN fallback (Discord, public QQ).
`inferMimeType` / `inferName` patch Telegram-style `photo` payloads (no `mimeType`/`name` from Bot API → defaults to `image/jpeg`) so vision models actually see them. Quoted-message attachments are also pulled from `raw.referenced_message.attachments` (Discord).
## Concurrency
`settings.concurrency` is `'queue'` or `'debounce'`:
- `debounce` → Chat SDK debounces inbound messages by `debounceMs`; `mergeSkippedMessages` joins skipped texts/attachments into the current message before handing to the agent.
- `queue` → Chat SDK serializes per-thread; the bridge's own `activeThreads` set is still required because in queue mode the SDK lock releases before the agent finishes.
## Gateway (persistent platforms)
Webhook platforms run fine in serverless functions. Persistent platforms (`websocket`, `polling`) need a long-running listener — that's the **gateway**.
**`GatewayService.startClient(platform, appId, userId)`** (`src/server/services/gateway/index.ts`):
- On Vercel + persistent mode → `BotConnectQueue.push` (Redis hash) and mark runtime status `queued`. The cron picks it up.
- On Vercel + webhook mode → start the client inline (one HTTP call).
- Off-Vercel → `GatewayManager` singleton holds long-lived clients in process.
**`GET /api/agent/gateway`** (`src/server/agent-hono/handlers/gatewayCron.ts`, cron, `Bearer ${CRON_SECRET}`):
- Iterates registered platforms and starts every enabled persistent provider with `durationMs = 10min`, then in `after(...)` polls `BotConnectQueue` every 30s for new connect requests, until the window expires.
- `getEffectiveConnectionMode(platform, settings)` is the only place that resolves per-provider mode — respect it everywhere.
**`POST /api/agent/gateway/start`** (`src/server/agent-hono/handlers/gatewayStart.ts`) is the non-Vercel `ensureRunning` entry point (`Bearer ${KEY_VAULTS_SECRET}`).
**Runtime status** is stored in Redis at `bot:runtime-status:platform:appId` with TTL ≈ `durationMs + 60s`. States: `starting | connected | disconnected | failed | queued`. Updated by each `PlatformClient.start/stop` and by the gateway service.
## Platform Definitions
Each platform exposes a `PlatformDefinition` registered in `platforms/index.ts`:
```ts
{
id: 'discord',
name: 'Discord',
connectionMode: 'websocket', // recommended default
schema: FieldSchema[], // applicationId + credentials + settings
clientFactory: new DiscordClientFactory(),
supportsMarkdown?: boolean, // default true
supportsMessageEdit?: boolean, // default true
documentation?: { portalUrl, setupGuideUrl },
}
```
`schema` drives both server validation (`mergeWithDefaults`, `extractDefaults`) **and** the auto-generated UI form. Top-level keys `applicationId` / `credentials` / `settings` map to DB columns. Common settings fields live in `platforms/const.ts` (`displayToolCallsField`, `makeServerIdField(platform?)`, `makeUserIdField(platform?)`). The `serverId` / `userId` factories take a platform identifier so the field's hint can render platform-specific "how to find this ID" guidance (Discord Developer Mode, Telegram @userinfobot, etc.); pass no argument to fall back to generic copy.
Each platform implements `PlatformClient` (see `platforms/types.ts`):
- Lifecycle: `start(opts?)`, `stop()`
- Inbound: `createAdapter()` → Chat SDK adapter map
- Outbound: `getMessenger(platformThreadId)``{ createMessage, editMessage, removeReaction, triggerTyping, updateThreadName? }`
- Formatting: `formatMarkdown?`, `formatReply?` (usage-stats footer when `showUsageStats`)
- Helpers: `extractChatId`, `parseMessageId`, `sanitizeUserInput`, `shouldSubscribe`, `resolveReactionThreadId`
- Optional patches: `applyChatPatches(chatBot)` (Discord uses this for `forwardedInteractions` + `threadRecovery`)
- Optional menu: `registerBotCommands(commands)` (Telegram `setMyCommands`)
`ClientFactory.validateCredentials` is called from the TRPC `testConnection` mutation — implement it to hit the platform API and return useful per-field errors.
## Database
**Schema** (`packages/database/src/schemas/agentBotProvider.ts`):
```ts
agent_bot_providers (
id uuid pk,
agent_id text fk agents.id (cascade),
user_id text fk users.id (cascade),
platform varchar(50), // 'discord' | 'slack' | …
application_id varchar(255),
credentials text, // KeyVaults-encrypted JSON
settings jsonb default '{}',
enabled boolean default true,
timestamps
)
unique (platform, application_id)
```
**Model** (`packages/database/src/models/agentBotProvider.ts`):
- User-scoped: `create / update / delete / query / findById / findByAgentId / findEnabledByApplicationId`. Credentials are encrypted/decrypted via the injected `KeyVaultsGateKeeper`.
- Static (system-wide): `findByPlatformAndAppId`, `findEnabledByPlatform` — used by webhook routing & gateway sync, since they don't have a user context yet.
**TRPC router** (`src/server/routers/lambda/agentBotProvider.ts`):
| Procedure | Notes | |
| -------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------ |
| `listPlatforms` | Returns `SerializedPlatformDefinition[]` (no `clientFactory`) | |
| `create` / `update` / `delete` | Calls `BotMessageRouter.invalidateBot` + `GatewayService.stopClient` so changes take effect | |
| `list` / `getByAgentId` / `getRuntimeStatus` | Decorate rows with Redis runtime status | |
| `connectBot` | Returns \`{ status: 'started' | 'queued' }\` |
| `testConnection` | Calls `clientFactory.validateCredentials` | |
| `wechatGetQrCode` / `wechatPollQrStatus` | iLink onboarding flow | |
Client service: `src/services/agentBotProvider.ts`. Store actions: `src/store/agent/slices/bot/action.ts`. UI: `src/routes/(main)/agent/channel/{list,detail}` — settings form is auto-generated from each platform's `schema`.
## Reply Templates
`src/server/services/bot/replyTemplate.ts` exports `renderStart`, `renderStepProgress`, `renderFinalReply`, `renderError`, `renderStopped`, `splitMessage`. Step progress carries elapsed time, last LLM content, last tools, totals; final reply uses `client.formatMarkdown` then `client.formatReply` (which optionally appends `formatUsageStats`). `splitMessage(text, charLimit)` chunks at paragraph → line → hard cut.
`src/server/services/bot/ackPhrases/` provides randomized ack phrases.
## Key Files
```plaintext
Webhook routes (mounted via `src/app/(backend)/api/agent/[[...route]]/route.ts` → `src/server/agent-hono`):
src/server/agent-hono/handlers/platformWebhook.ts — inbound catch-all (POST /webhooks/:platform/:appId?)
src/server/agent-hono/handlers/botCallback.ts — qstash bot callback
src/server/agent-hono/handlers/gatewayCron.ts — cron gateway (10min window)
src/server/agent-hono/handlers/gatewayStart.ts — non-Vercel ensureRunning
Bot service:
src/server/services/bot/index.ts — barrel
src/server/services/bot/BotMessageRouter.ts — lazy bot loading + handler registration + commands
src/server/services/bot/AgentBridgeService.ts — Chat SDK ↔ AiAgentService bridge, both exec modes
src/server/services/bot/BotCallbackService.ts — qstash callback handler
src/server/services/bot/formatPrompt.ts — speaker tag + referenced_message + sanitize
src/server/services/bot/replyTemplate.ts — render*/splitMessage
src/server/services/bot/ackPhrases/ — randomized acks
src/server/services/bot/__tests__/ — unit tests for the above
Platform abstraction:
src/server/services/bot/platforms/index.ts — registry singleton + exports
src/server/services/bot/platforms/types.ts — PlatformClient/Definition/FieldSchema/ClientFactory
src/server/services/bot/platforms/registry.ts — PlatformRegistry class
src/server/services/bot/platforms/utils.ts — mergeWithDefaults, getEffectiveConnectionMode, formatUsageStats, runtimeKey
src/server/services/bot/platforms/const.ts — shared FieldSchema fragments (displayToolCalls, serverId, userId)
src/server/services/bot/platforms/stripMarkdown.ts — used by no-markdown platforms
Per-platform (each ships definition.ts, schema.ts, client.ts, const.ts, protocol-spec.md):
src/server/services/bot/platforms/discord/ — websocket gateway + chat patches
src/server/services/bot/platforms/slack/ — multi-mode (Socket Mode / webhook), markdownToMrkdwn
src/server/services/bot/platforms/telegram/ — webhook, markdownToHTML, registerBotCommands
src/server/services/bot/platforms/feishu/ — feishu + lark share client/schema (definitions/{feishu,lark,shared}.ts)
src/server/services/bot/platforms/qq/ — websocket, no markdown, no edit
src/server/services/bot/platforms/wechat/ — long-poll, no markdown, no edit
Gateway:
src/server/services/gateway/index.ts — GatewayService (Vercel-aware startClient/stopClient)
src/server/services/gateway/GatewayManager.ts — long-running client registry (non-Vercel)
src/server/services/gateway/botConnectQueue.ts — Redis hash queue with TTL
src/server/services/gateway/runtimeStatus.ts — Redis bot:runtime-status keys
Database:
packages/database/src/schemas/agentBotProvider.ts — agent_bot_providers table
packages/database/src/models/agentBotProvider.ts — encrypted CRUD + system-wide finders
TRPC + client:
src/server/routers/lambda/agentBotProvider.ts — TRPC router
src/services/agentBotProvider.ts — client wrapper
src/store/agent/slices/bot/action.ts — Zustand actions
UI:
src/routes/(main)/agent/channel/list.tsx — channel list
src/routes/(main)/agent/channel/detail/ — auto-generated form (Header/Body/Footer)
src/routes/(main)/agent/channel/const.ts — platform icons
Types & runtime status:
src/types/botRuntimeStatus.ts — BOT_RUNTIME_STATUSES enum + snapshot type
```
## Adding a New Platform
1. Create `src/server/services/bot/platforms/<id>/`:
- `definition.ts``PlatformDefinition` registered in `platforms/index.ts`
- `schema.ts``FieldSchema[]` (`applicationId` + `credentials` + `settings`); reuse fragments from `../const.ts`
- `client.ts``class XClientFactory extends ClientFactory` returning a `PlatformClient` (lifecycle + adapter + messenger + helpers)
- `const.ts``DEFAULT_X_CONNECTION_MODE`, history limits, etc.
- `protocol-spec.md` — protocol notes (every existing platform has one)
2. Pick the right `connectionMode` — webhook is much simpler if the platform supports it.
3. If the platform can't render markdown, set `supportsMarkdown: false` and implement `formatMarkdown` via `stripMarkdown`.
4. If it can't edit messages, set `supportsMessageEdit: false``BotCallbackService` will skip step edits and only send the final reply.
5. Implement `validateCredentials` so the UI's "Test connection" button gives useful errors.
6. Add the platform icon in `src/routes/(main)/agent/channel/const.ts` and register the platform in `src/server/services/bot/platforms/index.ts`.
7. Add i18n keys under `channel.*` in `src/locales/default/setting.ts` (or wherever the channel namespace lives) — the schema's `label`/`description`/`placeholder`/`enumLabels` are i18n keys.
+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,27 @@ 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. **只有结构化结果才需要 Render。** 如果工具结果只是自然语言总结,通常不需要 Render;如果结果包含列表、媒体、文件、表格、代码、diff、地图、时间线、权限请求等结构,就应该提供 Render。
5. **Render 要帮助用户检查结果,而不是复述参数。** Render 的主体应该围绕工具产物组织:可预览、可比较、可筛选、可定位。参数只作为上下文辅助出现,不要把 Render 做成一块更大的 args dump。
6. **参数和结果要一起参与渲染。** 好的 Tool UI 通常同时用 `args` 解释意图,用 `pluginState` 展示真实执行结果;但 `pluginState` 只放结果域数据,不要反向塞入可以从 `args` 推导出的内容。
7. **慢操作要有 Placeholder。** 如果工具通常需要等待网络、文件系统、模型或外部进程,Placeholder 应该先占住最终 Render 的版式,让用户知道即将看到什么,而不是只显示一个泛化 loading。
8. **Streaming 只用于连续产物。** 搜索列表、日志、长文本、文件分析、分阶段计划适合 Streaming;一次性小结果不需要强行做 Streaming。Streaming UI 要能渐进追加,并且完成后自然过渡到最终 Render。
9. **有风险的动作必须 Intervention。** 写文件、删除、发送、安装、执行命令、外部可见操作、权限敏感操作,都应该在执行前给出可理解的确认界面;确认文案要说明影响范围,而不是只问 “是否继续”。
10. **错误、空态和截断都是正式状态。** Render 不能在失败、无结果、超长结果时退化成空白。错误要说明发生在哪一步;空态要告诉用户没有产物;超长内容要明确 “展示前 N 项 / 还有 N 项”。
11. **信息密度要克制。** 默认展示最有判断价值的部分:标题、来源、状态、摘要、少量关键字段。大对象、长列表、原文、调试数据放进可展开区域或 Portal,避免把聊天流撑成后台管理页。
12. **视觉上融入聊天流。** Tool UI 应该使用 `@lobehub/ui` / base-ui、`Flexbox``createStaticStyles``cssVar.*`,遵循现有间距、圆角、颜色、字号;不要为单个工具发明一套独立视觉语言。
13. **Devtools fixture 是验收入口。** 新增或修改 Tool UI 时,应在 `/devtools` 里准备覆盖典型态、loading/streaming、空态、错误态、长内容态的 fixture;一个 API 如果在真实聊天里会出现,就不应该在 devtools 中缺席。
14. **先做用户会看的 UI,再做调试 UI。** Raw JSON、trace、schema、内部 id 可以存在,但应默认收起或放到调试区;主界面先回答用户最关心的问题:工具做了什么,结果值不值得信任,下一步能做什么。
---
## 0. Shared Style Rules
These apply across every surface.
+1
View File
@@ -8,6 +8,7 @@ description: >
(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.
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
---
File diff suppressed because it is too large Load Diff
@@ -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>;
};
```
+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
---
+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
@@ -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
---
+80 -130
View File
@@ -1,10 +1,15 @@
---
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.
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).
@@ -13,7 +18,7 @@ Open-source, modern-design AI Agent Workspace: **LobeHub** (previously LobeChat)
- Web desktop/mobile
- Desktop (Electron)
- Mobile app (React Native) - coming soon
- Mobile app (React Native) **separate repo, already launched** (not in this monorepo)
**Logo emoji:** 🤯
@@ -38,147 +43,92 @@ 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
This is a monorepo extending the open-source `lobehub` submodule. Two repos:
- **cloud repo root** — `src/` and `packages/business/` (`config`, `const`, `model-runtime`) hold cloud-only SaaS code that overrides/extends the submodule. See `AGENTS.md` for the override mechanism.
- **`lobehub/` submodule** — the open-source product core.
### `lobehub/` submodule — key directories
```
lobehub/
├── 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
│ ├── agent-signal/ # Agent Signal pipeline
── builtin-tool-*/ # Builtin tool packages
│ ├── builtin-tools/ # Builtin tool registries
│ ├── 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}
│ ├── 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 (~50) overridden by cloud src/business/
├── features/ # Domain business components
├── store/ # ~28 zustand stores — `ls` for the full set
├── server/ # featureFlags, globalConfig, modules, routers, services
└── ... # components, hooks, layout, libs, locales, services, types, utils
```
### cloud repo — key directories
```
(cloud root)
├── packages/business/ # Cloud overrides: config, const, model-runtime
├── src/
│ ├── business/ # Cloud impls of submodule stubs (client/server/locales)
│ ├── routes/ # Cloud-only route groups: (cloud)/, embed/
│ ├── store/ # Cloud-only stores (e.g. subscription/)
│ ├── server/ # Cloud routers & services (billing, budget, risk control...)
│ └── app/(backend)/cron/ # Vercel cron routes (schedules declared in root vercel.ts)
└── vercel.ts # Cron schedule declarations
```
> File search rule: a path like `@/store/x` resolves cloud `src/store/x` first, then
> `lobehub/packages/store/src/x`, then `lobehub/src/store/x`. Cloud override wins.
## Architecture Map
| Layer | Location |
| ---------------- | --------------------------------------------------- |
| UI Components | `src/components`, `src/features` |
| SPA Pages | `src/routes/` |
| React Router | `src/spa/router/` |
| Global Providers | `src/layout` |
| Zustand Stores | `src/store` |
| Client Services | `src/services/` |
| REST API | `src/app/(backend)/webapi` |
| tRPC Routers | `src/server/routers/{async\|lambda\|mobile\|tools}` |
| Server Services | `src/server/services` (can access DB) |
| Server Modules | `src/server/modules` (no DB access) |
| Feature Flags | `src/server/featureFlags` |
| Global Config | `src/server/globalConfig` |
| DB Schema | `packages/database/src/schemas` |
| DB Model | `packages/database/src/models` |
| DB Repository | `packages/database/src/repositories` |
| Third-party | `src/libs` (analytics, oidc, etc.) |
| Builtin Tools | `src/tools`, `packages/builtin-tool-*` |
| Cloud-only | `src/business/*`, `packages/business/*` |
| Layer | Location |
| ---------------- | ---------------------------------------------------- |
| UI Components | `src/components`, `src/features` |
| SPA Pages | `src/routes/` |
| React Router | `src/spa/router/` |
| Global Providers | `src/layout` |
| Zustand Stores | `src/store` |
| Client Services | `src/services/` |
| REST API | `src/app/(backend)/webapi` |
| tRPC Routers | `src/server/routers/{async\|lambda\|mobile\|tools}` |
| Server Services | `src/server/services` (can access DB) |
| Server Modules | `src/server/modules` (no DB access) |
| Feature Flags | `src/server/featureFlags` |
| Global Config | `src/server/globalConfig` |
| DB Schema | `packages/database/src/schemas` |
| DB Model | `packages/database/src/models` |
| DB Repository | `packages/database/src/repositories` |
| Third-party | `src/libs` (analytics, oidc, etc.) |
| Builtin Tools | `src/tools`, `packages/builtin-tool-*` |
| Cloud-only | `src/business/*`, `packages/business/*` (cloud repo) |
## Data Flow
+71 -70
View File
@@ -1,94 +1,95 @@
---
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: 'Use when writing or editing any `.tsx` under `src/**`. Triggers: createStaticStyles, createStyles, cssVar, antd-style, Flexbox, Center, Select, Modal, Drawer, Button, Tooltip, DropdownMenu, Popover, Switch, ScrollArea, Link, useNavigate, react-router-dom, next/link, desktopRouter, componentMap.desktop, .desktop.tsx, new component, new page, edit layout, add styles, zustand selector, @lobehub/ui, antd import.'
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) |
+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.'
user-invocable: false
---
# Review Checklist
+5 -4
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/.
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.
+70 -380
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.
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` — 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
+7 -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,7 @@ 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`)
## UI and Theming
@@ -51,7 +57,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,
},
},
);
```
+22 -260
View File
@@ -1,10 +1,14 @@
---
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 `references/`.
## Scope Boundary (Important)
This skill is only for:
@@ -28,68 +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 |
| ----- | ---------------------------------------------- | --------------------- | -------------- | ------------------------------------ | ------------- |
| Minor | Feature iteration release | \~Every 4 weeks | canary | `🚀 release: v{x.y.0}` | Manually set |
| Patch | Weekly release / hotfix / model / DB migration | \~Weekly or as needed | canary or main | Custom (e.g. `🚀 release: 20260222`) | Auto patch +1 |
| 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` |
## Minor Release Workflow
Used to publish a new minor version (e.g. `v2.2.0`), roughly every 4 weeks.
### Steps
1. **Create a release branch from canary**
```bash
git checkout canary
git pull origin canary
git checkout -b release/v{version}
git push -u origin release/v{version}
```
2. **Determine the version number** — Read the current version from `package.json` and compute the next minor version (e.g. 2.1.x -> 2.2.0)
3. **Create a PR to main**
```bash
gh pr create \
--title "🚀 release: v{version}" \
--base main \
--head release/v{version} \
--body "## 📦 Release v{version} ..."
```
> \[!IMPORTANT]
> The PR title must strictly match the `🚀 release: v{x.y.z}` format. CI uses a regex on this title to determine the exact version number.
4. **Automatic trigger after merge**: `auto-tag-release` detects the title format and uses the version number from the title to complete the release.
### Scripts
```bash
bun run release:branch # Interactive
bun run release:branch --minor # Directly specify minor
```
## Patch Release Workflow
Version number is automatically bumped by patch +1. There are 4 common scenarios:
| Scenario | Source Branch | Branch Naming | Description |
| ------------------- | ------------- | ----------------------------- | ------------------------------------------------ |
| Weekly Release | canary | `release/weekly-{YYYYMMDD}` | Weekly release train, canary -> main |
| Bug Hotfix | main | `hotfix/v{version}-{hash}` | Emergency bug fix |
| New Model Launch | canary | Community PR merged directly | New model launch, triggered by PR title prefix |
| DB Schema Migration | main | `release/db-migration-{name}` | Database migration, requires dedicated changelog |
All scenarios auto-bump patch +1. Patch PR titles do not need a version number. See `reference/patch-release-scenarios.md` for detailed steps per scenario.
### Scripts
```bash
bun run hotfix:branch # Hotfix scenario
```
For writing the release-note body (any release type), see `references/release-notes-style.md`.
## Auto-Release Trigger Rules (`auto-tag-release.yml`)
@@ -127,7 +75,7 @@ PRs that don't match any conditions above (e.g. `docs`, `chore`, `ci`, `test`) w
When the user requests a release:
### Precheck
### Precheck (applies to all release types)
Before creating the release branch, verify the source branch:
@@ -135,204 +83,18 @@ Before creating the release branch, verify the source branch:
- **All other release/hotfix branches**: must branch from `main`; run `git merge-base --is-ancestor main <branch> && echo OK`
- If the branch is based on the wrong source, recreate from the correct base
### Minor Release
### Routing
1. Read `package.json` to get the current version and compute the next minor version
2. Create a `release/v{version}` branch from canary
3. Push and create PR — **title must be `🚀 release: v{version}`**
4. Inform the user that merge will auto-trigger release
Pick the right reference and follow it end-to-end:
### Patch Release
- **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`
Choose workflow by scenario (see `reference/patch-release-scenarios.md`):
### Hard Rules (apply to every release type)
- **Weekly Release**: create `release/weekly-{YYYYMMDD}` from canary; use `git log main..canary` for release note inputs; title like `🚀 release: 20260222`
- **Bug Hotfix**: create `hotfix/` from main; use gitmoji prefix title (e.g. `🐛 fix: ...`)
- **New Model Launch**: community PRs trigger automatically via title prefix (`feat` / `style`)
- **DB Migration**: create `release/db-migration-{name}` from main; cherry-pick migration commits; include dedicated migration notes
### Hard Rules
- **Do NOT** manually modify `package.json` version
- **Do NOT** manually create tags
- Minor PR title format is strict
- Patch PRs do not need explicit version number
- Keep release facts accurate; do not invent metrics or availability statements
## GitHub Release Changelog Standard (Long-Form Style)
Use this section for writing **GitHub Release notes** (or release PR body when the PR body is intended to become release notes).\
Do not use this as `docs/changelog` page guidance.
### Positioning
This release-note style is:
1. **Data-backed at the top** (date, range, key metrics)
2. **Narrative first, then structured detail**
3. **Deep but scannable** (clear sectioning + compact bullets)
4. **Contributor-forward** (credits are part of the release story)
### Required Inputs Before Writing
Collect these inputs first:
1. Compare range (`<prev_tag>...<current_tag>`)
2. Release metrics (commits, merged PRs, resolved issues, contributors, optional files/insertions/deletions)
3. High-impact changes by domain (core loop, platform/gateway, UX, tooling, security, reliability)
4. Contributor list (with standout contributions if known)
5. Known risks / migrations / rollout notes (if any)
If metrics cannot be reliably computed, omit unknown numbers instead of guessing.
### Canonical Structure
Follow this section order unless the user asks otherwise:
1. `# 🚀 LobeHub Release (<YYYYMMDD>)`
2. Metadata lines:
- `Release Date`
- `Since <Previous Version>` metrics
3. One quoted release thesis (single paragraph, 1-2 lines)
4. `## ✨ Highlights` (6-12 bullets for major releases; 3-8 for weekly)
5. Domain blocks with optional `###` subsections:
- `## 🏗️ Core Agent & Architecture` (or equivalent product core)
- `## 📱 Platforms / Integrations`
- `## 🖥️ CLI & User Experience`
- `## 🔧 Tooling`
- `## 🔒 Security & Reliability`
- `## 📚 Documentation` (optional if meaningful)
6. `## 👥 Contributors`
7. `**Full Changelog**: <prev>...<current>`
Use `---` separators between major blocks for long releases.
### Writing Rules (Hard)
1. **No fabricated metrics**: all numbers must be traceable.
2. **No vague headline bullets**: each bullet must include capability + impact.
3. **No internal-only framing**: phrase from user/operator perspective.
4. **Security must be explicit** when security-sensitive fixes are present.
5. **PR/issue linkage**: use `(#1234)` when IDs are available.
6. **Terminology consistency**: same feature/provider name across sections.
7. **Do not bury migration or breaking changes**: elevate to dedicated section or callout.
### Style Rules (Long-Form)
1. Start with an "everyday use" framing, not implementation internals.
2. Mix narrative sentence + evidence bullets.
3. Keep bullets compact but informative:
- Good: `**Fast Mode (`/fast`)** — Priority routing for OpenAI and Anthropic, reducing latency on supported models. (#6875, #6960)`
4. Use bold only for capability names, not for whole sentences.
5. Keep heading depth <= 3 levels.
### Release Size Heuristics
- **Minor / major milestone release**
- Include full structure with multiple domain blocks.
- `Highlights` usually 8-12 bullets.
- **Weekly patch release**
- Keep full skeleton but reduce subsection count.
- `Highlights` usually 4-8 bullets.
- **DB migration release**
- Keep concise.
- Must include `Migration overview`, operator impact, and rollback/backup note.
### Contributor Ordering
Render contributors as a **single flat list** (no separate "Community" / "Core Team" subsections). Order: **community contributors first, team members after**. Within each group, sort by PR count desc. Bots (`@lobehubbot`, `renovate[bot]`) go on a separate "maintenance" line.
**LobeHub team roster** — anyone in this list is a team member; anyone not in this list is a community contributor:
- @arvinxx
- @Innei
- @tjx666 (commit author name: YuTengjing)
- @LiJian
- @Neko
- @Rdmclin2
- @AmAzing129
- @sudongyuer
- @rivertwilight
- @CanisMinor
> **Resolving handles** — git author names (e.g. `YuTengjing`) are not always the GitHub handle. Verify via `gh pr view <PR> --json author` or `gh api search/users -f q='<email>'` before listing.
If a new contributor appears who is not on this list, treat them as community by default and ask the user whether to add them to the roster.
### GitHub Release Changelog Template
```md
# 🚀 LobeHub Release (<YYYYMMDD>)
**Release Date:** <Month DD, YYYY>
**Since <Previous Version>:** <N merged PRs> · <N resolved issues> · <N contributors>
> <One release thesis sentence: what this release unlocks in practice.>
---
## ✨ Highlights
- **<Capability A>** — <What changed and why it matters>. (#1234)
- **<Capability B>** — <What changed and why it matters>. (#2345)
- **<Capability C>** — <What changed and why it matters>. (#3456)
---
## 🏗️ Core Product & Architecture
### <Subdomain>
- <Concrete change + impact>. (#...)
- <Concrete change + impact>. (#...)
---
## 📱 Platforms / Integrations
- <Platform update + impact>. (#...)
- <Compatibility/reliability fix + impact>. (#...)
---
## 🖥️ CLI & User Experience
- <User-facing workflow improvement>. (#...)
- <Quality-of-life fix>. (#...)
---
## 🔧 Tooling
- <Tool/runtime improvement>. (#...)
---
## 🔒 Security & Reliability
- **Security:** <hardening or vulnerability fix>. (#...)
- **Reliability:** <stability/performance behavior improvement>. (#...)
---
## 👥 Contributors
Huge thanks to **<N contributors>** who shipped **<N merged PRs>** this cycle.
@<community-handle> · @<community-handle> · @<team-handle> · @<team-handle>
Plus @lobehubbot and renovate[bot] for maintenance.
---
**Full Changelog**: <previous_tag>...<current_tag>
```
### Quick Checklist
- [ ] Uses top metadata and a clear release thesis
- [ ] Includes `Highlights` plus domain-grouped sections
- [ ] Every major bullet states both change and user/operator impact
- [ ] Security and reliability updates are explicitly surfaced (when present)
- [ ] Contributor credits and compare range are included
- [ ] All numbers and claims are verifiable
- **Do NOT** manually modify `package.json` version — CI handles it.
- **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 `references/release-notes-style.md` § Computing Inputs — never from memory or descriptions.
@@ -0,0 +1,47 @@
# Minor Release Workflow
Used to publish a new minor version (e.g. `v2.2.0`), roughly every 4 weeks. The PR title carries the exact version number; CI parses it to drive the rest of the release.
## Steps
1. **Create a release branch from canary**
```bash
git checkout canary
git pull origin canary
git checkout -b release/v{version}
git push -u origin release/v{version}
```
2. **Determine the version number** — Read the current version from `package.json` and compute the next minor version (e.g. `2.1.x` → `2.2.0`).
3. **Create a PR to main**
```bash
gh pr create \
--title "🚀 release: v{version}" \
--base main \
--head release/v{version} \
--body-file release_body.md
```
> \[!IMPORTANT]
> The PR title must strictly match the `🚀 release: v{x.y.z}` format. CI uses a regex on this title to determine the exact version number.
4. **Write the PR body as release notes** — Follow `release-notes-style.md`. Compare base is the latest semver tag on main (`git describe --tags --abbrev=0 origin/main`).
5. **Automatic trigger after merge** — `auto-tag-release` detects the title format, uses the version number from the title, bumps `package.json`, tags `v{x.y.z}`, creates the GitHub Release, and dispatches `sync-main-to-canary`.
## Scripts
```bash
bun run release:branch # Interactive
bun run release:branch --minor # Directly specify minor
```
## Hard Rules (specific to Minor)
- PR title format is **strict**: `🚀 release: v{x.y.z}`. Any deviation falls through to patch detection.
- Do **NOT** manually modify `package.json` version — CI will bump it.
- Do **NOT** manually create the tag — CI will tag.
- Highlights bullet count is usually 812 (see `release-notes-style.md` size heuristics).
@@ -21,12 +21,16 @@ git push -u origin release/weekly-{YYYYMMDD}
2. **Scan changes and write changelog**
Compute the previous tag from main first — never reuse the last weekly's tag, since hotfixes published in between will be missed:
```bash
git log main..canary --oneline
git diff main...canary --stat
git fetch origin main canary --tags
PREV_TAG=$(git describe --tags --abbrev=0 origin/main --match 'v*.*.*' --exclude '*-canary*' --exclude '*-nightly*')
git log "$PREV_TAG..origin/release/weekly-{YYYYMMDD}" --oneline --no-merges
git diff "$PREV_TAG...origin/release/weekly-{YYYYMMDD}" --stat
```
Write a user-facing changelog following the format in `patch-release-changelog-example.md`.
Then follow `./release-notes-style.md` § **Computing Inputs (Hard Rules)** to derive PR refs, metrics, and contributors. Every `(#XXXX)` in the body must come from actual commit subjects in this range — never inferred from descriptions.
3. **Create PR to main** with the changelog as the PR body
@@ -0,0 +1,330 @@
# GitHub Release Changelog Standard (Long-Form Style)
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:
1. **Data-backed at the top** (date, range, key metrics)
2. **Narrative first, then structured detail**
3. **Deep but scannable** (clear sectioning + compact bullets)
4. **Contributor-forward** (credits are part of the release story)
## Required Inputs Before Writing
Collect these inputs first:
1. Compare range (`<prev_tag>...<current_tag>`)
2. Release metrics (commits, merged PRs, resolved issues, contributors, optional files/insertions/deletions)
3. High-impact changes by domain (core loop, platform/gateway, UX, tooling, security, reliability)
4. Contributor list (with standout contributions if known)
5. Known risks / migrations / rollout notes (if any)
If metrics cannot be reliably computed, omit unknown numbers instead of guessing.
## Computing Inputs (Hard Rules — Verify, Never Guess)
> Hallucinated PR numbers and wrong "Since v..." bases are the #1 failure mode of this skill. Every number and every `(#XXXX)` must come from `git`, never from memory or inference.
### 1. Compare base = latest semver tag on `main`
Do **not** eyeball the tag list or pick the "last weekly" PR. Compute it:
```bash
git fetch origin main canary --tags
PREV_TAG=$(git describe --tags --abbrev=0 origin/main --match 'v*.*.*' --exclude '*-canary*' --exclude '*-nightly*')
echo "$PREV_TAG"
```
Sanity check that the tag is reachable from the release branch:
```bash
git merge-base --is-ancestor "$PREV_TAG" origin/release/weekly-{YYYYMMDD} && echo OK
```
If the check fails, stop and ask the user — the release branch is based on the wrong source.
> **Why not "the last weekly release PR"?** Hotfixes (`v2.1.54`, `v2.1.55`, …) merge directly into main between weeklies. They get back-merged via `sync-main-to-canary`, so the latest semver tag on main _is_ the correct previous release for both weekly and minor flows. Picking the previous weekly's tag will silently undercount and put a stale version in "Since v…".
### 2. PR refs must come from commit subjects — never from descriptions
Compute the canonical set:
```bash
git log "$PREV_TAG..origin/release/weekly-{YYYYMMDD}" \
--pretty=format:'%s' --no-merges \
| grep -oE '\(#[0-9]+\)$' \
| sort -u > /tmp/release_prs.txt
```
Hard rules:
- Every `(#XXXX)` you write in the body **must** appear in `/tmp/release_prs.txt`. No exceptions.
- Never infer a PR number from a feature description. If you remember "the KB BM25 PR was around #14501", that memory is wrong about half the time. Look up the commit hash by feature keyword and read its actual subject.
- If your terminal truncates long subjects (any wrapper that compresses output, e.g. `rtk`), bypass it. With `rtk` use `rtk proxy git log …`. Verify with `wc -l /tmp/release_prs.txt` — the count must match `git log $PREV_TAG..HEAD --no-merges --pretty=format:'%h' | wc -l` minus the few commits without a PR ref. A mismatch of >5% means subjects are being silently truncated.
### 3. Metrics must come from git counts
```bash
PR_COUNT=$(wc -l < /tmp/release_prs.txt | tr -d ' ')
COMMIT_COUNT=$(git log "$PREV_TAG..origin/release/weekly-{YYYYMMDD}" --no-merges --pretty=format:'%h' | wc -l | tr -d ' ')
CONTRIBUTOR_COUNT=$(git log "$PREV_TAG..origin/release/weekly-{YYYYMMDD}" --no-merges --pretty=format:'%an' \
| sort -u \
| grep -viE '^(lobehubbot|LobeHub Bot|renovate\[bot\])$' \
| wc -l | tr -d ' ')
```
If a number cannot be confidently derived, omit it — never guess.
### 4. Author-to-handle resolution
Git `%an` is the commit author display name, not the GitHub handle. For each author you mention, confirm the handle:
```bash
gh pr view "$PR_NUMBER" --repo lobehub/lobe-chat --json author --jq '.author.login'
```
Use the result for `@handle`. Then classify each author per the `LobeHub team roster` below; community first, team after.
### 5. Pre-publish verification (mandatory)
Before `gh pr create` / `gh pr edit --body-file`, diff body PR refs against the canonical set:
```bash
grep -oE '#[0-9]+' release_body.md | sort -u > /tmp/body_prs.txt
sed 's/[()]//g' /tmp/release_prs.txt > /tmp/release_prs_clean.txt
echo "=== In body but NOT in actual range (must be EMPTY) ==="
comm -23 /tmp/body_prs.txt /tmp/release_prs_clean.txt
```
Empty diff = OK. Any output = the body cites a PR that wasn't merged in this range. Stop and fix before publishing.
Also verify the metrics line in the body matches the computed values (`PR_COUNT`, `CONTRIBUTOR_COUNT`) and that `**Full Changelog**` uses `$PREV_TAG`, not some older tag.
## Canonical Structure (Long-Form: Minor / Weekly)
Follow this section order for **Minor** and **Weekly** releases unless the user asks otherwise. For **Hotfix** and **DB Migration**, see § Variants for Shorter Releases below — the canonical structure does not apply.
1. `# 🚀 LobeHub Release (<YYYYMMDD>)`
2. Metadata lines:
- `Release Date`
- `Since <Previous Version>` metrics
3. One quoted release thesis (single paragraph, 1-2 lines)
4. `## ✨ Highlights` (6-12 bullets for major releases; 3-8 for weekly)
5. Domain blocks with optional `###` subsections:
- `## 🏗️ Core Agent & Architecture` (or equivalent product core)
- `## 📱 Platforms / Integrations`
- `## 🖥️ CLI & User Experience`
- `## 🔧 Tooling`
- `## 🔒 Security & Reliability`
- `## 📚 Documentation` (optional if meaningful)
6. `## 👥 Contributors`
7. `**Full Changelog**: <prev>...<current>`
Use `---` separators between major blocks for long releases.
## Variants for Shorter Releases
The Canonical Structure above is for **long-form** (Minor / Weekly). Two short-form variants override it.
### Hotfix Variant
A hotfix targets one regression and ships fast. The body is short and operator-focused — no Highlights, no domain blocks, no Contributors line.
Required sections, in order:
1. `# 🚀 LobeHub Release (<YYYYMMDD>)`
2. `**Hotfix Scope:**` — one line summarizing the regression scope (e.g. `Agent topic-switching regression — stale chat state on agent change`). Replaces the long-form `Release Date` / `Since vX.Y.Z` metrics.
3. One quoted thesis (single paragraph, 1-2 lines) describing what is now restored.
4. `## 🐛 What's Fixed` — 1-3 bullets, each `**<symptom>** — <fix in one sentence>. (#PR)`. No root-cause prose; that lives in the commit message.
5. `## ⚙️ Upgrade` — short notes for self-hosted (pull image / restart, schema or env changes) and cloud (usually "applied automatically").
6. `## 👥 Owner` — single `@handle` for the PR author, resolved via `gh pr view "$PR" --json author --jq '.author.login'`. Never hardcoded.
Hard rules specific to hotfix:
- **No Highlights / domain blocks / Contributors / Full Changelog** — these add noise to a one-shot fix.
- **No metric line** — `Since vX.Y.Z` doesn't apply; the body cites the single PR (or 1-3 PRs) directly.
- **Owner ≠ Contributors** — one author, listed under § Owner. Not a flat handle list.
- See `changelog-example/hotfix.md` for the canonical template.
### DB Migration Variant
Database schema changes that need to be released independently. Operator impact is the headline.
Required sections, in order:
1. `# 🚀 LobeHub Release (<YYYYMMDD>)` + scope line
2. **Migration overview** — what tables / columns are added, modified, or removed
3. **Operator impact** — backwards-compatible? required actions for self-hosted?
4. **Rollback / backup note** — how to recover
5. `## 👥 Owner` — single PR author, resolved via `gh pr view`
See `changelog-example/db-migration.md` for the canonical template.
## Writing Rules (Hard)
1. **No fabricated metrics**: all numbers must be traceable.
2. **No vague headline bullets**: each bullet must include capability + impact.
3. **No internal-only framing**: phrase from user/operator perspective.
4. **Security must be explicit** when security-sensitive fixes are present.
5. **PR/issue linkage**: use `(#1234)` when IDs are available.
6. **Terminology consistency**: same feature/provider name across sections.
7. **Do not bury migration or breaking changes**: elevate to dedicated section or callout.
## Style Rules (Long-Form)
1. Start with an "everyday use" framing, not implementation internals.
2. Mix narrative sentence + evidence bullets.
3. Keep bullets compact but informative:
- Good: `**Fast Mode (`/fast`)** — Priority routing for OpenAI and Anthropic, reducing latency on supported models. (#6875, #6960)`
4. Use bold only for capability names, not for whole sentences.
5. Keep heading depth ≤ 3 levels.
## Release Size Heuristics
- **Minor / major milestone release**
- Long-form structure with multiple domain blocks.
- `Highlights` usually 8-12 bullets.
- **Weekly patch release**
- Long-form skeleton with reduced subsection count.
- `Highlights` usually 4-8 bullets.
- **Hotfix release**
- Short-form (see § Variants → Hotfix). No Highlights, no domain blocks, no Contributors.
- 1-3 fix bullets. Body should fit on one screen.
- **DB migration release**
- Short-form (see § Variants → DB Migration).
- Must include `Migration overview`, operator impact, and rollback/backup note.
## Contributor Ordering
Render contributors as a **single flat list** (no separate "Community" / "Core Team" subsections). Order: **community contributors first, team members after**. Within each group, sort by PR count desc. Bots (`@lobehubbot`, `renovate[bot]`) go on a separate "maintenance" line.
**LobeHub team roster** — anyone in this list is a team member; anyone not in this list is a community contributor:
- @arvinxx
- @Innei
- @tjx666 (commit author name: YuTengjing)
- @LiJian
- @Neko
- @Rdmclin2
- @AmAzing129
- @sudongyuer (commit author name: Tsuki)
- @rivertwilight (commit author name: René Wang)
- @CanisMinor
- @cy948 (commit author name: Rylan Cai)
> **Resolving handles** — git author names (e.g. `YuTengjing`) are not always the GitHub handle. Verify via `gh pr view "$PR" --json author` or `gh api search/users -f q='<email>'` before listing.
If a new contributor appears who is not on this list, treat them as community by default and ask the user whether to add them to the roster.
## Template
```md
# 🚀 LobeHub Release (<YYYYMMDD>)
**Release Date:** <Month DD, YYYY>
**Since <Previous Version>:** <N merged PRs> · <N resolved issues> · <N contributors>
> <One release thesis sentence: what this release unlocks in practice.>
---
## ✨ Highlights
- **<Capability A>** — <What changed and why it matters>. (#1234)
- **<Capability B>** — <What changed and why it matters>. (#2345)
- **<Capability C>** — <What changed and why it matters>. (#3456)
---
## 🏗️ Core Product & Architecture
### <Subdomain>
- <Concrete change + impact>. (#...)
- <Concrete change + impact>. (#...)
---
## 📱 Platforms / Integrations
- <Platform update + impact>. (#...)
- <Compatibility/reliability fix + impact>. (#...)
---
## 🖥️ CLI & User Experience
- <User-facing workflow improvement>. (#...)
- <Quality-of-life fix>. (#...)
---
## 🔧 Tooling
- <Tool/runtime improvement>. (#...)
---
## 🔒 Security & Reliability
- **Security:** <hardening or vulnerability fix>. (#...)
- **Reliability:** <stability/performance behavior improvement>. (#...)
---
## 👥 Contributors
Huge thanks to **<N contributors>** who shipped **<N merged PRs>** this cycle.
@<community-handle> · @<community-handle> · @<team-handle> · @<team-handle>
Plus @lobehubbot and renovate[bot] for maintenance.
---
**Full Changelog**: <previous_tag>...<current_tag>
```
## Quick Checklist
### Long-Form (Minor / Weekly)
- [ ] `PREV_TAG` is `git describe --tags --abbrev=0 origin/main` (latest semver), not the last weekly's tag
- [ ] Every `(#XXXX)` in the body appears in `/tmp/release_prs.txt` (verified via `comm -23`)
- [ ] `Since v…` line uses `$PREV_TAG`; PR / contributor counts match `wc -l` on the computed sets
- [ ] `**Full Changelog**` uses `$PREV_TAG...release/weekly-<YYYYMMDD>` (or `…v{x.y.z}` for minor)
- [ ] Author handles resolved via `gh pr view --json author`, not assumed from `%an`
- [ ] Uses top metadata and a clear release thesis
- [ ] Includes `Highlights` plus domain-grouped sections
- [ ] Every major bullet states both change and user/operator impact
- [ ] Security and reliability updates are explicitly surfaced (when present)
- [ ] Contributor credits and compare range are included
- [ ] All numbers and claims are verifiable
### Hotfix
- [ ] `**Hotfix Scope:**` line replaces metrics line
- [ ] Single quoted thesis describes what is restored (operator-facing, not internal)
- [ ] `## 🐛 What's Fixed` has 1-3 bullets, each `**<symptom>** — <fix>. (#PR)` with PR ref verified to exist and be merged
- [ ] `## ⚙️ Upgrade` notes self-hosted action and cloud auto-apply
- [ ] `## 👥 Owner` is a single `@handle` resolved via `gh pr view "$PR" --json author`
- [ ] No Highlights / domain blocks / Contributors / Full Changelog included
+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
+20 -11
View File
@@ -56,7 +56,6 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# add your custom model name, multi model separate by comma. for example gpt-3.5-1106,gpt-4-1106
# OPENAI_MODEL_LIST=gpt-3.5-turbo
# ## Azure OpenAI ###
# you can learn azure OpenAI Service on https://learn.microsoft.com/en-us/azure/ai-services/openai/overview
@@ -71,7 +70,6 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# Azure's API version, follows the YYYY-MM-DD format
# AZURE_API_VERSION=2024-10-21
# ## Anthropic Service ####
# ANTHROPIC_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
@@ -79,19 +77,16 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# use a proxy to connect to the Anthropic API
# ANTHROPIC_PROXY_URL=https://api.anthropic.com
# ## Google AI ####
# GOOGLE_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# ## AWS Bedrock ###
# AWS_REGION=us-east-1
# AWS_ACCESS_KEY_ID=xxxxxxxxxxxxxxxxxxx
# AWS_SECRET_ACCESS_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# ## Ollama AI ####
# You can use ollama to get and run LLM locally, learn more about it via https://github.com/ollama/ollama
@@ -101,13 +96,11 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# OLLAMA_MODEL_LIST=your_ollama_model_names
# ## OpenRouter Service ###
# OPENROUTER_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# OPENROUTER_MODEL_LIST=model1,model2,model3
# ## Mistral AI ###
# MISTRAL_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
@@ -168,7 +161,6 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# SILICONCLOUD_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# ## TencentCloud AI ####
# TENCENT_CLOUD_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
@@ -181,7 +173,6 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# INFINIAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# ## 302.AI ###
# AI302_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
@@ -222,7 +213,6 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# VERCELAIGATEWAY_API_KEY=your_vercel_ai_gateway_api_key
# #######################################
# ########### Market Service ############
# #######################################
@@ -283,7 +273,6 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# but some service providers may require configuration
# S3_REGION=us-west-1
# #######################################
# ########### Auth Service ##############
# #######################################
@@ -424,3 +413,23 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# MESSAGE_GATEWAY_ENABLED=1
# MESSAGE_GATEWAY_URL=https://message-gateway.lobehub.com
# MESSAGE_GATEWAY_SERVICE_TOKEN=your_service_token_here
# #######################################
# ########### Messenger Bot #############
# #######################################
# LobeHub-operated bots that users link their account to once and then chat
# with any of their agents from. Credentials (Telegram / Slack / Discord) are
# now managed in dc-center → Agent → System Bots and stored in the
# `system_bot_providers` table. See docs/development/messenger/managed-by-dc-center.md.
#
# Webhook URLs are registered against APP_URL:
# Telegram: <APP_URL>/api/agent/messenger/webhooks/telegram
# Slack: <APP_URL>/api/agent/messenger/webhooks/slack
# Discord: <APP_URL>/api/agent/messenger/webhooks/discord
#
# For local dev with bot platforms, point APP_URL at your tunnel
# (ngrok / cloudflared) so platforms can reach your machine.
# Verify-im link token TTL in seconds (default 1800 = 30 min)
# LOBE_LINK_TOKEN_TTL_SECONDS=1800
+3
View File
@@ -148,3 +148,6 @@ apps/desktop/resources/cli-package.json
.superpowers/
docs/superpowers/
.heerogeneous-tracing
# Kagura agent runtime
.kagura/
+37
View File
@@ -2,6 +2,43 @@
# Changelog
## [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
@@ -89,7 +89,7 @@ RUN set -e && \
pnpm i && \
mkdir -p /deps && \
cd /deps && \
pnpm init && \
echo '{"name":"deps","private":true}' > package.json && \
pnpm add pg drizzle-orm
COPY . .
+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.11" "User Commands"
.TH LH 1 "" "@lobehub/cli 0.0.15" "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.11",
"version": "0.0.15",
"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;
+202
View File
@@ -269,6 +269,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) {
@@ -608,6 +806,10 @@ export function registerBotCommand(program: Command) {
name: 'group-allowlist',
});
// ── watch-keywords (LOBE-8891) ────────────────────────
registerWatchKeywordsCommand(bot);
// ── remove ────────────────────────────────────────────
bot
+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}`);
}
});
}
+120 -14
View File
@@ -10,7 +10,10 @@ import type {
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']);
@@ -21,7 +24,22 @@ interface ExecOptions {
inputJson?: string;
operationId?: string;
prompt?: string;
/**
* Output rendering mode.
* jsonl — emit each `AgentStreamEvent` as a JSONL line on stdout (default
* when no --topic is set, or when explicitly requested).
* none — suppress JSONL stdout; only server-ingest mode is active.
* Default when --topic is set and running non-interactively.
*/
render?: 'jsonl' | 'none';
resume?: string;
/**
* Server topic id. When set, enables server-ingest mode: events are
* batch-POSTed to `aiAgent.heteroIngest` in addition to (or instead of)
* being written to stdout. Requires `--operation-id` to be a valid
* server-allocated operation id.
*/
topic?: string;
type: string;
}
@@ -171,12 +189,35 @@ const exec = async (options: ExecOptions): Promise<void> => {
process.exit(2);
}
// Standalone (phase 1a): no server ingest, so the operationId is just an
// identity stamp on the JSONL stream. Generate a fresh one if the caller
// didn't provide --operation-id; phase 1b will require it as a real
// server-allocated id.
// Server-ingest mode is active when --topic is provided.
// --operation-id must be a server-allocated id in this mode (the server
// generates it before spawning the process and passes it via CLI args).
const serverIngest = !!options.topic;
if (serverIngest && !options.operationId) {
log.error('--operation-id is required when --topic is set (server-ingest mode).');
process.exit(2);
}
const operationId = options.operationId || randomUUID();
// Determine JSONL output mode.
// Explicit --render flag always wins. Otherwise: emit JSONL in standalone
// mode; suppress in server-ingest mode (sink handles the data path).
const emitJsonl = options.render === 'jsonl' || (options.render === undefined && !serverIngest);
// Build the ingest sink — no-op for standalone mode, real tRPC sink for
// 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>;
if (serverIngest) {
const client = await getTrpcClient();
sink = new TrpcIngestSink(client, agentType, operationId, options.topic!);
} else {
sink = new NoopIngestSink();
}
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
@@ -203,36 +244,93 @@ const exec = async (options: ExecOptions): Promise<void> => {
// 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 = () => {
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', () => handle.kill('SIGTERM'));
process.on('SIGTERM', async () => {
handle.kill('SIGTERM');
if (serverIngest) {
try {
await ingester.drain();
await sink.finish({ result: 'cancelled' });
} catch {
// best-effort
}
}
});
// Stream events out as JSONL on stdout. Each line is one `AgentStreamEvent`.
// Use raw write (not console.log) so we don't pull in console formatting
// and JSONL stays parseable downstream.
// 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) {
process.stdout.write(`${JSON.stringify(event)}\n`);
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. Signal-induced exits (SIGINT etc.)
// surface as `code === null` — map to 130 (POSIX convention for SIGINT).
// 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 (code !== null) process.exit(code);
if (serverIngest) {
try {
await ingester.drain();
} catch (err) {
log.error(
'Failed to flush events to server:',
err instanceof Error ? err.message : String(err),
);
ingestError = true;
}
const exitedClean = !ingestError && (code === 0 || signal === 'SIGTERM');
try {
await sink.finish({
result: exitedClean ? 'success' : 'error',
sessionId: handle.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 (signal === 'SIGINT') process.exit(130);
if (signal === 'SIGTERM') process.exit(143);
if (signal === 'SIGKILL') process.exit(137);
@@ -268,7 +366,15 @@ export function registerHeteroCommand(program: Command) {
)
.option(
'--operation-id <id>',
'Operation id stamped onto every emitted event. Generated as a uuid if omitted (phase 1a).',
'Operation id stamped onto every emitted event. Required in server-ingest mode (--topic). Generated as a UUID if omitted (standalone).',
)
.option(
'--topic <topicId>',
'Server topic id. Enables server-ingest mode: events are batch-POSTed to aiAgent.heteroIngest. Requires --operation-id.',
)
.option(
'--render <mode>',
'Output mode: jsonl (emit events as JSONL on stdout) | none (suppress stdout). Defaults to jsonl in standalone, none in server-ingest mode.',
)
.action(exec);
}
+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;
+17
View File
@@ -83,6 +83,23 @@ describe('model command', () => {
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(models, null, 2));
});
it('should filter hidden runtime-only models from JSON output', async () => {
const visibleModels = [{ displayName: 'DeepSeek V4 Pro', id: 'deepseek-v4-pro' }];
mockTrpcClient.aiModel.getAiProviderModelList.query.mockResolvedValue([
...visibleModels,
{
displayName: 'LobeHub Onboarding',
id: 'lobehub-onboarding-v1',
visible: false,
},
]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'model', 'list', 'lobehub', '--json']);
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(visibleModels, null, 2));
});
});
describe('view', () => {
+5 -1
View File
@@ -5,6 +5,8 @@ import { getTrpcClient } from '../api/client';
import { confirm, outputJson, printTable, truncate } from '../utils/format';
import { log } from '../utils/logger';
const isVisibleModel = (model: { visible?: boolean }) => model.visible !== false;
export function registerModelCommand(program: Command) {
const model = program.command('model').description('Manage AI models');
@@ -33,7 +35,9 @@ export function registerModelCommand(program: Command) {
if (options.type) input.type = options.type;
const result = await client.aiModel.getAiProviderModelList.query(input as any);
let items = Array.isArray(result) ? result : ((result as any).items ?? []);
let items = (Array.isArray(result) ? result : ((result as any).items ?? [])).filter(
isVisibleModel,
);
if (options.type) {
items = items.filter((m: any) => m.type === options.type);
+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)')
-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);
+99
View File
@@ -0,0 +1,99 @@
import type { AgentStreamEvent } from '@lobechat/heterogeneous-agents/spawn';
export interface IngestSink {
finish: (params: {
error?: { message: string; type: string };
result: 'cancelled' | 'error' | 'success';
sessionId?: string;
}) => Promise<void>;
ingest: (events: AgentStreamEvent[]) => Promise<void>;
}
export class NoopIngestSink implements IngestSink {
async finish(_params: Parameters<IngestSink['finish']>[0]): Promise<void> {}
async ingest(_events: AgentStreamEvent[]): Promise<void> {}
}
const MAX_BATCH = 50;
const FLUSH_INTERVAL_MS = 250;
const MAX_RETRIES = 5;
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
/**
* Buffers `AgentStreamEvent`s and flushes them in batches to an `IngestSink`.
*
* Flush triggers:
* - Buffer reaches MAX_BATCH (50) → immediate flush
* - FLUSH_INTERVAL_MS (250ms) timer fires → flush whatever is buffered
*
* Each batch is retried up to MAX_RETRIES (5) times with exponential back-off
* starting at 500ms, doubling up to 8s. After the final retry the error is
* stored and re-thrown by `drain()`, allowing the caller to call
* `sink.finish({ result: 'error' })` and exit(1).
*
* Call order: push() repeatedly → drain() once (before finish()).
*/
export class BatchIngester {
private buffer: AgentStreamEvent[] = [];
private fatalError: Error | null = null;
private inflightFlush: Promise<void> = Promise.resolve();
private timer: ReturnType<typeof setTimeout> | null = null;
constructor(private readonly sink: IngestSink) {}
push(event: AgentStreamEvent): void {
if (this.fatalError) return;
this.buffer.push(event);
if (this.buffer.length >= MAX_BATCH) {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
this.triggerFlush();
} else if (!this.timer) {
this.timer = setTimeout(() => {
this.timer = null;
this.triggerFlush();
}, FLUSH_INTERVAL_MS);
}
}
/** Flush remaining buffer and wait for all in-flight sends to settle. */
async drain(): Promise<void> {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
this.triggerFlush();
await this.inflightFlush;
if (this.fatalError) throw this.fatalError;
}
private async sendWithRetry(batch: AgentStreamEvent[]): Promise<void> {
let delay = 500;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
await this.sink.ingest(batch);
return;
} catch (err) {
if (attempt === MAX_RETRIES) {
this.fatalError = err instanceof Error ? err : new Error(String(err));
throw this.fatalError;
}
await sleep(delay);
delay = Math.min(delay * 2, 8_000);
}
}
}
private triggerFlush(): void {
if (this.fatalError || this.buffer.length === 0) return;
const batch = this.buffer.splice(0);
this.inflightFlush = this.inflightFlush
.then(() => this.sendWithRetry(batch))
.catch(() => {
// fatalError is already set; drain() re-throws it
});
}
}
+38
View File
@@ -0,0 +1,38 @@
import type { AgentStreamEvent } from '@lobechat/heterogeneous-agents/spawn';
import type { TrpcClient } from '../api/client';
import type { IngestSink } from './BatchIngester';
/**
* `IngestSink` implementation that forwards batches to the server via tRPC
* (`aiAgent.heteroIngest` / `aiAgent.heteroFinish`).
*
* The CLI authenticates using the `LOBEHUB_JWT` env var (operation-scoped JWT
* injected by the server before spawning the sandbox / desktop process).
*/
export class TrpcIngestSink implements IngestSink {
constructor(
private readonly client: TrpcClient,
private readonly agentType: 'claude-code' | 'codex',
private readonly operationId: string,
private readonly topicId: string,
) {}
async finish(params: Parameters<IngestSink['finish']>[0]): Promise<void> {
await this.client.aiAgent.heteroFinish.mutate({
agentType: this.agentType,
operationId: this.operationId,
topicId: this.topicId,
...params,
});
}
async ingest(events: AgentStreamEvent[]): Promise<void> {
await this.client.aiAgent.heteroIngest.mutate({
agentType: this.agentType,
events: events as any,
operationId: this.operationId,
topicId: this.topicId,
});
}
}
+7
View File
@@ -6,6 +6,10 @@ import { fileURLToPath } from 'node:url';
import dotenv from 'dotenv';
import {
copyExternalRuntimeModulesToSource,
getExternalRuntimeModulesFilesConfig,
} from './external-runtime-deps.config.mjs';
import {
copyNativeModules,
copyNativeModulesToSource,
@@ -106,6 +110,7 @@ const config = {
*/
beforePack: async () => {
await copyNativeModulesToSource();
await copyExternalRuntimeModulesToSource();
console.info('📦 Downloading agent-browser binary...');
execSync('node scripts/download-agent-browser.mjs', { stdio: 'inherit', cwd: __dirname });
@@ -251,6 +256,8 @@ const config = {
'!node_modules',
// Then explicitly include native modules using object form (handles pnpm symlinks)
...getNativeModulesFilesConfig(),
// Include non-native runtime modules that are intentionally externalized from Vite.
...getExternalRuntimeModulesFilesConfig(),
],
generateUpdatesFilesForAllChannels: true,
linux: {
+8 -3
View File
@@ -13,7 +13,8 @@ import {
sharedRendererPlugins,
sharedRollupOutput,
} from '../../plugins/vite/sharedRendererConfig';
import { getExternalDependencies } from './native-deps.config.mjs';
import { externalRuntimeModules } from './external-runtime-deps.config.mjs';
import { getNativeExternalDependencies } from './native-deps.config.mjs';
/**
* Force `base: '/'` in renderer config. The `electron-vite` preset
@@ -99,7 +100,11 @@ const desktopPackageJson = JSON.parse(
readFileSync(path.resolve(__dirname, 'package.json'), 'utf8'),
) as { version: string };
const electronRuntimeExternals = ['electron'];
const mainProcessRuntimeExternals = [...electronRuntimeExternals, 'node-mac-permissions'];
const mainProcessRuntimeExternals = [
...electronRuntimeExternals,
...externalRuntimeModules,
'node-mac-permissions',
];
console.info(`[electron-vite.config.ts] Detected UPDATE_CHANNEL: ${updateChannel}`);
@@ -113,7 +118,7 @@ export default defineConfig({
// bufferutil and utf-8-validate are optional peer deps of ws that may not be installed.
external: [
...mainProcessRuntimeExternals,
...getExternalDependencies(),
...getNativeExternalDependencies(),
'bufferutil',
'utf-8-validate',
],
@@ -0,0 +1,33 @@
import {
copyModulesToSource,
getDependenciesForModules,
getModuleFilesConfig,
} from './module-deps.config.mjs';
/**
* Non-native modules intentionally externalized from the main-process bundle.
*
* These modules are not native dependencies. They stay external because their
* process-level side effects must be owned by one Node runtime module instance.
*/
export const externalRuntimeModules = ['electron-log'];
/**
* Get all dependencies for runtime external modules.
* @returns {string[]}
*/
export function getAllExternalRuntimeDependencies() {
return getDependenciesForModules(externalRuntimeModules);
}
/**
* Generate files config objects for non-native runtime external modules.
* @returns {Array<{from: string, to: string, filter: string[]}>}
*/
export function getExternalRuntimeModulesFilesConfig() {
return getModuleFilesConfig(externalRuntimeModules);
}
export async function copyExternalRuntimeModulesToSource() {
await copyModulesToSource(externalRuntimeModules, 'runtime external module');
}
+189
View File
@@ -0,0 +1,189 @@
/* eslint-disable no-console */
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const sourceNodeModules = path.join(__dirname, 'node_modules');
/**
* Recursively resolve all dependencies of a module.
* @param {string} moduleName - The module to resolve
* @param {Set<string>} visited - Set of already visited modules
* @param {string} nodeModulesPath - Path to node_modules directory
* @returns {Set<string>} Set of all dependencies
*/
function resolveDependencies(moduleName, visited = new Set(), nodeModulesPath = sourceNodeModules) {
if (visited.has(moduleName)) {
return visited;
}
// Always add the module name first. Workspace and optional platform modules
// may not be materialized locally, but they still need stable package rules.
visited.add(moduleName);
const packageJsonPath = path.join(nodeModulesPath, moduleName, 'package.json');
if (!fs.existsSync(packageJsonPath)) {
return visited;
}
try {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
const dependencies = packageJson.dependencies || {};
const optionalDependencies = packageJson.optionalDependencies || {};
for (const dep of Object.keys(dependencies)) {
resolveDependencies(dep, visited, nodeModulesPath);
}
for (const dep of Object.keys(optionalDependencies)) {
resolveDependencies(dep, visited, nodeModulesPath);
}
} catch {
// Ignore unreadable package.json files; electron-builder will surface any
// actual missing runtime dependency during packaging or startup.
}
return visited;
}
/**
* Get all transitive dependencies for a set of top-level modules.
* @param {string[]} modules
* @returns {string[]}
*/
export function getDependenciesForModules(modules) {
const allDeps = new Set();
for (const moduleName of modules) {
const deps = resolveDependencies(moduleName);
for (const dep of deps) {
allDeps.add(dep);
}
}
return [...allDeps];
}
/**
* Generate glob patterns for electron-builder files config.
* @param {string[]} modules
* @returns {string[]}
*/
export function getModuleFilesPatterns(modules) {
return getDependenciesForModules(modules).map((dep) => `node_modules/${dep}/**/*`);
}
/**
* Generate object-form electron-builder files config.
* Object form is required because pnpm symlinks are resolved before packaging.
* @param {string[]} modules
* @returns {Array<{from: string, to: string, filter: string[]}>}
*/
export function getModuleFilesConfig(modules) {
return getDependenciesForModules(modules).map((dep) => ({
filter: ['**/*'],
from: `node_modules/${dep}`,
to: `node_modules/${dep}`,
}));
}
/**
* Copy module symlinks in source node_modules to real directories so
* electron-builder can include them via file rules.
* @param {string[]} modules
* @param {string} label
*/
export async function copyModulesToSource(modules, label) {
const deps = getDependenciesForModules(modules);
console.log(`📦 Resolving ${deps.length} ${label} symlinks for packaging...`);
for (const dep of deps) {
const modulePath = path.join(sourceNodeModules, dep);
try {
const stat = await fs.promises.lstat(modulePath);
if (stat.isSymbolicLink()) {
const realPath = await fs.promises.realpath(modulePath);
console.log(` 📎 ${dep} (resolving symlink)`);
await fs.promises.rm(modulePath, { force: true, recursive: true });
await fs.promises.mkdir(path.dirname(modulePath), { recursive: true });
await copyDir(realPath, modulePath);
}
} catch (err) {
console.log(` ⏭️ ${dep} (skipped: ${err.code || err.message})`);
}
}
console.log(`${label} symlinks resolved`);
}
/**
* Copy modules to a destination node_modules directory, resolving symlinks.
* @param {string[]} modules
* @param {string} destNodeModules
* @param {string} label
*/
export async function copyModulesToDirectory(modules, destNodeModules, label) {
const deps = getDependenciesForModules(modules);
console.log(`📦 Copying ${deps.length} ${label} to unpacked directory...`);
for (const dep of deps) {
const sourcePath = path.join(sourceNodeModules, dep);
const destPath = path.join(destNodeModules, dep);
try {
const stat = await fs.promises.lstat(sourcePath);
if (stat.isSymbolicLink()) {
const realPath = await fs.promises.realpath(sourcePath);
console.log(` 📎 ${dep} (symlink -> ${path.relative(sourceNodeModules, realPath)})`);
await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
await copyDir(realPath, destPath);
} else if (stat.isDirectory()) {
console.log(` 📁 ${dep}`);
await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
await copyDir(sourcePath, destPath);
}
} catch (err) {
console.log(` ⏭️ ${dep} (skipped: ${err.code || err.message})`);
}
}
console.log(`${label} copied successfully`);
}
/**
* Recursively copy a directory.
* @param {string} src
* @param {string} dest
*/
async function copyDir(src, dest) {
await fs.promises.mkdir(dest, { recursive: true });
const entries = await fs.promises.readdir(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
await copyDir(srcPath, destPath);
} else if (entry.isSymbolicLink()) {
const realPath = await fs.promises.realpath(srcPath);
const realStat = await fs.promises.stat(realPath);
if (realStat.isDirectory()) {
await copyDir(realPath, destPath);
} else {
await fs.promises.copyFile(realPath, destPath);
}
} else {
await fs.promises.copyFile(srcPath, destPath);
}
}
}
+17 -176
View File
@@ -1,4 +1,3 @@
/* eslint-disable no-console */
/**
* Native dependencies configuration for Electron build
*
@@ -9,12 +8,15 @@
*
* This module automatically resolves the full dependency tree.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
import {
copyModulesToDirectory,
copyModulesToSource,
getDependenciesForModules,
getModuleFilesConfig,
getModuleFilesPatterns,
} from './module-deps.config.mjs';
/**
* Get the current target platform
@@ -40,78 +42,20 @@ export const nativeModules = [
'node-screenshots',
];
/**
* Recursively resolve all dependencies of a module
* @param {string} moduleName - The module to resolve
* @param {Set<string>} visited - Set of already visited modules (to avoid cycles)
* @param {string} nodeModulesPath - Path to node_modules directory
* @returns {Set<string>} Set of all dependencies
*/
function resolveDependencies(
moduleName,
visited = new Set(),
nodeModulesPath = path.join(__dirname, 'node_modules'),
) {
if (visited.has(moduleName)) {
return visited;
}
// Always add the module name first (important for workspace dependencies
// that may not be in local node_modules but are declared in nativeModules)
visited.add(moduleName);
const packageJsonPath = path.join(nodeModulesPath, moduleName, 'package.json');
// If module doesn't exist locally, still keep it in visited but skip dependency resolution
if (!fs.existsSync(packageJsonPath)) {
return visited;
}
try {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
const dependencies = packageJson.dependencies || {};
const optionalDependencies = packageJson.optionalDependencies || {};
// Resolve regular dependencies
for (const dep of Object.keys(dependencies)) {
resolveDependencies(dep, visited, nodeModulesPath);
}
// Also resolve optional dependencies (important for native modules like @napi-rs/canvas
// which have platform-specific binaries in optional deps)
for (const dep of Object.keys(optionalDependencies)) {
resolveDependencies(dep, visited, nodeModulesPath);
}
} catch {
// Ignore errors reading package.json
}
return visited;
}
/**
* Get all dependencies for all native modules (including transitive dependencies)
* @returns {string[]} Array of all dependency names
*/
export function getAllDependencies() {
const allDeps = new Set();
for (const nativeModule of nativeModules) {
const deps = resolveDependencies(nativeModule);
for (const dep of deps) {
allDeps.add(dep);
}
}
return [...allDeps];
export function getAllNativeDependencies() {
return getDependenciesForModules(nativeModules);
}
/**
* Generate glob patterns for electron-builder files config
* @returns {string[]} Array of glob patterns
*/
export function getFilesPatterns() {
return getAllDependencies().map((dep) => `node_modules/${dep}/**/*`);
export function getNativeModuleFilesPatterns() {
return getModuleFilesPatterns(nativeModules);
}
/**
@@ -120,11 +64,7 @@ export function getFilesPatterns() {
* @returns {Array<{from: string, to: string, filter: string[]}>}
*/
export function getNativeModulesFilesConfig() {
return getAllDependencies().map((dep) => ({
filter: ['**/*'],
from: `node_modules/${dep}`,
to: `node_modules/${dep}`,
}));
return getModuleFilesConfig(nativeModules);
}
/**
@@ -132,15 +72,15 @@ export function getNativeModulesFilesConfig() {
* @returns {string[]} Array of glob patterns
*/
export function getAsarUnpackPatterns() {
return getAllDependencies().map((dep) => `node_modules/${dep}/**/*`);
return getNativeModuleFilesPatterns();
}
/**
* Get the list of native dependencies for Vite external config
* @returns {string[]} Array of dependency names
*/
export function getExternalDependencies() {
return getAllDependencies();
export function getNativeExternalDependencies() {
return getAllNativeDependencies();
}
/**
@@ -149,39 +89,7 @@ export function getExternalDependencies() {
* included in the asar archive (electron-builder glob doesn't follow symlinks).
*/
export async function copyNativeModulesToSource() {
const fsPromises = await import('node:fs/promises');
const deps = getAllDependencies();
const sourceNodeModules = path.join(__dirname, 'node_modules');
console.log(`📦 Resolving ${deps.length} native module symlinks for packaging...`);
for (const dep of deps) {
const modulePath = path.join(sourceNodeModules, dep);
try {
const stat = await fsPromises.lstat(modulePath);
if (stat.isSymbolicLink()) {
// Resolve the symlink to get the real path
const realPath = await fsPromises.realpath(modulePath);
console.log(` 📎 ${dep} (resolving symlink)`);
// Remove the symlink
await fsPromises.rm(modulePath, { force: true, recursive: true });
// Create parent directory if needed (for scoped packages like @napi-rs)
await fsPromises.mkdir(path.dirname(modulePath), { recursive: true });
// Copy the actual directory content in place of the symlink
await copyDir(realPath, modulePath);
}
} catch (err) {
// Module might not exist (optional dependency for different platform)
console.log(` ⏭️ ${dep} (skipped: ${err.code || err.message})`);
}
}
console.log(`✅ Native module symlinks resolved`);
await copyModulesToSource(nativeModules, 'native module');
}
/**
@@ -190,72 +98,5 @@ export async function copyNativeModulesToSource() {
* @param {string} destNodeModules - Destination node_modules path
*/
export async function copyNativeModules(destNodeModules) {
const fsPromises = await import('node:fs/promises');
const deps = getAllDependencies();
const sourceNodeModules = path.join(__dirname, 'node_modules');
console.log(`📦 Copying ${deps.length} native modules to unpacked directory...`);
for (const dep of deps) {
const sourcePath = path.join(sourceNodeModules, dep);
const destPath = path.join(destNodeModules, dep);
try {
// Check if source exists (might be a symlink)
const stat = await fsPromises.lstat(sourcePath);
if (stat.isSymbolicLink()) {
// Resolve the symlink to get the real path
const realPath = await fsPromises.realpath(sourcePath);
console.log(` 📎 ${dep} (symlink -> ${path.relative(sourceNodeModules, realPath)})`);
// Create destination directory
await fsPromises.mkdir(path.dirname(destPath), { recursive: true });
// Copy the actual directory content (not the symlink)
await copyDir(realPath, destPath);
} else if (stat.isDirectory()) {
console.log(` 📁 ${dep}`);
await fsPromises.mkdir(path.dirname(destPath), { recursive: true });
await copyDir(sourcePath, destPath);
}
} catch (err) {
// Module might not exist (optional dependency for different platform)
console.log(` ⏭️ ${dep} (skipped: ${err.code || err.message})`);
}
}
console.log(`✅ Native modules copied successfully`);
}
/**
* Recursively copy a directory
* @param {string} src - Source directory
* @param {string} dest - Destination directory
*/
async function copyDir(src, dest) {
const fsPromises = await import('node:fs/promises');
await fsPromises.mkdir(dest, { recursive: true });
const entries = await fsPromises.readdir(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
await copyDir(srcPath, destPath);
} else if (entry.isSymbolicLink()) {
// For symlinks within the module, resolve and copy the actual file
const realPath = await fsPromises.realpath(srcPath);
const realStat = await fsPromises.stat(realPath);
if (realStat.isDirectory()) {
await copyDir(realPath, destPath);
} else {
await fsPromises.copyFile(realPath, destPath);
}
} else {
await fsPromises.copyFile(srcPath, destPath);
}
}
await copyModulesToDirectory(nativeModules, destNodeModules, 'native modules');
}
+2 -2
View File
@@ -44,6 +44,7 @@
"dependencies": {
"@lobehub/fluent-emoji": "^4.1.0",
"@napi-rs/canvas": "^0.1.70",
"electron-log": "^5.4.3",
"get-windows": "^9.3.0",
"node-screenshots": "^0.2.8"
},
@@ -79,7 +80,6 @@
"electron-builder": "^26.8.1",
"electron-devtools-installer": "4.0.0",
"electron-is": "^3.0.0",
"electron-log": "^5.4.3",
"electron-store": "^8.2.0",
"electron-updater": "^6.6.2",
"electron-vite": "6.0.0-beta.1",
@@ -109,7 +109,7 @@
"typescript": "^5.9.3",
"undici": "^7.16.0",
"uuid": "^14.0.0",
"vite": "^8.0.9",
"vite": "8.0.12",
"vitest": "^3.2.4",
"zod": "^3.25.76"
},
@@ -7,18 +7,18 @@ import { entryLocaleJsonFilepath, i18nConfig, localeDir, srcDefaultLocales } fro
import { tagWhite, writeJSON } from './utils';
export const genDefaultLocale = () => {
consola.info(`默认语言为 ${i18nConfig.entryLocale}...`);
consola.info(`Default locale: ${i18nConfig.entryLocale}...`);
// Ensure entry locale directory exists
const entryLocaleDir = localeDir(i18nConfig.entryLocale);
if (!existsSync(entryLocaleDir)) {
mkdirSync(entryLocaleDir, { recursive: true });
consola.info(`创建目录:${entryLocaleDir}`);
consola.info(`Creating directory: ${entryLocaleDir}`);
}
const resources = require(srcDefaultLocales);
const data = Object.entries(resources.default);
consola.start(`生成默认语言 JSON 文件,发现 ${data.length} 个命名空间...`);
consola.start(`Generating default locale JSON files, found ${data.length} namespaces...`);
for (const [ns, value] of data) {
const filepath = entryLocaleJsonFilepath(`${ns}.json`);
+3 -3
View File
@@ -13,7 +13,7 @@ import {
import { readJSON, tagWhite, writeJSON } from './utils';
export const genDiff = () => {
consola.start(`对比开发与生产环境中的本地化文件...`);
consola.start(`Comparing localization files between dev and prod environments...`);
const resources = require(srcDefaultLocales);
const data = Object.entries(resources.default);
@@ -21,7 +21,7 @@ export const genDiff = () => {
for (const [ns, devJSON] of data) {
const filepath = entryLocaleJsonFilepath(`${ns}.json`);
if (!existsSync(filepath)) {
consola.info(`文件不存在,跳过:${filepath}`);
consola.info(`File does not exist, skipping: ${filepath}`);
continue;
}
@@ -50,7 +50,7 @@ export const genDiff = () => {
}
if (clearLocals.length > 0) {
consola.info('清理了以下语言的过期项目:', clearLocals.join(', '));
consola.info('Cleaned up stale entries for the following locales:', clearLocals.join(', '));
}
consola.success(tagWhite(ns), colors.gray(filepath));
}
+3 -3
View File
@@ -21,15 +21,15 @@ const run = async () => {
ensureLocalesDirs();
// Diff analysis
split('差异分析');
split('Diff Analysis');
genDiff();
// Generate default locale files
split('生成默认语言文件');
split('Generate Default Locale Files');
genDefaultLocale();
// Generate i18n files
split('生成国际化文件');
split('Generate i18n Files');
};
run();
+3
View File
@@ -1 +1,4 @@
export const ELECTRON_BE_PROTOCOL_SCHEME = 'lobe-backend';
export const LOCAL_FILE_PROTOCOL_SCHEME = 'localfile';
export const LOCAL_FILE_PROTOCOL_HOST = 'file';
+1
View File
@@ -35,6 +35,7 @@ export const STORE_DEFAULTS: ElectronMainStore = {
gatewayEnabled: true,
gatewayUrl: 'https://device-gateway.lobehub.com',
locale: 'auto',
localFileWorkspaceRoots: [],
networkProxy: defaultProxySettings,
shortcuts: DEFAULT_ELECTRON_DESKTOP_SHORTCUTS,
storagePath: appStorageDir,
@@ -1,7 +1,9 @@
import type { AgentRunRequestMessage } from '@lobechat/device-gateway-client';
import type { GatewayConnectionStatus } from '@lobechat/electron-client-ipc';
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
import HeterogeneousAgentCtr from './HeterogeneousAgentCtr';
import { ControllerModule, IpcMethod } from './index';
import LocalFileCtr from './LocalFileCtr';
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
@@ -33,6 +35,10 @@ export default class GatewayConnectionCtr extends ControllerModule {
return this.app.getController(ShellCommandCtr);
}
private get heterogeneousAgentCtr() {
return this.app.getController(HeterogeneousAgentCtr);
}
// ─── Lifecycle ───
afterAppReady() {
@@ -47,6 +53,9 @@ export default class GatewayConnectionCtr extends ControllerModule {
// Wire up tool call handler
srv.setToolCallHandler((apiName, args) => this.executeToolCall(apiName, args));
// Wire up agent run handler
srv.setAgentRunHandler((request) => this.executeAgentRun(request));
// Auto-connect if already logged in
this.tryAutoConnect();
}
@@ -108,6 +117,45 @@ export default class GatewayConnectionCtr extends ControllerModule {
await this.service.connect();
}
// ─── Agent Run Routing ───
private async executeAgentRun(
request: AgentRunRequestMessage,
): Promise<{ reason?: string; status: 'accepted' | 'rejected' }> {
try {
const ctr = this.heterogeneousAgentCtr;
// Create a session for the hetero agent.
const { sessionId } = await ctr.startSession({
agentType: request.agentType,
args: [],
command: request.agentType === 'codex' ? 'codex' : 'claude',
cwd: request.cwd,
// Inject LOBEHUB_JWT so the CLI authenticates against heteroIngest.
env: { LOBEHUB_JWT: request.jwt },
resumeSessionId: request.resumeSessionId,
});
// Fire-and-forget: sendPrompt runs the CLI until completion.
ctr
.sendPrompt({
operationId: request.operationId,
prompt: request.prompt,
sessionId,
})
.catch((err: Error) => {
// Errors are surfaced via heteroFinish on the server side.
// Log locally for desktop debugging only.
console.error('[GatewayConnectionCtr] agent run failed:', err.message);
});
return { status: 'accepted' };
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
return { reason, status: 'rejected' };
}
}
// ─── Tool Call Routing ───
private async executeToolCall(apiName: string, args: any): Promise<unknown> {
+68 -1
View File
@@ -1,5 +1,5 @@
import { execFile, spawn } from 'node:child_process';
import { readFile, stat } from 'node:fs/promises';
import { readFile, rm, stat } from 'node:fs/promises';
import path from 'node:path';
import { promisify } from 'node:util';
@@ -11,6 +11,7 @@ import type {
GitBranchListItem,
GitCheckoutResult,
GitFileDiffStatus,
GitFileRevertResult,
GitLinkedPullRequestResult,
GitPullResult,
GitPushResult,
@@ -1106,4 +1107,70 @@ export default class GitController extends ControllerModule {
return { error: stderr || 'git push failed', success: false };
}
}
/**
* Revert a single working-tree change. Mirrors what "Discard changes" does
* in GitHub Desktop / VSCode SCM: restore the file to its HEAD state,
* dropping any unstaged / staged edits — and physically delete the file
* when it doesn't exist at HEAD (untracked or staged-add).
*
* Branch logic by HEAD presence:
* - present at HEAD → `git checkout HEAD -- <file>` (covers modified,
* deleted, staged-D — restores both index + worktree from HEAD)
* - absent at HEAD → `git rm --cached` (unstage if staged-A; silent
* no-op for untracked) + `fs.rm` to delete the file from disk
*
* filePath is the repo-relative path from `git status` output, the same
* shape we hand to the renderer in `GitWorkingTreePatch.filePath`. We
* reject absolute paths and `..` traversal so the renderer can't poke
* outside the repo even if its payload were tampered with.
*/
@IpcMethod()
async revertGitFile(payload: { filePath: string; path: string }): Promise<GitFileRevertResult> {
const { path: dirPath, filePath } = payload;
if (!filePath?.trim()) return { error: 'File path is required', success: false };
if (path.isAbsolute(filePath) || filePath.split(/[/\\]/).includes('..')) {
return { error: `Invalid file path: ${filePath}`, success: false };
}
const execFileAsync = promisify(execFile);
// Probe HEAD via cat-file -e — exit 0 means the blob exists at HEAD.
let existsAtHead: boolean;
try {
await execFileAsync('git', ['cat-file', '-e', `HEAD:${filePath}`], {
cwd: dirPath,
timeout: 5000,
});
existsAtHead = true;
} catch {
existsAtHead = false;
}
try {
if (existsAtHead) {
await execFileAsync('git', ['checkout', 'HEAD', '--', filePath], {
cwd: dirPath,
timeout: 15_000,
});
} else {
// Unstage if the file is in the index (staged-add). `git rm --cached`
// exits non-zero on untracked paths, which is fine — swallow it.
try {
await execFileAsync('git', ['rm', '--cached', '--quiet', '--', filePath], {
cwd: dirPath,
timeout: 5000,
});
} catch {
// not staged — fall through to the disk-delete
}
await rm(path.resolve(dirPath, filePath), { force: true, recursive: false });
}
return { success: true };
} catch (error: any) {
const stderr: string = (error?.stderr ?? error?.message ?? '').toString().trim();
logger.debug('[revertGitFile] failed', { filePath, stderr });
return { error: stderr || 'git revert failed', success: false };
}
}
}
@@ -1,7 +1,9 @@
import type { ChildProcess } from 'node:child_process';
import { spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { access, appendFile, mkdir, writeFile } from 'node:fs/promises';
import { unlinkSync } from 'node:fs';
import { access, appendFile, mkdir, unlink, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import type { Readable, Writable } from 'node:stream';
import { finished as streamFinished } from 'node:stream/promises';
@@ -14,6 +16,8 @@ import {
CODEX_CLI_INSTALL_DOCS_URL,
HeterogeneousAgentSessionErrorCode,
} from '@lobechat/electron-client-ipc';
import type { AskUserBridge } from '@lobechat/heterogeneous-agents/askUser';
import { AskUserMcpServer } from '@lobechat/heterogeneous-agents/askUser';
import type { AgentContentBlock } from '@lobechat/heterogeneous-agents/spawn';
import {
AgentStreamPipeline,
@@ -99,6 +103,18 @@ interface CancelSessionParams {
sessionId: string;
}
interface SubmitInterventionParams {
cancelled?: boolean;
/** When set, signals user-cancelled or timeout — the bridge resolves with isError. */
cancelReason?: 'timeout' | 'user_cancelled';
/** Operation id stamped on the request the renderer is responding to. */
operationId: string;
/** Structured user answer; ignored when `cancelled` is true. */
result?: unknown;
/** Correlation key carried on the original `agent_intervention_request`. */
toolCallId: string;
}
interface StopSessionParams {
sessionId: string;
}
@@ -150,10 +166,28 @@ interface CliTraceSession {
*
* Lifecycle: startSession → sendPrompt → (heteroAgentEvent broadcasts) → stopSession
*/
interface InterventionSlot {
bridge: AskUserBridge;
/** Resolves once bridge.events() iterator ends (after `cancelAll`). */
pumpDone: Promise<void>;
/** Path to the per-op temp `mcp.json` we wrote for `--mcp-config`. */
tmpConfigPath: string;
}
export default class HeterogeneousAgentCtr extends ControllerModule {
static override readonly groupName = 'heterogeneousAgent';
private sessions = new Map<string, AgentSession>();
/**
* Per-operation AskUserQuestion bridge state. Keyed by `operationId` so the
* `submitIntervention` IPC can route an answer to the right pending MCP
* handler regardless of which `sessionId` it belongs to (one session can
* fire many ops over its lifetime).
*/
private opIdToIntervention = new Map<string, InterventionSlot>();
/** Lazy single MCP server, started on first claude-code prompt. */
private askUserMcpServer?: AskUserMcpServer;
private askUserMcpStartPromise?: Promise<AskUserMcpServer>;
private resolveSessionCommand(session: AgentSession): string {
const resolvedCommand = session.command.trim();
@@ -567,6 +601,92 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
}
}
// ─── AskUserQuestion MCP server (LOBE-8725) ───
/**
* Lazy single-instance MCP server for CC's AskUserQuestion replacement.
* First claude-code prompt triggers `start()`; subsequent prompts reuse
* the same listener. Concurrent first-callers de-dupe via the in-flight
* promise so we don't bind two ports.
*/
private async ensureAskUserMcpServerStarted(): Promise<AskUserMcpServer> {
if (this.askUserMcpServer) return this.askUserMcpServer;
if (!this.askUserMcpStartPromise) {
this.askUserMcpStartPromise = (async () => {
const server = new AskUserMcpServer();
await server.start();
this.askUserMcpServer = server;
logger.info('AskUserQuestion MCP server started:', server.url);
return server;
})().catch((err) => {
// Reset so a later sendPrompt can retry; surface the error.
this.askUserMcpStartPromise = undefined;
logger.error('Failed to start AskUserQuestion MCP server:', err);
throw err;
});
}
return this.askUserMcpStartPromise;
}
/**
* Register a per-op AskUserQuestion bridge, write its temp `mcp.json`,
* and start pumping the bridge's outbound events into the regular
* `heteroAgentEvent` broadcast. Caller must invoke the returned cleanup
* after the spawn finishes (success, error, or cancel) to remove the
* temp file and tear down the bridge.
*
* Pump errors are logged but never thrown — they don't fail the spawn.
*/
private async setupInterventionForOp(
operationId: string,
sessionId: string,
): Promise<{ cleanup: () => Promise<void>; tmpConfigPath: string }> {
const server = await this.ensureAskUserMcpServerStarted();
const bridge = server.registerOperation(operationId);
const tmpConfigPath = path.join(os.tmpdir(), `lobe-cc-mcp-${operationId}.json`);
// `alwaysLoad: true` is the undocumented CC flag that promotes our
// server's tool out of the deferred set so the model calls it directly
// (no ToolSearch hop). See LOBE-8725 spike notes — falls back to the
// 2-hop ToolSearch path if a future CC drops the flag, no breakage.
const config = {
mcpServers: {
lobe_cc: {
alwaysLoad: true,
type: 'http' as const,
url: server.urlForOperation(operationId),
},
},
};
await writeFile(tmpConfigPath, JSON.stringify(config), 'utf8');
// Pump bridge.events() into the `heteroAgentEvent` broadcast. The
// iterator only ends after `cancelAll()`, so `pumpDone` resolves at
// cleanup time and gates teardown.
const pumpDone = (async () => {
for await (const event of bridge.events()) {
this.broadcast('heteroAgentEvent', { event, sessionId });
}
})().catch((err) => {
logger.warn('AskUserQuestion bridge pump error:', err);
});
this.opIdToIntervention.set(operationId, { bridge, pumpDone, tmpConfigPath });
const cleanup = async () => {
// Unregistering on the server cancels all bridge pendings AND closes
// the events iterator (cancelAll fires from within unregisterOperation).
this.askUserMcpServer?.unregisterOperation(operationId);
await pumpDone;
this.opIdToIntervention.delete(operationId);
await unlink(tmpConfigPath).catch(() => {
/* file may already be gone if app crashed mid-prompt */
});
};
return { cleanup, tmpConfigPath };
}
// ─── File cache ───
private get fileCacheDir(): string {
@@ -697,32 +817,58 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
throw new Error(preflightError.message);
}
const driver = getHeterogeneousAgentDriver(session.agentType);
const spawnPlan = await driver.buildSpawnPlan({
args: session.args,
helpers: {
buildClaudeStreamJsonInput: (prompt, imageList) =>
this.buildStreamJsonInput(prompt, imageList),
resolveCliImagePaths: (imageList) => this.resolveCliImagePaths(imageList),
},
imageList: params.imageList ?? [],
prompt: params.prompt,
resumeSessionId: session.agentSessionId,
});
// Stand up the AskUserQuestion MCP bridge for claude-code prompts BEFORE
// building the spawn plan so the driver can wire the temp config path
// into `--mcp-config`. Codex / future agents skip this entirely.
const intervention =
session.agentType === 'claude-code'
? await this.setupInterventionForOp(params.operationId, session.sessionId).catch((err) => {
logger.warn('Failed to set up AskUserQuestion bridge — proceeding without it:', err);
return undefined;
})
: undefined;
let spawnPlan;
let traceSession;
let cwd: string;
try {
const driver = getHeterogeneousAgentDriver(session.agentType);
spawnPlan = await driver.buildSpawnPlan({
args: session.args,
helpers: {
buildClaudeStreamJsonInput: (prompt, imageList) =>
this.buildStreamJsonInput(prompt, imageList),
resolveCliImagePaths: (imageList) => this.resolveCliImagePaths(imageList),
},
imageList: params.imageList ?? [],
mcpConfigPath: intervention?.tmpConfigPath,
prompt: params.prompt,
resumeSessionId: session.agentSessionId,
});
// Fall back to the user's Desktop so the process never inherits
// the Electron parent's cwd (which is `/` when launched from Finder).
cwd = session.cwd || electronApp.getPath('desktop');
traceSession = await this.createCliTraceSession({
cliArgs: spawnPlan.args,
cwd,
imageList: params.imageList ?? [],
session,
stdinPayload: spawnPlan.stdinPayload,
});
} catch (err) {
// We never made it to spawn — the `proc.on('exit')` cleanup path
// won't run, so tear the intervention bridge down right here.
if (intervention) {
await intervention.cleanup().catch((cleanupErr) => {
logger.warn('AskUserQuestion cleanup error during pre-spawn failure:', cleanupErr);
});
}
throw err;
}
const useStdin = spawnPlan.stdinPayload !== undefined;
const cliArgs = spawnPlan.args;
// Fall back to the user's Desktop so the process never inherits
// the Electron parent's cwd (which is `/` when launched from Finder).
const cwd = session.cwd || electronApp.getPath('desktop');
const traceSession = await this.createCliTraceSession({
cliArgs,
cwd,
imageList: params.imageList ?? [],
session,
stdinPayload: spawnPlan.stdinPayload,
});
return new Promise<void>((resolve, reject) => {
logger.info('Spawning agent:', session.command, cliArgs.join(' '), `(cwd: ${cwd})`);
@@ -838,6 +984,15 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
void stdoutDrained
.then(() => stdoutBroadcastQueue)
.finally(async () => {
// Tear down the AskUserQuestion bridge / temp `mcp.json` for this
// op. Pending MCP handlers get a `session_ended` cancellation so
// they return cleanly even if CC was killed mid-tool-call.
if (intervention) {
await intervention.cleanup().catch((err) => {
logger.warn('AskUserQuestion cleanup error:', err);
});
}
void this.writeCliTraceJson(traceSession, 'exit.json', {
code,
finishedAt: new Date().toISOString(),
@@ -972,10 +1127,54 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
}
/**
* Cleanup on app quit.
* Renderer → main: deliver the user's answer to a pending CC AskUserQuestion
* (or signal cancellation). The matching bridge resolves its blocked
* `pending()` Promise, the local MCP handler returns to CC, and CC's
* `tool_result` flows back through the normal stream pipeline.
*
* Idempotent — late submissions for already-resolved tool calls are no-ops.
* No-op when called for an unknown opId; the bridge may have been cleaned
* up already (op finished / cancelled).
*/
@IpcMethod()
async submitIntervention(params: SubmitInterventionParams): Promise<void> {
const slot = this.opIdToIntervention.get(params.operationId);
if (!slot) {
logger.warn('submitIntervention: no active intervention for operationId', params.operationId);
return;
}
slot.bridge.resolve(params.toolCallId, {
cancelReason: params.cancelled ? (params.cancelReason ?? 'user_cancelled') : undefined,
cancelled: params.cancelled,
result: params.result,
});
}
/**
* Synchronously unlink every pending intervention's temp `mcp.json`. The
* async exit-handler cleanup loses to Electron's main-process teardown
* often enough that we'd leak `lobe-cc-mcp-<opId>.json` files into
* `os.tmpdir()` on real shutdowns; sync unlink here is the only reliable
* guarantee. Safe to call multiple times.
*/
private unlinkPendingInterventionConfigsSync = (): void => {
for (const [, intervention] of this.opIdToIntervention) {
try {
unlinkSync(intervention.tmpConfigPath);
} catch {
/* file may already be gone — fine */
}
}
};
/**
* Cleanup on app quit. `before-quit` covers the user-driven Cmd+Q /
* `app.quit()` path; SIGTERM / SIGINT cover external kills (test
* harnesses, OS shutdown) where Electron's lifecycle events never fire.
*/
afterAppReady() {
electronApp.on('before-quit', () => {
this.unlinkPendingInterventionConfigsSync();
for (const [, session] of this.sessions) {
if (session.process && !session.process.killed) {
session.cancelledByUs = true;
@@ -983,6 +1182,28 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
}
}
this.sessions.clear();
// The exit handlers will tear each per-op intervention down, but if
// CC's stdio close races shutdown we'd leave the MCP server bound to
// a port. Stopping it here cancels every still-pending bridge with
// `session_ended` and closes the listener.
void this.askUserMcpServer?.stop().catch((err) => {
logger.warn('AskUserQuestion MCP server stop error:', err);
});
});
const onSignal = (signal: NodeJS.Signals) => {
this.unlinkPendingInterventionConfigsSync();
// Defer to Electron's normal quit flow so the rest of the app gets a
// chance to tear down. The `before-quit` handler above is idempotent.
try {
electronApp.quit();
} catch {
/* during late shutdown app.quit may throw — fine */
}
// Last-resort exit if Electron is wedged and won't quit on its own.
setTimeout(() => process.exit(signal === 'SIGINT' ? 130 : 143), 1000).unref();
};
process.on('SIGTERM', onSignal);
process.on('SIGINT', onSignal);
}
}
@@ -12,6 +12,8 @@ import {
type GrepContentParams,
type GrepContentResult,
type ListLocalFileParams,
type LocalFilePreviewUrlParams,
type LocalFilePreviewUrlResult,
type LocalMoveFilesResultItem,
type LocalReadFileParams,
type LocalReadFileResult,
@@ -370,6 +372,28 @@ export default class LocalFileCtr extends ControllerModule {
};
}
@IpcMethod()
async getLocalFilePreviewUrl({
path: filePath,
workingDirectory,
}: LocalFilePreviewUrlParams): Promise<LocalFilePreviewUrlResult> {
try {
const url = await this.app.localFileProtocolManager.createPreviewUrl({
filePath,
workspaceRoot: workingDirectory,
});
if (!url) {
return { error: 'File is outside the approved workspace', success: false };
}
return { success: true, url };
} catch (error) {
logger.error('Failed to create local file preview URL:', error);
return { error: (error as Error).message, success: false };
}
}
@IpcMethod()
async handlePrepareSkillDirectory({
forceRefresh,
@@ -532,6 +556,7 @@ export default class LocalFileCtr extends ControllerModule {
requestedScope,
root,
});
await this.approveProjectRootForPreview(root);
return {
entries,
@@ -560,6 +585,7 @@ export default class LocalFileCtr extends ControllerModule {
engine: fallback.engine,
requestedScope,
});
await this.approveProjectRootForPreview(requestedScope);
return {
entries,
@@ -641,4 +667,12 @@ export default class LocalFileCtr extends ControllerModule {
logger.debug(`Editing file ${params.file_path}`, { replace_all: params.replace_all });
return editLocalFile(params);
}
private async approveProjectRootForPreview(root: string) {
try {
await this.app.localFileProtocolManager.approveIndexedProjectRoot(root);
} catch (error) {
logger.error(`Failed to approve project preview root ${root}:`, error);
}
}
}
@@ -0,0 +1,43 @@
import type {
DetectAppsResult,
OpenInAppParams,
OpenInAppResult,
} from '@lobechat/electron-client-ipc';
import { getCachedDetection } from '@/modules/openInApp/cache';
import { detectApp } from '@/modules/openInApp/detectors';
import { launchApp } from '@/modules/openInApp/launchers';
import { createLogger } from '@/utils/logger';
import { ControllerModule, IpcMethod } from './index';
const logger = createLogger('controllers:OpenInAppCtr');
export default class OpenInAppCtr extends ControllerModule {
static override readonly groupName = 'openInApp';
@IpcMethod()
async detectApps(): Promise<DetectAppsResult> {
const apps = await getCachedDetection();
return { apps };
}
@IpcMethod()
async openInApp({ appId, path }: OpenInAppParams): Promise<OpenInAppResult> {
// Re-validate installation status before launching: per spec, the main
// process must reject if the app disappeared between probe and launch.
const installed = await detectApp(appId, process.platform);
if (!installed) {
logger.warn(`openInApp: ${appId} reported not installed`);
return { error: `${appId} is not installed`, success: false };
}
const result = await launchApp(appId, path, process.platform);
if (result.success) {
logger.info(`openInApp: launched ${appId} with path ${path}`);
} else {
logger.error(`openInApp: launch failed for ${appId}: ${result.error}`);
}
return result;
}
}
@@ -186,6 +186,19 @@ export default class SystemController extends ControllerModule {
const folderPath = result.filePaths[0];
const repoType = await detectRepoType(folderPath);
try {
const approvedRoot = await this.app.localFileProtocolManager.approveWorkspaceRoot(folderPath);
if (approvedRoot) {
const storedRoots = this.app.storeManager.get('localFileWorkspaceRoots', []);
if (!storedRoots.includes(approvedRoot)) {
this.app.storeManager.set('localFileWorkspaceRoots', [approvedRoot, ...storedRoots]);
}
}
} catch (error) {
logger.error(`Failed to approve local file workspace root ${folderPath}:`, error);
}
return { path: folderPath, repoType };
}
@@ -802,4 +802,131 @@ describe('HeterogeneousAgentCtr', () => {
expect(toolEnds.length).toBeGreaterThan(0);
});
});
describe('app-quit cleanup of AskUserQuestion temp configs (LOBE-8725)', () => {
// The async exit-handler cleanup races Electron's main-process teardown
// and used to leak `lobe-cc-mcp-<opId>.json` files in `os.tmpdir()` on
// every quit. The controller now unlinks pending intervention temp
// configs *synchronously* from `before-quit` AND from process signal
// handlers (SIGTERM / SIGINT — `before-quit` doesn't fire on external
// kills). These tests exercise both paths against real files.
/**
* Drop a temp `lobe-cc-mcp-<id>.json` and stash it on the controller's
* `opIdToIntervention` map under the same key, so the quit hook treats
* it like a real pending intervention and tries to unlink it.
*/
const seedPendingIntervention = async (ctr: HeterogeneousAgentCtr, opId: string) => {
const tmpConfigPath = path.join(tmpdir(), `lobe-cc-mcp-test-${opId}.json`);
await writeFile(tmpConfigPath, '{"mcpServers":{}}');
const slot = {
bridge: {} as any,
pumpDone: Promise.resolve(),
tmpConfigPath,
};
(ctr as any).opIdToIntervention.set(opId, slot);
return tmpConfigPath;
};
const captureRegisteredHandler = (
registerSpy: ReturnType<typeof vi.fn> | ReturnType<typeof vi.spyOn>,
eventName: string,
): (() => void) => {
const calls = (registerSpy as any).mock.calls as Array<[string, () => void]>;
const match = calls.findLast(([evt]) => evt === eventName);
if (!match) throw new Error(`no handler registered for "${eventName}"`);
return match[1];
};
it('before-quit synchronously unlinks every pending intervention temp config', async () => {
const electron = (await import('electron')) as any;
electron.app.on.mockClear();
const ctr = new HeterogeneousAgentCtr({
appStoragePath,
storeManager: { get: vi.fn() },
} as any);
const fileA = await seedPendingIntervention(ctr, 'opA');
const fileB = await seedPendingIntervention(ctr, 'opB');
ctr.afterAppReady();
const beforeQuit = captureRegisteredHandler(electron.app.on, 'before-quit');
beforeQuit();
await expect(access(fileA)).rejects.toThrow();
await expect(access(fileB)).rejects.toThrow();
});
it('SIGTERM handler unlinks pending intervention temp configs (external-kill path)', async () => {
// External kills (test harness, OS shutdown) skip Electron's lifecycle
// events entirely — `before-quit` never fires, so the controller has to
// hook the raw process signal too. Stub `process.on` so the handler is
// *recorded* but never actually attached to the test runner's process
// (otherwise the test leaks a SIGTERM listener that survives the test).
// Same for `process.exit` — the controller's fail-safe shouldn't get a
// chance to actually exit the worker if its `setTimeout(...).unref()`
// ever fires before mockRestore.
const electron = (await import('electron')) as any;
electron.app.on.mockClear();
const processOnSpy = vi.spyOn(process, 'on').mockImplementation(() => process);
const processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
const ctr = new HeterogeneousAgentCtr({
appStoragePath,
storeManager: { get: vi.fn() },
} as any);
const file = await seedPendingIntervention(ctr, 'opSigterm');
ctr.afterAppReady();
const sigterm = captureRegisteredHandler(processOnSpy, 'SIGTERM');
sigterm();
await expect(access(file)).rejects.toThrow();
processOnSpy.mockRestore();
processExitSpy.mockRestore();
});
it('SIGINT handler unlinks pending intervention temp configs (Ctrl-C path)', async () => {
const electron = (await import('electron')) as any;
electron.app.on.mockClear();
const processOnSpy = vi.spyOn(process, 'on').mockImplementation(() => process);
const processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
const ctr = new HeterogeneousAgentCtr({
appStoragePath,
storeManager: { get: vi.fn() },
} as any);
const file = await seedPendingIntervention(ctr, 'opSigint');
ctr.afterAppReady();
const sigint = captureRegisteredHandler(processOnSpy, 'SIGINT');
sigint();
await expect(access(file)).rejects.toThrow();
processOnSpy.mockRestore();
processExitSpy.mockRestore();
});
it('cleanup is idempotent — already-deleted files do not throw', async () => {
const electron = (await import('electron')) as any;
electron.app.on.mockClear();
const ctr = new HeterogeneousAgentCtr({
appStoragePath,
storeManager: { get: vi.fn() },
} as any);
const file = await seedPendingIntervention(ctr, 'opIdempotent');
// Pre-delete the file out from under the controller — simulates a
// partial cleanup race where the async exit handler beat us to it.
await unlink(file);
ctr.afterAppReady();
const beforeQuit = captureRegisteredHandler(electron.app.on, 'before-quit');
expect(() => beforeQuit()).not.toThrow();
});
});
});
@@ -0,0 +1,176 @@
import fs from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { type App } from '@/core/App';
import LocalFileCtr from '../LocalFileCtr';
// Real fs + real @lobechat/file-loaders end-to-end. We only mock the
// boundaries we genuinely cannot run in a test process: electron IPC,
// execa shell-outs, logger, net fetch.
vi.mock('electron', () => ({
dialog: { showOpenDialog: vi.fn(), showSaveDialog: vi.fn() },
ipcMain: { handle: vi.fn() },
shell: { openPath: vi.fn() },
}));
vi.mock('execa', () => ({ execa: vi.fn() }));
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
}),
}));
vi.mock('@/utils/net-fetch', () => ({ netFetch: vi.fn() }));
vi.mock('@/utils/file-system', () => ({ makeSureDirExist: vi.fn() }));
const mockApp = {
appStoragePath: '/mock/app/storage',
getService: vi.fn(),
toolDetectorManager: { getBestTool: vi.fn(() => null) },
} as unknown as App;
describe('LocalFileCtr — readFile / readFiles (real fs)', () => {
const tmpDir = path.join(os.tmpdir(), 'localfilectr-readfile-test-' + process.pid);
let localFileCtr: LocalFileCtr;
beforeEach(async () => {
vi.clearAllMocks();
await mkdir(tmpDir, { recursive: true });
localFileCtr = new LocalFileCtr(mockApp);
});
afterEach(() => {
fs.rmSync(tmpDir, { force: true, recursive: true });
});
describe('readFile', () => {
it('should read file successfully with default location', async () => {
const filePath = path.join(tmpDir, 'test.txt');
const content = 'line1\nline2\nline3\nline4\nline5';
await writeFile(filePath, content);
const result = await localFileCtr.readFile({ path: filePath });
expect(result).toEqual({
charCount: 29,
content,
createdTime: expect.any(Date),
fileType: 'txt',
filename: 'test.txt',
lineCount: 5,
loc: [0, 200],
modifiedTime: expect.any(Date),
totalCharCount: 29,
totalLineCount: 5,
});
});
it('should read file with custom location range', async () => {
const filePath = path.join(tmpDir, 'range.txt');
await writeFile(filePath, 'line1\nline2\nline3\nline4\nline5');
const result = await localFileCtr.readFile({ loc: [1, 3], path: filePath });
expect(result).toEqual({
charCount: 11,
content: 'line2\nline3',
createdTime: expect.any(Date),
fileType: 'txt',
filename: 'range.txt',
lineCount: 2,
loc: [1, 3],
modifiedTime: expect.any(Date),
totalCharCount: 29,
totalLineCount: 5,
});
});
it('should read full file content when fullContent is true', async () => {
const filePath = path.join(tmpDir, 'full.txt');
const content = 'line1\nline2\nline3\nline4\nline5';
await writeFile(filePath, content);
const result = await localFileCtr.readFile({ fullContent: true, path: filePath });
expect(result).toEqual({
charCount: 29,
content,
createdTime: expect.any(Date),
fileType: 'txt',
filename: 'full.txt',
lineCount: 5,
loc: [0, 5],
modifiedTime: expect.any(Date),
totalCharCount: 29,
totalLineCount: 5,
});
});
it('should handle file read error', async () => {
const result = await localFileCtr.readFile({
path: path.join(tmpDir, 'does-not-exist.txt'),
});
expect(result).toEqual({
charCount: 0,
content: expect.stringContaining('Error accessing or processing file'),
createdTime: expect.any(Date),
fileType: 'txt',
filename: 'does-not-exist.txt',
lineCount: 0,
loc: [0, 0],
modifiedTime: expect.any(Date),
totalCharCount: 0,
totalLineCount: 0,
});
});
});
describe('readFiles', () => {
it('should read multiple files successfully', async () => {
const file1 = path.join(tmpDir, 'a.txt');
const file2 = path.join(tmpDir, 'b.txt');
await writeFile(file1, 'content a');
await writeFile(file2, 'content b');
const result = await localFileCtr.readFiles({ paths: [file1, file2] });
expect(result).toEqual([
{
charCount: 9,
content: 'content a',
createdTime: expect.any(Date),
fileType: 'txt',
filename: 'a.txt',
lineCount: 1,
loc: [0, 200],
modifiedTime: expect.any(Date),
totalCharCount: 9,
totalLineCount: 1,
},
{
charCount: 9,
content: 'content b',
createdTime: expect.any(Date),
fileType: 'txt',
filename: 'b.txt',
lineCount: 1,
loc: [0, 200],
modifiedTime: expect.any(Date),
totalCharCount: 9,
totalLineCount: 1,
},
]);
});
});
});
@@ -84,6 +84,12 @@ const mockContentSearchService = {
checkToolAvailable: vi.fn(),
};
const mockLocalFileProtocolManager = {
approveIndexedProjectRoot: vi.fn(),
approveProjectRootFromScope: vi.fn(),
createPreviewUrl: vi.fn(),
};
// Mock makeSureDirExist
vi.mock('@/utils/file-system', () => ({
makeSureDirExist: vi.fn(),
@@ -98,6 +104,7 @@ const mockApp = {
}
return mockSearchService;
}),
localFileProtocolManager: mockLocalFileProtocolManager,
toolDetectorManager: {
getBestTool: vi.fn(() => null), // No external tools available, use Node.js fallback
},
@@ -106,7 +113,6 @@ const mockApp = {
describe('LocalFileCtr', () => {
let localFileCtr: LocalFileCtr;
let mockShell: any;
let mockLoadFile: any;
let mockFsPromises: any;
beforeEach(async () => {
@@ -114,7 +120,6 @@ describe('LocalFileCtr', () => {
// Import mocks
mockShell = (await import('electron')).shell;
mockLoadFile = (await import('@lobechat/file-loaders')).loadFile;
mockFsPromises = await import('node:fs/promises');
localFileCtr = new LocalFileCtr(mockApp);
@@ -178,89 +183,43 @@ describe('LocalFileCtr', () => {
});
});
describe('readFile', () => {
it('should read file successfully with default location', async () => {
const mockFileContent = 'line1\nline2\nline3\nline4\nline5';
vi.mocked(mockLoadFile).mockResolvedValue({
content: mockFileContent,
filename: 'test.txt',
fileType: 'txt',
createdTime: new Date('2024-01-01'),
modifiedTime: new Date('2024-01-02'),
// readFile / readFiles e2e tests live in LocalFileCtr.readFile.test.ts so
// they exercise real fs + file-loaders without fighting the heavy mocks
// this suite needs for execa-driven tools, electron, and the like.
describe('getLocalFilePreviewUrl', () => {
it('should return a main-issued preview URL for an approved workspace file', async () => {
mockLocalFileProtocolManager.createPreviewUrl.mockResolvedValue(
'localfile://file/workspace/app.ts?token=abc',
);
const result = await localFileCtr.getLocalFilePreviewUrl({
path: '/workspace/app.ts',
workingDirectory: '/workspace',
});
const result = await localFileCtr.readFile({ path: '/test/file.txt' });
expect(result.filename).toBe('test.txt');
expect(result.fileType).toBe('txt');
expect(result.totalLineCount).toBe(5);
expect(result.content).toBe(mockFileContent);
expect(mockLocalFileProtocolManager.createPreviewUrl).toHaveBeenCalledWith({
filePath: '/workspace/app.ts',
workspaceRoot: '/workspace',
});
expect(result).toEqual({
success: true,
url: 'localfile://file/workspace/app.ts?token=abc',
});
});
it('should read file with custom location range', async () => {
const mockFileContent = 'line1\nline2\nline3\nline4\nline5';
vi.mocked(mockLoadFile).mockResolvedValue({
content: mockFileContent,
filename: 'test.txt',
fileType: 'txt',
createdTime: new Date('2024-01-01'),
modifiedTime: new Date('2024-01-02'),
it('should reject preview URL creation outside an approved workspace', async () => {
mockLocalFileProtocolManager.createPreviewUrl.mockResolvedValue(null);
const result = await localFileCtr.getLocalFilePreviewUrl({
path: '/Users/alice/.ssh/id_rsa',
workingDirectory: '/workspace',
});
const result = await localFileCtr.readFile({ path: '/test/file.txt', loc: [1, 3] });
expect(result.content).toBe('line2\nline3');
expect(result.lineCount).toBe(2);
expect(result.totalLineCount).toBe(5);
});
it('should read full file content when fullContent is true', async () => {
const mockFileContent = 'line1\nline2\nline3\nline4\nline5';
vi.mocked(mockLoadFile).mockResolvedValue({
content: mockFileContent,
filename: 'test.txt',
fileType: 'txt',
createdTime: new Date('2024-01-01'),
modifiedTime: new Date('2024-01-02'),
expect(result).toEqual({
error: 'File is outside the approved workspace',
success: false,
});
const result = await localFileCtr.readFile({ path: '/test/file.txt', fullContent: true });
expect(result.content).toBe(mockFileContent);
expect(result.lineCount).toBe(5);
expect(result.charCount).toBe(mockFileContent.length);
expect(result.totalLineCount).toBe(5);
expect(result.totalCharCount).toBe(mockFileContent.length);
expect(result.loc).toEqual([0, 5]);
});
it('should handle file read error', async () => {
vi.mocked(mockLoadFile).mockRejectedValue(new Error('File not found'));
const result = await localFileCtr.readFile({ path: '/test/missing.txt' });
expect(result.content).toContain('Error accessing or processing file');
expect(result.lineCount).toBe(0);
expect(result.charCount).toBe(0);
});
});
describe('readFiles', () => {
it('should read multiple files successfully', async () => {
vi.mocked(mockLoadFile).mockResolvedValue({
content: 'file content',
filename: 'test.txt',
fileType: 'txt',
createdTime: new Date('2024-01-01'),
modifiedTime: new Date('2024-01-02'),
});
const result = await localFileCtr.readFiles({
paths: ['/test/file1.txt', '/test/file2.txt'],
});
expect(result).toHaveLength(2);
expect(mockLoadFile).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,147 @@
import type { DetectedApp, OpenInAppResult } from '@lobechat/electron-client-ipc';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { App } from '@/core/App';
import type { IpcContext } from '@/utils/ipc';
import { IpcHandler } from '@/utils/ipc/base';
import OpenInAppCtr from '../OpenInAppCtr';
const { getCachedDetectionMock, detectAppMock, launchAppMock, ipcHandlers, ipcMainHandleMock } =
vi.hoisted(() => {
const handlers = new Map<string, (event: any, ...args: any[]) => any>();
const handle = vi.fn((channel: string, handler: any) => {
handlers.set(channel, handler);
});
return {
detectAppMock: vi.fn(),
getCachedDetectionMock: vi.fn(),
ipcHandlers: handlers,
ipcMainHandleMock: handle,
launchAppMock: vi.fn(),
};
});
const invokeIpc = async <T = any>(
channel: string,
payload?: any,
context?: Partial<IpcContext>,
): Promise<T> => {
const handler = ipcHandlers.get(channel);
if (!handler) throw new Error(`IPC handler for ${channel} not found`);
const fakeEvent = {
sender: context?.sender ?? ({ id: 'test' } as any),
};
if (payload === undefined) {
return handler(fakeEvent);
}
return handler(fakeEvent, payload);
};
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
}),
}));
vi.mock('electron', () => ({
ipcMain: {
handle: ipcMainHandleMock,
},
}));
vi.mock('@/modules/openInApp/cache', () => ({
getCachedDetection: getCachedDetectionMock,
}));
vi.mock('@/modules/openInApp/detectors', () => ({
detectApp: detectAppMock,
}));
vi.mock('@/modules/openInApp/launchers', () => ({
launchApp: launchAppMock,
}));
const mockApp = {} as unknown as App;
describe('OpenInAppCtr', () => {
beforeEach(() => {
vi.clearAllMocks();
ipcHandlers.clear();
ipcMainHandleMock.mockClear();
(IpcHandler.getInstance() as any).registeredChannels?.clear();
new OpenInAppCtr(mockApp);
});
describe('detectApps', () => {
it('should call getCachedDetection and return the apps list', async () => {
const apps: DetectedApp[] = [
{ displayName: 'Visual Studio Code', id: 'vscode', installed: true },
{ displayName: 'Cursor', id: 'cursor', installed: false },
];
getCachedDetectionMock.mockResolvedValue(apps);
const result = await invokeIpc('openInApp.detectApps');
expect(getCachedDetectionMock).toHaveBeenCalledTimes(1);
expect(result).toEqual({ apps });
});
});
describe('openInApp', () => {
it('should launch the app when installed', async () => {
detectAppMock.mockResolvedValue(true);
const launchResult: OpenInAppResult = { success: true };
launchAppMock.mockResolvedValue(launchResult);
const result = await invokeIpc('openInApp.openInApp', {
appId: 'vscode',
path: '/tmp/project',
});
expect(detectAppMock).toHaveBeenCalledWith('vscode', process.platform);
expect(launchAppMock).toHaveBeenCalledWith('vscode', '/tmp/project', process.platform);
expect(result).toEqual({ success: true });
});
it('should not launch and return error when app is not installed', async () => {
detectAppMock.mockResolvedValue(false);
const result = await invokeIpc('openInApp.openInApp', {
appId: 'cursor',
path: '/tmp/project',
});
expect(detectAppMock).toHaveBeenCalledWith('cursor', process.platform);
expect(launchAppMock).not.toHaveBeenCalled();
expect(result).toEqual({
error: 'cursor is not installed',
success: false,
});
});
it('should pass through launch errors when launchApp fails', async () => {
detectAppMock.mockResolvedValue(true);
const launchResult: OpenInAppResult = {
error: 'Path not found: /tmp/missing',
success: false,
};
launchAppMock.mockResolvedValue(launchResult);
const result = await invokeIpc('openInApp.openInApp', {
appId: 'vscode',
path: '/tmp/missing',
});
expect(detectAppMock).toHaveBeenCalledWith('vscode', process.platform);
expect(launchAppMock).toHaveBeenCalledWith('vscode', '/tmp/missing', process.platform);
expect(result).toEqual(launchResult);
});
});
});
@@ -13,6 +13,7 @@ import McpInstallCtr from './McpInstallCtr';
import MenuController from './MenuCtr';
import NetworkProxyCtr from './NetworkProxyCtr';
import NotificationCtr from './NotificationCtr';
import OpenInAppCtr from './OpenInAppCtr';
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
import RemoteServerSyncCtr from './RemoteServerSyncCtr';
import ScreenCaptureCtr from './ScreenCaptureCtr';
@@ -37,6 +38,7 @@ export const controllerIpcConstructors = [
MenuController,
NetworkProxyCtr,
NotificationCtr,
OpenInAppCtr,
RemoteServerConfigCtr,
RemoteServerSyncCtr,
ScreenCaptureCtr,
+11
View File
@@ -31,6 +31,7 @@ import { createLogger } from '@/utils/logger';
import { BrowserManager } from './browser/BrowserManager';
import { I18nManager } from './infrastructure/I18nManager';
import { IoCContainer } from './infrastructure/IoCContainer';
import { LocalFileProtocolManager } from './infrastructure/LocalFileProtocolManager';
import { ProtocolManager } from './infrastructure/ProtocolManager';
import { RendererUrlManager } from './infrastructure/RendererUrlManager';
import { StaticFileServerManager } from './infrastructure/StaticFileServerManager';
@@ -62,6 +63,7 @@ export class App {
staticFileServerManager: StaticFileServerManager;
protocolManager: ProtocolManager;
rendererUrlManager: RendererUrlManager;
localFileProtocolManager: LocalFileProtocolManager;
toolDetectorManager: ToolDetectorManager;
screenCaptureManager: ScreenCaptureManager;
chromeFlags: string[] = ['OverlayScrollbar', 'FluentOverlayScrollbar', 'FluentScrollbar'];
@@ -102,6 +104,10 @@ export class App {
this.storeManager = new StoreManager(this);
this.rendererUrlManager = new RendererUrlManager();
this.localFileProtocolManager = new LocalFileProtocolManager();
void this.localFileProtocolManager.approveWorkspaceRoots(
this.storeManager.get('localFileWorkspaceRoots', []),
);
protocol.registerSchemesAsPrivileged([
{
privileges: {
@@ -114,6 +120,7 @@ export class App {
scheme: ELECTRON_BE_PROTOCOL_SCHEME,
},
this.rendererUrlManager.protocolScheme,
this.localFileProtocolManager.protocolScheme,
]);
// load controllers
@@ -152,6 +159,10 @@ export class App {
// should register before app ready
this.rendererUrlManager.configureRendererLoader();
// Serves arbitrary local files (e.g. project file previews) via
// `localfile://` to the renderer. Active in both dev and prod.
this.localFileProtocolManager.registerHandler();
// initialize protocol handlers
this.protocolManager.initialize();
@@ -115,9 +115,9 @@ vi.mock('../infrastructure/I18nManager', () => ({
vi.mock('../infrastructure/StoreManager', () => ({
StoreManager: vi.fn().mockImplementation(() => ({
get: vi.fn((key) => {
if (key === 'storagePath') return '/mock/storage/path';
return undefined;
get: vi.fn((_key, defaultValue) => {
if (_key === 'storagePath') return '/mock/storage/path';
return defaultValue;
}),
set: vi.fn(),
})),
@@ -0,0 +1,327 @@
import { randomUUID } from 'node:crypto';
import { readFile, realpath, stat } from 'node:fs/promises';
import path from 'node:path';
import { app, protocol } from 'electron';
import { LOCAL_FILE_PROTOCOL_HOST, LOCAL_FILE_PROTOCOL_SCHEME } from '@/const/protocol';
import { createLogger } from '@/utils/logger';
import { getExportMimeType } from '../../utils/mime';
const LOCAL_FILE_PROTOCOL_PRIVILEGES = {
allowServiceWorkers: false,
bypassCSP: false,
corsEnabled: true,
secure: true,
standard: true,
stream: true,
supportFetchAPI: true,
} as const;
const logger = createLogger('core:LocalFileProtocolManager');
const PREVIEW_TOKEN_TTL_MS = 5 * 60 * 1000;
const EXTRA_MIME_TYPES: Record<string, string> = {
'.avif': 'image/avif',
'.bmp': 'image/bmp',
'.heic': 'image/heic',
'.heif': 'image/heif',
'.tif': 'image/tiff',
'.tiff': 'image/tiff',
};
const getMimeType = (filePath: string): string => {
const ext = path.extname(filePath).toLowerCase();
return getExportMimeType(filePath) ?? EXTRA_MIME_TYPES[ext] ?? 'application/octet-stream';
};
const normalizeAbsolutePath = (filePath: string): string | null => {
const normalized = path.normalize(filePath);
return path.isAbsolute(normalized) ? normalized : null;
};
const isPathWithinRoot = (targetPath: string, rootPath: string): boolean => {
const relative = path.relative(rootPath, targetPath);
return (
relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative))
);
};
const buildLocalFileUrl = (absolutePath: string, token: string): string => {
const forwardSlashed = absolutePath.replaceAll('\\', '/');
const stripped = forwardSlashed.startsWith('/') ? forwardSlashed.slice(1) : forwardSlashed;
const encoded = stripped.split('/').map(encodeURIComponent).join('/');
const url = new URL(`${LOCAL_FILE_PROTOCOL_SCHEME}://${LOCAL_FILE_PROTOCOL_HOST}/${encoded}`);
url.searchParams.set('token', token);
return url.toString();
};
interface PreviewTokenRecord {
expiresAt: number;
realPath: string;
}
/**
* Custom `localfile://` protocol for project file previews.
*
* URL shape: `localfile://file/<percent-encoded-absolute-path>?token=<main-issued-token>`
* - host is fixed to `file` so the scheme behaves as `standard`
* - the absolute path is encoded in the URL pathname
* - every request must carry a short-lived token minted by the main process
*
* Examples:
* localfile://file//Users/alice/project/cat.png?token=...
* localfile://file/C:/Users/alice/project/cat.png?token=...
*/
export class LocalFileProtocolManager {
private readonly approvedWorkspaceRoots = new Set<string>();
private readonly indexedProjectRoots = new Set<string>();
private handlerRegistered = false;
private readonly previewTokens = new Map<string, PreviewTokenRecord>();
get protocolScheme() {
return {
privileges: LOCAL_FILE_PROTOCOL_PRIVILEGES,
scheme: LOCAL_FILE_PROTOCOL_SCHEME,
};
}
registerHandler() {
if (this.handlerRegistered) return;
const register = () => {
if (this.handlerRegistered) return;
protocol.handle(LOCAL_FILE_PROTOCOL_SCHEME, async (request) => {
try {
const url = new URL(request.url);
if (url.hostname !== LOCAL_FILE_PROTOCOL_HOST) {
return new Response('Not Found', { status: 404 });
}
const resolvedPath = this.resolveFilePath(url.pathname);
if (!resolvedPath) {
return new Response('Invalid path', { status: 400 });
}
const token = url.searchParams.get('token');
if (!token) {
return new Response('Forbidden', { status: 403 });
}
if (!this.hasPreviewToken(token)) {
return new Response('Forbidden', { status: 403 });
}
const realResolvedPath = normalizeAbsolutePath(await realpath(resolvedPath));
if (!realResolvedPath || !this.verifyPreviewToken(token, realResolvedPath)) {
return new Response('Forbidden', { status: 403 });
}
const fileStat = await stat(realResolvedPath);
if (!fileStat.isFile()) {
return new Response('Not a file', { status: 404 });
}
const buffer = await readFile(realResolvedPath);
const headers = new Headers();
headers.set('Content-Type', getMimeType(realResolvedPath));
headers.set('Content-Length', String(buffer.byteLength));
// Local files are immutable from the renderer's perspective for a
// single preview session; allow short-lived caching to avoid
// re-reading large images during scrolling/refresh.
headers.set('Cache-Control', 'private, max-age=60');
return new Response(buffer, { headers, status: 200 });
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT' || code === 'ENOTDIR') {
return new Response('Not Found', { status: 404 });
}
if (code === 'EACCES' || code === 'EPERM') {
return new Response('Forbidden', { status: 403 });
}
logger.error(`Failed to serve localfile request ${request.url}:`, error);
return new Response('Internal Server Error', { status: 500 });
}
});
this.handlerRegistered = true;
logger.debug(`Registered ${LOCAL_FILE_PROTOCOL_SCHEME}:// handler`);
};
if (app.isReady()) {
register();
} else {
app.whenReady().then(register);
}
}
async approveWorkspaceRoot(rootPath: string): Promise<string | null> {
const normalizedRoot = normalizeAbsolutePath(rootPath);
if (!normalizedRoot) return null;
const realRoot = normalizeAbsolutePath(await realpath(normalizedRoot));
if (!realRoot) return null;
this.approvedWorkspaceRoots.add(realRoot);
return realRoot;
}
async approveWorkspaceRoots(rootPaths: string[] = []): Promise<string[]> {
const approvedRoots = await Promise.allSettled(
rootPaths.map((rootPath) => this.approveWorkspaceRoot(rootPath)),
);
return approvedRoots
.map((result) => (result.status === 'fulfilled' ? result.value : null))
.filter((rootPath): rootPath is string => !!rootPath);
}
async approveProjectRootFromScope({
projectRoot,
requestedScope,
}: {
projectRoot: string;
requestedScope: string;
}): Promise<string | null> {
const [realProjectRoot, realRequestedScope] = await Promise.all([
realpath(projectRoot),
realpath(requestedScope),
]);
const normalizedProjectRoot = normalizeAbsolutePath(realProjectRoot);
const normalizedRequestedScope = normalizeAbsolutePath(realRequestedScope);
if (!normalizedProjectRoot || !normalizedRequestedScope) return null;
const scopeIsApproved = [...this.approvedWorkspaceRoots].some(
(approvedRoot) =>
normalizedRequestedScope === approvedRoot ||
isPathWithinRoot(normalizedRequestedScope, approvedRoot),
);
if (!scopeIsApproved) return null;
this.approvedWorkspaceRoots.add(normalizedProjectRoot);
return normalizedProjectRoot;
}
async approveIndexedProjectRoot(projectRoot: string): Promise<string | null> {
const normalizedProjectRoot = normalizeAbsolutePath(projectRoot);
if (!normalizedProjectRoot) return null;
const realProjectRoot = normalizeAbsolutePath(await realpath(normalizedProjectRoot));
if (!realProjectRoot) return null;
this.indexedProjectRoots.add(realProjectRoot);
return realProjectRoot;
}
async createPreviewUrl({
filePath,
workspaceRoot,
}: {
filePath: string;
workspaceRoot: string;
}): Promise<string | null> {
const normalizedFilePath = normalizeAbsolutePath(filePath);
const normalizedWorkspaceRoot = normalizeAbsolutePath(workspaceRoot);
if (!normalizedFilePath || !normalizedWorkspaceRoot) return null;
const [realFilePath, realWorkspaceRoot] = await Promise.all([
realpath(normalizedFilePath),
realpath(normalizedWorkspaceRoot),
]);
const normalizedRealFilePath = normalizeAbsolutePath(realFilePath);
const normalizedRealWorkspaceRoot = normalizeAbsolutePath(realWorkspaceRoot);
if (!normalizedRealFilePath || !normalizedRealWorkspaceRoot) return null;
if (
!this.approvedWorkspaceRoots.has(normalizedRealWorkspaceRoot) &&
!this.indexedProjectRoots.has(normalizedRealWorkspaceRoot)
) {
return null;
}
if (!isPathWithinRoot(normalizedRealFilePath, normalizedRealWorkspaceRoot)) return null;
this.cleanupExpiredTokens();
const token = randomUUID();
this.previewTokens.set(token, {
expiresAt: Date.now() + PREVIEW_TOKEN_TTL_MS,
realPath: normalizedRealFilePath,
});
return buildLocalFileUrl(normalizedFilePath, token);
}
/**
* Decode the URL pathname back into an absolute filesystem path.
*
* Pathname examples produced by `new URL('localfile://file//abs/path')`:
* posix: `//abs/path` -> `/abs/path`
* windows: `/C:/abs/path` -> `C:/abs/path`
*
* Returns null when the path is non-absolute or escapes via segments we
* cannot safely normalize (defense-in-depth, not a sandbox).
*/
private resolveFilePath(pathname: string): string | null {
let decoded: string;
try {
decoded = decodeURIComponent(pathname);
} catch {
return null;
}
// Strip the single leading slash inserted by URL parsing on standard
// schemes; what remains should already be an absolute filesystem path.
let candidate = decoded.startsWith('/') ? decoded.slice(1) : decoded;
if (!candidate) return null;
if (process.platform === 'win32') {
// posix-style absolute path won't have a drive letter; treat as invalid
// on Windows.
candidate = candidate.replaceAll('/', '\\');
} else if (!candidate.startsWith('/')) {
// We expect an absolute POSIX path: `localfile://file//abs/path` yields
// pathname `//abs/path` -> after stripping one slash -> `/abs/path`.
candidate = `/${candidate}`;
}
const normalized = path.normalize(candidate);
if (!path.isAbsolute(normalized)) return null;
return normalized;
}
private cleanupExpiredTokens() {
const now = Date.now();
for (const [token, record] of this.previewTokens) {
if (record.expiresAt <= now) {
this.previewTokens.delete(token);
}
}
}
private hasPreviewToken(token: string): boolean {
const record = this.previewTokens.get(token);
if (!record) return false;
if (record.expiresAt <= Date.now()) {
this.previewTokens.delete(token);
return false;
}
return true;
}
private verifyPreviewToken(token: string, realResolvedPath: string): boolean {
const record = this.previewTokens.get(token);
if (!record) return false;
return record.realPath === realResolvedPath;
}
}
@@ -0,0 +1,298 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { LocalFileProtocolManager } from '../LocalFileProtocolManager';
const { mockApp, mockProtocol, mockReadFile, mockRealpath, mockStat, protocolHandlerRef } =
vi.hoisted(() => {
const protocolHandlerRef = { current: null as any };
return {
mockApp: {
isReady: vi.fn().mockReturnValue(true),
whenReady: vi.fn().mockResolvedValue(undefined),
},
mockProtocol: {
handle: vi.fn((_scheme: string, handler: any) => {
protocolHandlerRef.current = handler;
}),
},
mockReadFile: vi.fn(),
mockRealpath: vi.fn(),
mockStat: vi.fn(),
protocolHandlerRef,
};
});
vi.mock('electron', () => ({
app: mockApp,
protocol: mockProtocol,
}));
vi.mock('node:fs/promises', () => ({
realpath: mockRealpath,
readFile: mockReadFile,
stat: mockStat,
}));
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
}),
}));
describe('LocalFileProtocolManager', () => {
beforeEach(() => {
vi.clearAllMocks();
protocolHandlerRef.current = null;
mockApp.isReady.mockReturnValue(true);
mockRealpath.mockImplementation(async (filePath: string) => filePath);
mockStat.mockImplementation(async () => ({ isFile: () => true, size: 1024 }));
mockReadFile.mockImplementation(async () => Buffer.from('image-bytes'));
});
afterEach(() => {
protocolHandlerRef.current = null;
});
it('exposes scheme metadata for registerSchemesAsPrivileged', () => {
const manager = new LocalFileProtocolManager();
expect(manager.protocolScheme).toEqual({
privileges: expect.objectContaining({
bypassCSP: false,
secure: true,
standard: true,
supportFetchAPI: true,
}),
scheme: 'localfile',
});
});
it('serves a POSIX absolute path with the correct mime type', async () => {
const manager = new LocalFileProtocolManager();
manager.registerHandler();
await manager.approveWorkspaceRoot('/Users/alice');
const url = await manager.createPreviewUrl({
filePath: '/Users/alice/Pictures/cat.png',
workspaceRoot: '/Users/alice',
});
if (!url) throw new Error('Expected local file preview URL');
expect(mockProtocol.handle).toHaveBeenCalledWith('localfile', expect.any(Function));
const handler = protocolHandlerRef.current;
const response = await handler({
headers: new Headers(),
method: 'GET',
url,
});
expect(mockStat).toHaveBeenCalledWith('/Users/alice/Pictures/cat.png');
expect(mockReadFile).toHaveBeenCalledWith('/Users/alice/Pictures/cat.png');
expect(response.status).toBe(200);
expect(response.headers.get('Content-Type')).toBe('image/png');
expect(response.headers.get('Content-Length')).toBe('11'); // 'image-bytes'.length
});
it('serves source files as text through the localfile protocol', async () => {
const manager = new LocalFileProtocolManager();
manager.registerHandler();
await manager.approveWorkspaceRoot('/Users/alice/project');
const url = await manager.createPreviewUrl({
filePath: '/Users/alice/project/App.tsx',
workspaceRoot: '/Users/alice/project',
});
if (!url) throw new Error('Expected local file preview URL');
const handler = protocolHandlerRef.current;
const response = await handler({
headers: new Headers(),
method: 'GET',
url,
});
expect(mockStat).toHaveBeenCalledWith('/Users/alice/project/App.tsx');
expect(mockReadFile).toHaveBeenCalledWith('/Users/alice/project/App.tsx');
expect(response.status).toBe(200);
expect(response.headers.get('Content-Type')).toBe('text/plain; charset=utf-8');
});
it('decodes percent-encoded characters in the path', async () => {
const manager = new LocalFileProtocolManager();
manager.registerHandler();
await manager.approveWorkspaceRoot('/Users/alice');
const url = await manager.createPreviewUrl({
filePath: '/Users/alice/My Pictures/图 #.png',
workspaceRoot: '/Users/alice',
});
if (!url) throw new Error('Expected local file preview URL');
const handler = protocolHandlerRef.current;
await handler({
headers: new Headers(),
method: 'GET',
url,
});
expect(mockStat).toHaveBeenCalledWith('/Users/alice/My Pictures/图 #.png');
});
it('rejects requests to a different host', async () => {
const manager = new LocalFileProtocolManager();
manager.registerHandler();
const handler = protocolHandlerRef.current;
const response = await handler({
headers: new Headers(),
method: 'GET',
url: 'localfile://other/Users/alice/cat.png',
});
expect(response.status).toBe(404);
expect(mockStat).not.toHaveBeenCalled();
});
it('returns 404 when the path is a directory', async () => {
mockStat.mockImplementation(async () => ({ isFile: () => false, size: 0 }));
const manager = new LocalFileProtocolManager();
manager.registerHandler();
await manager.approveWorkspaceRoot('/Users/alice');
const url = await manager.createPreviewUrl({
filePath: '/Users/alice/folder',
workspaceRoot: '/Users/alice',
});
if (!url) throw new Error('Expected local file preview URL');
const handler = protocolHandlerRef.current;
const response = await handler({
headers: new Headers(),
method: 'GET',
url,
});
expect(response.status).toBe(404);
expect(mockReadFile).not.toHaveBeenCalled();
});
it('maps ENOENT errors to a 404 response', async () => {
mockStat.mockImplementation(async () => {
const err: NodeJS.ErrnoException = new Error('no such file');
err.code = 'ENOENT';
throw err;
});
const manager = new LocalFileProtocolManager();
manager.registerHandler();
await manager.approveWorkspaceRoot('/');
const handler = protocolHandlerRef.current;
const url = await manager.createPreviewUrl({
filePath: '/nonexistent.png',
workspaceRoot: '/',
});
if (!url) throw new Error('Expected local file preview URL');
const response = await handler({
headers: new Headers(),
method: 'GET',
url,
});
expect(response.status).toBe(404);
});
it('rejects direct localfile requests without a main-issued preview token', async () => {
const manager = new LocalFileProtocolManager();
manager.registerHandler();
const handler = protocolHandlerRef.current;
const response = await handler({
headers: new Headers(),
method: 'GET',
url: 'localfile://file/Users/alice/.ssh/id_rsa',
});
expect(response.status).toBe(403);
expect(mockStat).not.toHaveBeenCalled();
expect(mockReadFile).not.toHaveBeenCalled();
});
it('rejects forged preview tokens before resolving the requested path', async () => {
const manager = new LocalFileProtocolManager();
manager.registerHandler();
const handler = protocolHandlerRef.current;
const response = await handler({
headers: new Headers(),
method: 'GET',
url: 'localfile://file/Users/alice/.ssh/id_rsa?token=forged',
});
expect(response.status).toBe(403);
expect(mockRealpath).not.toHaveBeenCalled();
expect(mockStat).not.toHaveBeenCalled();
expect(mockReadFile).not.toHaveBeenCalled();
});
it('does not mint preview URLs outside an approved workspace root', async () => {
const manager = new LocalFileProtocolManager();
await manager.approveWorkspaceRoot('/Users/alice/project');
const url = await manager.createPreviewUrl({
filePath: '/Users/alice/.ssh/id_rsa',
workspaceRoot: '/Users/alice/project',
});
expect(url).toBeNull();
});
it('can approve a project root derived from an already approved nested scope', async () => {
const manager = new LocalFileProtocolManager();
await manager.approveWorkspaceRoot('/Users/alice/project/packages/app');
await manager.approveProjectRootFromScope({
projectRoot: '/Users/alice/project',
requestedScope: '/Users/alice/project/packages/app',
});
const url = await manager.createPreviewUrl({
filePath: '/Users/alice/project/root.ts',
workspaceRoot: '/Users/alice/project',
});
if (!url) throw new Error('Expected local file preview URL');
expect(url).toContain('token=');
});
it('can mint preview URLs for roots produced by the main-process project index', async () => {
const manager = new LocalFileProtocolManager();
await manager.approveIndexedProjectRoot('/Users/alice/project');
const url = await manager.createPreviewUrl({
filePath: '/Users/alice/project/App.tsx',
workspaceRoot: '/Users/alice/project',
});
if (!url) throw new Error('Expected local file preview URL');
expect(url).toContain('token=');
});
it('defers registration until app ready when not yet ready', async () => {
mockApp.isReady.mockReturnValue(false);
let resolveReady: () => void = () => undefined;
mockApp.whenReady.mockReturnValue(
new Promise<void>((resolve) => {
resolveReady = resolve;
}),
);
const manager = new LocalFileProtocolManager();
manager.registerHandler();
expect(mockProtocol.handle).not.toHaveBeenCalled();
resolveReady();
await new Promise((r) => setImmediate(r));
expect(mockProtocol.handle).toHaveBeenCalled();
});
});
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import type {
HeterogeneousAgentBuildPlanHelpers,
HeterogeneousAgentBuildPlanParams,
} from '../types';
import { claudeCodeDriver } from './claudeCode';
const stubHelpers: HeterogeneousAgentBuildPlanHelpers = {
buildClaudeStreamJsonInput: async () => '{"type":"user","message":{}}\n',
resolveCliImagePaths: async () => [],
};
const buildParams = (
overrides: Partial<HeterogeneousAgentBuildPlanParams> = {},
): HeterogeneousAgentBuildPlanParams => ({
args: [],
helpers: stubHelpers,
imageList: [],
prompt: 'hi',
...overrides,
});
describe('claudeCodeDriver', () => {
it('omits --mcp-config when mcpConfigPath is undefined', async () => {
const { args } = await claudeCodeDriver.buildSpawnPlan(buildParams());
expect(args).not.toContain('--mcp-config');
});
it('appends --mcp-config <path> when mcpConfigPath is provided', async () => {
const { args } = await claudeCodeDriver.buildSpawnPlan(
buildParams({ mcpConfigPath: '/tmp/lobe-cc-mcp-op-1.json' }),
);
const idx = args.indexOf('--mcp-config');
expect(idx).toBeGreaterThan(-1);
expect(args[idx + 1]).toBe('/tmp/lobe-cc-mcp-op-1.json');
});
it('still pins --disallowedTools AskUserQuestion alongside --mcp-config', async () => {
// Even with our local MCP replacement available, CC's built-in stays
// disabled — leaving both visible would let the model double-register
// the same name and pick the broken one.
const { args } = await claudeCodeDriver.buildSpawnPlan(
buildParams({ mcpConfigPath: '/tmp/x.json' }),
);
const disallowedIdx = args.indexOf('--disallowedTools');
expect(disallowedIdx).toBeGreaterThan(-1);
expect(args[disallowedIdx + 1]).toBe('AskUserQuestion');
});
it('--mcp-config goes before --resume so user --args can still override the resume id', async () => {
const { args } = await claudeCodeDriver.buildSpawnPlan(
buildParams({ mcpConfigPath: '/tmp/x.json', resumeSessionId: 'cc-prev-1' }),
);
const mcpIdx = args.indexOf('--mcp-config');
const resumeIdx = args.indexOf('--resume');
expect(mcpIdx).toBeGreaterThan(-1);
expect(resumeIdx).toBeGreaterThan(-1);
expect(mcpIdx).toBeLessThan(resumeIdx);
expect(args[resumeIdx + 1]).toBe('cc-prev-1');
});
});
@@ -1,12 +1,13 @@
import { CLAUDE_CODE_BASE_ARGS } from '@lobechat/heterogeneous-agents/spawn';
import type { HeterogeneousAgentBuildPlanParams, HeterogeneousAgentDriver } from '../types';
const CLAUDE_CODE_BASE_ARGS = [
'-p',
'--input-format',
'stream-json',
'--output-format',
'stream-json',
'--verbose',
// Desktop runs CC as the user (never root, so bypassPermissions is fine) and
// renders the chat bubble live, so it always wants partial deltas. Compose
// the shared invariant base args (`@lobechat/heterogeneous-agents/spawn`)
// with those caller-specific flags.
const DESKTOP_CLAUDE_CODE_ARGS = [
...CLAUDE_CODE_BASE_ARGS,
'--include-partial-messages',
'--permission-mode',
'bypassPermissions',
@@ -17,6 +18,7 @@ export const claudeCodeDriver: HeterogeneousAgentDriver = {
args,
helpers,
imageList,
mcpConfigPath,
prompt,
resumeSessionId,
}: HeterogeneousAgentBuildPlanParams) {
@@ -24,7 +26,11 @@ export const claudeCodeDriver: HeterogeneousAgentDriver = {
return {
args: [
...CLAUDE_CODE_BASE_ARGS,
...DESKTOP_CLAUDE_CODE_ARGS,
// Wire the controller-managed temp mcp.json (AskUserQuestion server,
// see LOBE-8725) when present. Path-based config is required — CC
// does not accept inline JSON for `--mcp-config`.
...(mcpConfigPath ? ['--mcp-config', mcpConfigPath] : []),
...(resumeSessionId ? ['--resume', resumeSessionId] : []),
...args,
],
@@ -20,6 +20,12 @@ export interface HeterogeneousAgentBuildPlanParams {
args: string[];
helpers: HeterogeneousAgentBuildPlanHelpers;
imageList: HeterogeneousAgentImageAttachment[];
/**
* Optional path to an MCP config JSON written by the controller (e.g. for
* the local `lobe_cc` AskUserQuestion server). Drivers that recognize the
* field append `--mcp-config <path>`; others ignore it.
*/
mcpConfigPath?: string;
prompt: string;
resumeSessionId?: string;
}
@@ -0,0 +1,87 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { clearDetectionCache, getCachedDetection } from '../cache';
import { detectAllApps } from '../detectors';
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
}),
}));
vi.mock('../detectors', () => ({
detectAllApps: vi.fn(),
}));
const mockedDetectAll = vi.mocked(detectAllApps);
beforeEach(() => {
vi.clearAllMocks();
clearDetectionCache();
});
describe('getCachedDetection', () => {
it('invokes detection on first call', async () => {
mockedDetectAll.mockResolvedValueOnce([
{ displayName: 'VS Code', id: 'vscode', installed: true },
]);
const result = await getCachedDetection('darwin');
expect(result).toEqual([{ displayName: 'VS Code', id: 'vscode', installed: true }]);
expect(mockedDetectAll).toHaveBeenCalledTimes(1);
});
it('concurrent callers share a single inflight promise', async () => {
let resolveFn: (value: any) => void = () => {};
const inflight = new Promise<any>((resolve) => {
resolveFn = resolve;
});
mockedDetectAll.mockReturnValueOnce(inflight);
const p1 = getCachedDetection('darwin');
const p2 = getCachedDetection('darwin');
const p3 = getCachedDetection('darwin');
expect(mockedDetectAll).toHaveBeenCalledTimes(1);
resolveFn([{ displayName: 'VS Code', id: 'vscode', installed: true }]);
const results = await Promise.all([p1, p2, p3]);
// all three share the same resolved value
expect(results[0]).toBe(results[1]);
expect(results[1]).toBe(results[2]);
expect(mockedDetectAll).toHaveBeenCalledTimes(1);
});
it('subsequent serial calls reuse the cached promise', async () => {
mockedDetectAll.mockResolvedValueOnce([
{ displayName: 'VS Code', id: 'vscode', installed: true },
]);
await getCachedDetection('darwin');
await getCachedDetection('darwin');
await getCachedDetection('darwin');
expect(mockedDetectAll).toHaveBeenCalledTimes(1);
});
it('re-invokes detection after clearDetectionCache', async () => {
mockedDetectAll.mockResolvedValueOnce([
{ displayName: 'VS Code', id: 'vscode', installed: true },
]);
await getCachedDetection('darwin');
expect(mockedDetectAll).toHaveBeenCalledTimes(1);
clearDetectionCache();
mockedDetectAll.mockResolvedValueOnce([
{ displayName: 'VS Code', id: 'vscode', installed: false },
]);
await getCachedDetection('darwin');
expect(mockedDetectAll).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,274 @@
import { execFile } from 'node:child_process';
import { access } from 'node:fs/promises';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { detectAllApps, detectApp } from '../detectors';
import { extractAllIcons } from '../iconExtractor';
// Mock logger
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
}),
}));
// Mock node:fs/promises
vi.mock('node:fs/promises', () => ({
access: vi.fn(),
}));
// Mock node:child_process - execFile is wrapped via promisify, so the mock must
// expose execFile as the underlying callback-style function we can drive.
vi.mock('node:child_process', () => ({
execFile: vi.fn(),
}));
// Mock the icon extractor — detection tests should not depend on real icon
// extraction. The default returns an empty Map (no icons) which leaves the
// `icon` field absent from all detection results.
vi.mock('../iconExtractor', () => ({
extractAllIcons: vi.fn(async () => new Map<string, string>()),
}));
const mockedAccess = vi.mocked(access);
const mockedExecFile = vi.mocked(execFile) as unknown as ReturnType<typeof vi.fn>;
interface ExecOutcome {
code: number;
error?: NodeJS.ErrnoException;
stderr?: string;
stdout?: string;
}
const respondExec = (outcome: ExecOutcome) => {
mockedExecFile.mockImplementationOnce(
(_file: string, _args: string[], _opts: unknown, cb: any) => {
const callback = typeof _opts === 'function' ? _opts : cb;
if (outcome.code === 0) {
callback(null, outcome.stdout ?? '', outcome.stderr ?? '');
} else {
const err: NodeJS.ErrnoException & { stderr?: string } =
outcome.error ?? new Error('exec failed');
err.stderr = outcome.stderr ?? '';
(err as any).code = outcome.code;
callback(err, '', outcome.stderr ?? '');
}
return undefined as any;
},
);
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('detectApp', () => {
describe('appBundle strategy', () => {
it('returns true when fs.access resolves for any path', async () => {
mockedAccess.mockRejectedValueOnce(new Error('missing'));
mockedAccess.mockResolvedValueOnce(undefined);
const result = await detectApp('terminal', 'darwin');
expect(result).toBe(true);
expect(mockedAccess).toHaveBeenCalledTimes(2);
});
it('returns false when all paths reject', async () => {
mockedAccess.mockRejectedValue(new Error('missing'));
const result = await detectApp('vscode', 'darwin');
expect(result).toBe(false);
});
});
describe('commandV strategy', () => {
it('returns true on exit 0', async () => {
respondExec({ code: 0, stdout: '/usr/bin/zed' });
const result = await detectApp('zed', 'linux');
expect(result).toBe(true);
expect(mockedExecFile).toHaveBeenCalledWith(
'/bin/sh',
['-c', 'command -v "zed"'],
expect.any(Function),
);
});
it('returns false on non-zero exit', async () => {
respondExec({ code: 1, stderr: 'not found' });
const result = await detectApp('zed', 'linux');
expect(result).toBe(false);
});
it('rejects unsafe binary names without spawning a shell', async () => {
// We monkey-patch a registry entry transiently to inject a malicious binary.
const registry = await import('../registry');
const originalGhostty = registry.APP_REGISTRY.ghostty.detect.linux;
registry.APP_REGISTRY.ghostty.detect.linux = {
binary: 'foo; rm -rf /',
type: 'commandV',
};
const result = await detectApp('ghostty', 'linux');
expect(result).toBe(false);
expect(mockedExecFile).not.toHaveBeenCalled();
registry.APP_REGISTRY.ghostty.detect.linux = originalGhostty;
});
});
describe('registryAppPaths strategy', () => {
it('returns true on exit 0', async () => {
respondExec({ code: 0, stdout: 'C:\\Program Files\\code.exe' });
const result = await detectApp('vscode', 'win32');
expect(result).toBe(true);
expect(mockedExecFile).toHaveBeenCalledWith(
'where',
['Code.exe'],
{ windowsHide: true },
expect.any(Function),
);
});
it('returns false on non-zero exit', async () => {
respondExec({ code: 1, stderr: 'not found' });
const result = await detectApp('vscode', 'win32');
expect(result).toBe(false);
});
});
it('returns false when platform has no detect entry for the app', async () => {
const result = await detectApp('xcode', 'linux');
expect(result).toBe(false);
expect(mockedAccess).not.toHaveBeenCalled();
expect(mockedExecFile).not.toHaveBeenCalled();
});
it('returns true for ALWAYS_INSTALLED entries without probing', async () => {
const darwinFinder = await detectApp('finder', 'darwin');
const win32Explorer = await detectApp('explorer', 'win32');
const linuxFiles = await detectApp('files', 'linux');
expect(darwinFinder).toBe(true);
expect(win32Explorer).toBe(true);
expect(linuxFiles).toBe(true);
expect(mockedAccess).not.toHaveBeenCalled();
expect(mockedExecFile).not.toHaveBeenCalled();
});
});
describe('detectAllApps', () => {
it('returns one entry per AppId regardless of platform', async () => {
mockedAccess.mockRejectedValue(new Error('missing'));
mockedExecFile.mockImplementation((_file: string, _args: string[], _opts: unknown, cb: any) => {
const callback = typeof _opts === 'function' ? _opts : cb;
const err: NodeJS.ErrnoException = new Error('fail');
callback(err, '', '');
return undefined as any;
});
const apps = await detectAllApps('linux');
const registry = await import('../registry');
expect(apps.length).toBe(Object.keys(registry.APP_REGISTRY).length);
// every entry has the three required fields
for (const app of apps) {
expect(app).toEqual(
expect.objectContaining({
displayName: expect.any(String),
id: expect.any(String),
installed: expect.any(Boolean),
}),
);
}
});
it('marks unsupported-on-platform apps as not installed', async () => {
mockedAccess.mockRejectedValue(new Error('missing'));
mockedExecFile.mockImplementation((_file: string, _args: string[], _opts: unknown, cb: any) => {
const callback = typeof _opts === 'function' ? _opts : cb;
const err: NodeJS.ErrnoException = new Error('fail');
callback(err, '', '');
return undefined as any;
});
const apps = await detectAllApps('linux');
const xcode = apps.find((a) => a.id === 'xcode');
expect(xcode?.installed).toBe(false);
});
it('marks ALWAYS_INSTALLED platform file manager as installed without probes', async () => {
mockedAccess.mockRejectedValue(new Error('missing'));
mockedExecFile.mockImplementation((_file: string, _args: string[], _opts: unknown, cb: any) => {
const callback = typeof _opts === 'function' ? _opts : cb;
const err: NodeJS.ErrnoException = new Error('fail');
callback(err, '', '');
return undefined as any;
});
const apps = await detectAllApps('darwin');
const finder = apps.find((a) => a.id === 'finder');
expect(finder?.installed).toBe(true);
});
it('merges extracted icons onto installed apps only', async () => {
mockedAccess.mockRejectedValue(new Error('missing'));
mockedExecFile.mockImplementation((_file: string, _args: string[], _opts: unknown, cb: any) => {
const callback = typeof _opts === 'function' ? _opts : cb;
const err: NodeJS.ErrnoException = new Error('fail');
callback(err, '', '');
return undefined as any;
});
vi.mocked(extractAllIcons).mockResolvedValueOnce(
new Map([['finder', 'data:image/png;base64,FAKE']]),
);
const apps = await detectAllApps('darwin');
const finder = apps.find((a) => a.id === 'finder');
expect(finder?.icon).toBe('data:image/png;base64,FAKE');
// not-installed apps must not have an icon field
const xcode = apps.find((a) => a.id === 'xcode');
expect(xcode?.installed).toBe(false);
expect(xcode?.icon).toBeUndefined();
});
it('passes only installed AppIds to extractAllIcons', async () => {
mockedAccess.mockRejectedValue(new Error('missing'));
mockedExecFile.mockImplementation((_file: string, _args: string[], _opts: unknown, cb: any) => {
const callback = typeof _opts === 'function' ? _opts : cb;
const err: NodeJS.ErrnoException = new Error('fail');
callback(err, '', '');
return undefined as any;
});
vi.mocked(extractAllIcons).mockResolvedValueOnce(new Map());
await detectAllApps('darwin');
expect(extractAllIcons).toHaveBeenCalledTimes(1);
const [ids, platform] = vi.mocked(extractAllIcons).mock.calls[0];
expect(platform).toBe('darwin');
// only finder is ALWAYS_INSTALLED on darwin; all others fail probes
expect(ids).toEqual(['finder']);
});
});
@@ -0,0 +1,261 @@
import { execFile } from 'node:child_process';
import { access, mkdtemp, readFile, unlink } from 'node:fs/promises';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { __resetForTest, extractAllIcons, extractAppIcon } from '../iconExtractor';
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
}),
}));
vi.mock('node:fs/promises', () => ({
access: vi.fn(),
mkdtemp: vi.fn(),
readFile: vi.fn(),
unlink: vi.fn(),
}));
vi.mock('node:child_process', () => ({
execFile: vi.fn(),
}));
const mockedAccess = vi.mocked(access);
const mockedMkdtemp = vi.mocked(mkdtemp);
const mockedReadFile = vi.mocked(readFile);
const mockedUnlink = vi.mocked(unlink);
const mockedExecFile = vi.mocked(execFile) as unknown as ReturnType<typeof vi.fn>;
/**
* Drives the next execFile call. The promisified callback signature is
* `(error, stdout, stderr)`; non-error responses resolve with stdout.
*/
const respondExec = (
match: { args?: string[]; binary: string },
outcome: { error?: Error; stderr?: string; stdout?: string },
) => {
mockedExecFile.mockImplementationOnce(
(_file: string, _args: string[], _opts: unknown, cb: any) => {
const callback = typeof _opts === 'function' ? _opts : cb;
if (_file !== match.binary) {
callback(new Error(`unexpected binary: ${_file}`), '', '');
return undefined as any;
}
if (match.args && JSON.stringify(_args) !== JSON.stringify(match.args)) {
callback(new Error(`unexpected args: ${JSON.stringify(_args)}`), '', '');
return undefined as any;
}
if (outcome.error) {
callback(outcome.error, '', outcome.stderr ?? '');
} else {
callback(null, outcome.stdout ?? '', outcome.stderr ?? '');
}
return undefined as any;
},
);
};
// Shorthand: tools-available probe passes (which plutil + which sips both 0).
const respondToolsAvailable = () => {
// /usr/bin/which plutil
respondExec({ binary: '/usr/bin/which' }, { stdout: '/usr/bin/plutil\n' });
// /usr/bin/which sips
respondExec({ binary: '/usr/bin/which' }, { stdout: '/usr/bin/sips\n' });
};
beforeEach(() => {
vi.clearAllMocks();
mockedAccess.mockReset();
mockedMkdtemp.mockReset();
mockedReadFile.mockReset();
mockedUnlink.mockReset();
mockedExecFile.mockReset();
mockedUnlink.mockResolvedValue(undefined);
__resetForTest();
});
describe('extractAppIcon', () => {
it('returns a data URL when plutil + sips succeed on darwin', async () => {
respondToolsAvailable();
mockedAccess.mockResolvedValueOnce(undefined); // bundle exists
// plutil CFBundleIconFile lookup
respondExec({ binary: 'plutil' }, { stdout: 'Code.icns\n' });
mockedAccess.mockResolvedValueOnce(undefined); // .icns exists
mockedMkdtemp.mockResolvedValueOnce('/tmp/lobehub-openinapp-test');
// sips conversion
respondExec({ binary: 'sips' }, { stdout: '' });
mockedReadFile.mockResolvedValueOnce(Buffer.from([0x89, 0x50, 0x4e, 0x47])); // PNG header
const result = await extractAppIcon('vscode', 'darwin');
expect(result).toBe(
`data:image/png;base64,${Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString('base64')}`,
);
});
it('appends .icns suffix when CFBundleIconFile has no extension', async () => {
respondToolsAvailable();
mockedAccess.mockResolvedValueOnce(undefined); // bundle exists
respondExec({ binary: 'plutil' }, { stdout: 'Terminal\n' });
mockedAccess.mockImplementationOnce(async (p: any) => {
// .icns existence check — verify suffix appended
if (typeof p === 'string' && p.endsWith('Terminal.icns')) return undefined;
throw new Error('wrong path: ' + String(p));
});
mockedMkdtemp.mockResolvedValueOnce('/tmp/lobehub-openinapp-test');
respondExec({ binary: 'sips' }, { stdout: '' });
mockedReadFile.mockResolvedValueOnce(Buffer.from([0x89, 0x50]));
const result = await extractAppIcon('terminal', 'darwin');
expect(result).toBeDefined();
expect(result!.startsWith('data:image/png;base64,')).toBe(true);
});
it('falls back to the next path when the first bundle does not exist', async () => {
respondToolsAvailable();
// terminal has two candidate paths; first fails, second succeeds.
mockedAccess.mockRejectedValueOnce(new Error('missing'));
mockedAccess.mockResolvedValueOnce(undefined);
respondExec({ binary: 'plutil' }, { stdout: 'Terminal\n' });
mockedAccess.mockResolvedValueOnce(undefined);
mockedMkdtemp.mockResolvedValueOnce('/tmp/lobehub-openinapp-test');
respondExec({ binary: 'sips' }, { stdout: '' });
mockedReadFile.mockResolvedValueOnce(Buffer.from([0xff]));
const result = await extractAppIcon('terminal', 'darwin');
expect(result).toBeDefined();
});
it('returns undefined when no bundle path exists', async () => {
respondToolsAvailable();
mockedAccess.mockRejectedValue(new Error('missing'));
const result = await extractAppIcon('vscode', 'darwin');
expect(result).toBeUndefined();
});
it('returns undefined when plutil cannot read CFBundleIconFile', async () => {
respondToolsAvailable();
mockedAccess.mockResolvedValueOnce(undefined);
respondExec({ binary: 'plutil' }, { error: new Error('plutil: not found') });
const result = await extractAppIcon('vscode', 'darwin');
expect(result).toBeUndefined();
});
it('returns undefined when the resolved .icns is missing', async () => {
respondToolsAvailable();
mockedAccess.mockResolvedValueOnce(undefined); // bundle exists
respondExec({ binary: 'plutil' }, { stdout: 'Code.icns\n' });
mockedAccess.mockRejectedValueOnce(new Error('missing icns')); // .icns missing
const result = await extractAppIcon('vscode', 'darwin');
expect(result).toBeUndefined();
});
it('returns undefined when sips fails', async () => {
respondToolsAvailable();
mockedAccess.mockResolvedValueOnce(undefined);
respondExec({ binary: 'plutil' }, { stdout: 'Code.icns\n' });
mockedAccess.mockResolvedValueOnce(undefined);
mockedMkdtemp.mockResolvedValueOnce('/tmp/lobehub-openinapp-test');
respondExec({ binary: 'sips' }, { error: new Error('sips error') });
const result = await extractAppIcon('vscode', 'darwin');
expect(result).toBeUndefined();
});
it('returns undefined when the produced PNG is empty', async () => {
respondToolsAvailable();
mockedAccess.mockResolvedValueOnce(undefined);
respondExec({ binary: 'plutil' }, { stdout: 'Code.icns\n' });
mockedAccess.mockResolvedValueOnce(undefined);
mockedMkdtemp.mockResolvedValueOnce('/tmp/lobehub-openinapp-test');
respondExec({ binary: 'sips' }, { stdout: '' });
mockedReadFile.mockResolvedValueOnce(Buffer.alloc(0));
const result = await extractAppIcon('vscode', 'darwin');
expect(result).toBeUndefined();
});
it('returns undefined when registry has no darwin entry for the app', async () => {
respondToolsAvailable();
const result = await extractAppIcon('explorer', 'darwin');
expect(result).toBeUndefined();
expect(mockedAccess).not.toHaveBeenCalled();
});
it('returns undefined on win32 (extractor is macOS-only)', async () => {
const result = await extractAppIcon('vscode', 'win32');
expect(result).toBeUndefined();
expect(mockedExecFile).not.toHaveBeenCalled();
});
it('returns undefined on linux (extractor is macOS-only)', async () => {
const result = await extractAppIcon('vscode', 'linux');
expect(result).toBeUndefined();
expect(mockedExecFile).not.toHaveBeenCalled();
});
});
describe('extractAllIcons', () => {
it('returns a map of only AppIds with successfully extracted icons', async () => {
respondToolsAvailable();
// vscode succeeds
mockedAccess.mockResolvedValueOnce(undefined); // bundle
respondExec({ binary: 'plutil' }, { stdout: 'Code.icns\n' });
mockedAccess.mockResolvedValueOnce(undefined); // .icns
mockedMkdtemp.mockResolvedValueOnce('/tmp/lobehub-openinapp-test');
respondExec({ binary: 'sips' }, { stdout: '' });
mockedReadFile.mockResolvedValueOnce(Buffer.from('vscode'));
// cursor fails at bundle access (try all paths fail)
mockedAccess.mockRejectedValue(new Error('missing'));
// xcode succeeds — reset access for it
// (subsequent calls to mockedAccess will keep returning rejection)
// So this test exercises: success, fail-no-bundle.
const map = await extractAllIcons(['vscode', 'cursor'], 'darwin');
expect(map.has('vscode')).toBe(true);
expect(map.has('cursor')).toBe(false);
});
it('returns empty map when input list is empty', async () => {
const map = await extractAllIcons([], 'darwin');
expect(map.size).toBe(0);
});
it('does not throw when extraction errors', async () => {
respondToolsAvailable();
mockedAccess.mockResolvedValueOnce(undefined);
respondExec({ binary: 'plutil' }, { error: new Error('boom') });
const map = await extractAllIcons(['vscode'], 'darwin');
expect(map.size).toBe(0);
});
it('skips all when tools are unavailable', async () => {
// /usr/bin/which plutil fails
respondExec({ binary: '/usr/bin/which' }, { error: new Error('not found') });
const map = await extractAllIcons(['vscode', 'terminal'], 'darwin');
expect(map.size).toBe(0);
});
});
@@ -0,0 +1,247 @@
import { execFile } from 'node:child_process';
import { access } from 'node:fs/promises';
import { shell } from 'electron';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { launchApp } from '../launchers';
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
}),
}));
vi.mock('node:fs/promises', () => ({
access: vi.fn(),
}));
vi.mock('node:child_process', () => ({
execFile: vi.fn(),
}));
vi.mock('electron', () => ({
shell: {
openPath: vi.fn(),
},
}));
const mockedAccess = vi.mocked(access);
const mockedExecFile = vi.mocked(execFile) as unknown as ReturnType<typeof vi.fn>;
const mockedShell = vi.mocked(shell);
type LastCall = { file: string; args: string[] };
const captureExec = (): LastCall => {
expect(mockedExecFile).toHaveBeenCalled();
const [file, args] = mockedExecFile.mock.calls[0];
return { args: args as string[], file: file as string };
};
interface ExecOutcome {
code: number;
stderr?: string;
stdout?: string;
}
const respondExec = (outcome: ExecOutcome) => {
mockedExecFile.mockImplementationOnce(
(_file: string, _args: string[], _opts: unknown, cb: any) => {
const callback = typeof _opts === 'function' ? _opts : cb;
if (outcome.code === 0) {
callback(null, outcome.stdout ?? '', outcome.stderr ?? '');
} else {
const err: NodeJS.ErrnoException & { stderr?: string } = new Error('exec failed');
err.stderr = outcome.stderr ?? '';
(err as any).code = outcome.code;
callback(err, '', outcome.stderr ?? '');
}
return undefined as any;
},
);
};
beforeEach(() => {
vi.clearAllMocks();
mockedAccess.mockResolvedValue(undefined);
});
describe('launchApp - path validation', () => {
it('rejects relative paths', async () => {
const result = await launchApp('vscode', 'relative/path', 'darwin');
expect(result.success).toBe(false);
expect(result.error).toBe('Path must be absolute');
expect(mockedExecFile).not.toHaveBeenCalled();
});
it('rejects paths that do not exist', async () => {
mockedAccess.mockRejectedValueOnce(new Error('ENOENT'));
const result = await launchApp('vscode', '/missing', 'darwin');
expect(result.success).toBe(false);
expect(result.error).toBe('Path not found: /missing');
expect(mockedExecFile).not.toHaveBeenCalled();
});
it('returns error when app is not available on platform', async () => {
const result = await launchApp('xcode', '/some/path', 'linux');
expect(result.success).toBe(false);
expect(result.error).toContain('Xcode');
expect(result.error).toContain('not available on this platform');
});
});
describe('launchApp - macOpenA strategy', () => {
it('spawns open -a <appName> <path>', async () => {
respondExec({ code: 0 });
const result = await launchApp('vscode', '/work/dir', 'darwin');
expect(result.success).toBe(true);
const call = captureExec();
expect(call.file).toBe('open');
expect(call.args).toEqual(['-a', 'Visual Studio Code', '/work/dir']);
});
it('returns stderr substring on failure', async () => {
respondExec({ code: 1, stderr: ' cannot open Cursor.app ' });
const result = await launchApp('cursor', '/work/dir', 'darwin');
expect(result.success).toBe(false);
expect(result.error).toBe('cannot open Cursor.app');
});
});
describe('launchApp - macOpen strategy', () => {
it('spawns open <path>', async () => {
respondExec({ code: 0 });
const result = await launchApp('finder', '/work/dir', 'darwin');
expect(result.success).toBe(true);
const call = captureExec();
expect(call.file).toBe('open');
expect(call.args).toEqual(['/work/dir']);
});
});
describe('launchApp - exec strategy', () => {
it('spawns <binary> <path>', async () => {
respondExec({ code: 0 });
const result = await launchApp('vscode', '/work/dir', 'linux');
expect(result.success).toBe(true);
const call = captureExec();
expect(call.file).toBe('code');
expect(call.args).toEqual(['/work/dir']);
});
it('appends registry-provided args before path', async () => {
const registry = await import('../registry');
const original = registry.APP_REGISTRY.vscode.launch.linux;
registry.APP_REGISTRY.vscode.launch.linux = {
args: ['--new-window'],
binary: 'code',
type: 'exec',
};
respondExec({ code: 0 });
const result = await launchApp('vscode', '/work/dir', 'linux');
expect(result.success).toBe(true);
const call = captureExec();
expect(call.args).toEqual(['--new-window', '/work/dir']);
registry.APP_REGISTRY.vscode.launch.linux = original;
});
it('rejects suspicious binary names', async () => {
const registry = await import('../registry');
const original = registry.APP_REGISTRY.vscode.launch.linux;
registry.APP_REGISTRY.vscode.launch.linux = {
binary: 'rm; ls',
type: 'exec',
};
const result = await launchApp('vscode', '/work/dir', 'linux');
expect(result.success).toBe(false);
expect(result.error).toBe('Invalid binary name');
expect(mockedExecFile).not.toHaveBeenCalled();
registry.APP_REGISTRY.vscode.launch.linux = original;
});
it('rejects binary names with spaces', async () => {
const registry = await import('../registry');
const original = registry.APP_REGISTRY.vscode.launch.linux;
registry.APP_REGISTRY.vscode.launch.linux = {
binary: 'foo bar',
type: 'exec',
};
const result = await launchApp('vscode', '/work/dir', 'linux');
expect(result.success).toBe(false);
expect(result.error).toBe('Invalid binary name');
registry.APP_REGISTRY.vscode.launch.linux = original;
});
it('accepts absolute-path binary names', async () => {
const registry = await import('../registry');
const original = registry.APP_REGISTRY.vscode.launch.linux;
registry.APP_REGISTRY.vscode.launch.linux = {
binary: '/usr/local/bin/code',
type: 'exec',
};
respondExec({ code: 0 });
const result = await launchApp('vscode', '/work/dir', 'linux');
expect(result.success).toBe(true);
const call = captureExec();
expect(call.file).toBe('/usr/local/bin/code');
registry.APP_REGISTRY.vscode.launch.linux = original;
});
it('returns stderr substring on non-zero exit', async () => {
respondExec({ code: 1, stderr: 'command not found' });
const result = await launchApp('vscode', '/work/dir', 'linux');
expect(result.success).toBe(false);
expect(result.error).toBe('command not found');
});
});
describe('launchApp - shellOpenPath strategy', () => {
it('delegates to shell.openPath', async () => {
mockedShell.openPath.mockResolvedValueOnce('');
const result = await launchApp('explorer', '/abs/work-dir', 'win32');
expect(result.success).toBe(true);
expect(mockedShell.openPath).toHaveBeenCalledWith('/abs/work-dir');
});
it('returns error string from shell.openPath as error', async () => {
mockedShell.openPath.mockResolvedValueOnce('cannot open');
const result = await launchApp('files', '/some/dir', 'linux');
expect(result.success).toBe(false);
expect(result.error).toBe('cannot open');
});
});
@@ -0,0 +1,18 @@
import type { DetectedApp } from '@lobechat/electron-client-ipc';
import { detectAllApps } from './detectors';
let cachedPromise: Promise<DetectedApp[]> | null = null;
export const getCachedDetection = (
platform: NodeJS.Platform = process.platform,
): Promise<DetectedApp[]> => {
if (!cachedPromise) {
cachedPromise = detectAllApps(platform);
}
return cachedPromise;
};
export const clearDetectionCache = (): void => {
cachedPromise = null;
};
@@ -0,0 +1,109 @@
import { execFile } from 'node:child_process';
import { access } from 'node:fs/promises';
import { promisify } from 'node:util';
import type { DetectedApp, OpenInAppId } from '@lobechat/electron-client-ipc';
import { createLogger } from '@/utils/logger';
import { extractAllIcons } from './iconExtractor';
import type { DetectStrategy } from './registry';
import { ALWAYS_INSTALLED, APP_REGISTRY } from './registry';
// Icon extraction shells out to plutil + sips on macOS (see iconExtractor.ts)
// so Electron itself cannot crash on `app.getFileIcon` regressions. Renderer
// falls back to lucide if extraction returns undefined.
const logger = createLogger('modules:openInApp:detectors');
const execFileAsync = promisify(execFile);
const SAFE_BINARY_REGEX = /^[\w.-]+$/;
const probeAppBundle = async (paths: string[]): Promise<boolean> => {
for (const path of paths) {
try {
await access(path);
return true;
} catch {
// try next
}
}
return false;
};
const probeCommandV = async (binary: string): Promise<boolean> => {
if (!SAFE_BINARY_REGEX.test(binary)) {
logger.debug(`rejecting unsafe binary name for commandV: ${binary}`);
return false;
}
try {
await execFileAsync('/bin/sh', ['-c', `command -v "${binary}"`]);
return true;
} catch (error) {
logger.debug(`commandV probe failed for ${binary}: ${(error as Error).message}`);
return false;
}
};
const probeRegistryAppPaths = async (exeName: string): Promise<boolean> => {
try {
await execFileAsync('where', [exeName], { windowsHide: true });
return true;
} catch (error) {
logger.debug(`where probe failed for ${exeName}: ${(error as Error).message}`);
return false;
}
};
const runDetectStrategy = (strategy: DetectStrategy): Promise<boolean> => {
switch (strategy.type) {
case 'appBundle': {
return probeAppBundle(strategy.paths);
}
case 'commandV': {
return probeCommandV(strategy.binary);
}
case 'registryAppPaths': {
return probeRegistryAppPaths(strategy.exeName);
}
}
};
export const detectApp = async (id: OpenInAppId, platform: NodeJS.Platform): Promise<boolean> => {
if (ALWAYS_INSTALLED[platform] === id) {
return true;
}
const descriptor = APP_REGISTRY[id];
const strategy = descriptor?.detect[platform];
if (!strategy) {
return false;
}
return runDetectStrategy(strategy);
};
export const detectAllApps = async (
platform: NodeJS.Platform = process.platform,
): Promise<DetectedApp[]> => {
const entries = Object.entries(APP_REGISTRY) as Array<
[OpenInAppId, (typeof APP_REGISTRY)[OpenInAppId]]
>;
const installedFlags = await Promise.all(entries.map(([id]) => detectApp(id, platform)));
// Extract icons for installed apps only. Extraction shells out to plutil +
// sips (see iconExtractor.ts) so it cannot crash the renderer; failures
// resolve to undefined and the renderer falls back to lucide icons.
const installedIds = entries.filter((_entry, i) => installedFlags[i]).map(([id]) => id);
const icons = await extractAllIcons(installedIds, platform);
return entries.map(([id, descriptor], i) => {
const installed = installedFlags[i];
const icon = installed ? icons.get(id) : undefined;
return {
displayName: descriptor.displayName,
id,
installed,
...(icon ? { icon } : {}),
} satisfies DetectedApp;
});
};

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