Compare commits

..

135 Commits

Author SHA1 Message Date
Arvin Xu 5fcf2daa65 🐛 fix: require model context for "does not exist" matches
Previously, any message containing "does not exist" was classified as ModelNotFound, which could misclassify API key, deployment, or unrelated errors. Now require the word "model" to appear before "does not exist" within the same sentence. Periods inside version numbers (e.g. "doubao-seed-2.0-pro") are still allowed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-24 01:10:58 +08:00
Arvin Xu d4efbe1ed1 feat: add isModelNotFoundError pattern matching for model-runtime error classification
Add message-based pattern matching for ModelNotFound errors, similar to existing patterns for context window, quota, and account errors. Previously ModelNotFound was only detected via error code 'model_not_found', missing cases where providers return different codes but include recognizable messages like "does not exist" (Volcengine/doubao).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-24 01:10:58 +08:00
Arvin Xu b3d2d2fdbd feat(review-panel): group review changes by submodule (#15148)
* 🐛 fix(claude-code): show task subject in TaskUpdate inspector & header

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Behavior unchanged — the resulting path is identical.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Mirror the @lobechat/agent-tracing viewer style:

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

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

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

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

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

This commit introduces a dedicated `tracing` option:

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

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

* Update inputCompletion.ts

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

*  test: stabilize bun vitest environment

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

* 🐛 fix: normalize platform route chunk ids

* ️ perf: refine route chunk preloading

* 🔧 chore: keep desktop renderer preload unchanged

* ️ perf: skip renderer chunks in route warmup

* ️ perf: preload agent route dynamic chunks

* ️ perf: align route preload deployment urls

* ️ perf: coalesce stable vendor chunks

* ️ perf: group shared data runtime chunks

* ️ perf: group model runtime chunks

* ️ perf: trim initial route preloads

* ️ perf: limit idle route micro preloads

* ️ perf: strip tiny html modulepreloads

* ️ perf: prune redundant route chunk imports

* ️ perf: enable rolldown devtools

* ️ perf: gate vite devtools output

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

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

* ️ perf: support cloud route chunk preload

---------

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

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

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

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

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

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

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

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

*  feat(conversation): emit onAssistantTurnSettled hook from provider

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: `hasMoreTopics` selector

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

---------

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

* ♻️ chore trim unrelated bm-36 diff

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

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

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

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

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

---------

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* 🔖 chore(cli): bump version to 0.0.20

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

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

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

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

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

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

---------

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

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

Two-layer fix:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* 🔨 chore(cli): bump version to 0.0.18

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

Two-part fix:

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

Closes LOBE-8660
Part of LOBE-8678

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

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

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

* test: add coverage for validateToolLimits / assertToolLimits

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

Fixes LOBE-8927
Fixes LOBE-8928

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat: add the platform agents get profiles

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

Coerce the preview to a string before slicing.

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

Includes a small ApprovalMode dropdown placement + trigger styling tweak.

* 🔨 chore: untrack docs/superpowers from git

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

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

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

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

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

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

This reverts commit d96912dae7.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

* 🐛 fix: allow legacy system agent settings

*  test: fix disabled thinking payload type

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

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

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

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

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

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

---------

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

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

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

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

* 🐛 fix: scope generate object thinking params

* 💡 docs: clarify generate object thinking scope

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

* ♻️ refactor: remove static LobeHub model cards

* ♻️ refactor: share OpenAI image parameters

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

* 🐛 fix: repair model bank CI follow-ups

* 🐛 fix: avoid repeated model bank fallback loads

* 🐛 fix: resolve business model config import in browser

* 🐛 fix: align Nano Banana 2 resolution default

* ♻️ refactor: move model loader slot under client

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

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

* ♻️ refactor: consolidate model bank provider utilities

* 🐛 fix: preserve Nano Banana 2 raw resolution

* 🐛 fix: avoid generated locale sync for raw resolution

* 🌐 style: add Nano Banana 2 resolution locales

* 🌐 style: add online LobeHub model locales

* 🐛 fix: guard optional model provider loaders

* 🐛 fix: prevent sitemap build from hanging

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

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

* chore: unify client and runtime adapter

* feat: support system bot messenger and cli

* chore: remove unnecessary listOutboundChannels

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

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

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

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

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

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

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

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

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

* 🐛 fix: scope route meta to tab url

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs: add ph #1 badge

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

* chore: remove telegram CN screenshots

* feat: add feedback commands

* fix: bot feedback commands

* chore: optimize messenger intergration

* chore: update onboarding style

* feat: support wechat adapter attachments

* feat: support ai attachments

* chore: update i18n files

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

---------

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

* fix: remove actions-cool/issues-helper

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

* style: update readme

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

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

Fixes LOBE-9149

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 14:30:12 +08:00
Rdmclin2 6f42386345 🐛 fix: sidebar new agent (#14920)
fix: sidebar new agent
2026-05-18 11:54:10 +07:00
lobehubbot 1792752231 Merge remote-tracking branch 'origin/main' into canary 2026-05-18 04:42:33 +00:00
AmAzing- d6b5e81a57 🐛 fix(agent-signal): persist memory receipt routing metadata (#14912) 2026-05-18 11:41:33 +08:00
1392 changed files with 51714 additions and 16907 deletions
+13 -11
View File
@@ -25,17 +25,18 @@ The two reference tools to read end-to-end:
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 可以存在,但应默认收起或放到调试区;主界面先回答用户最关心的问题:工具做了什么,结果值不值得信任,下一步能做什么
4. **文案要随状态切换时态。** 同一个动作在 loading 与 completed 两个阶段必须用不同的措辞:执行中用现在进行时(“正在创建任务 / Creating task / 正在搜索”),执行完成后切到完成态(“已创建任务 / Task created / 已找到 N 条”)。Inspector chip 会一直留在聊天记录里 —— 如果一直挂着 “正在 xxx”,几小时后回看历史时会读起来像还在跑。约定的 i18n 形式是 `<api>.loading` / `<api>.completed` 一对键(见 `lobe-agent.apiName.callSubAgent.{loading,completed}``lobe-claude-code.task.{create,list,update,get}.{loading,completed}`),渲染时按 `isArgumentsStreaming || isLoading` 决定取哪一个。只读 / 查询类(“查看任务” 这种本来就是名词性的)可以共用一个键
5. **只有结构化结果才需要 Render。** 如果工具结果只是自然语言总结,通常不需要 Render;如果结果包含列表、媒体、文件、表格、代码、diff、地图、时间线、权限请求等结构,就应该提供 Render
6. **Render 要帮助用户检查结果,而不是复述参数。** Render 的主体应该围绕工具产物组织:可预览、可比较、可筛选、可定位。参数只作为上下文辅助出现,不要把 Render 做成一块更大的 args dump
7. **参数和结果要一起参与渲染。** 好的 Tool UI 通常同时用 `args` 解释意图,用 `pluginState` 展示真实执行结果;但 `pluginState` 只放结果域数据,不要反向塞入可以从 `args` 推导出的内容
8. **慢操作要有 Placeholder。** 如果工具通常需要等待网络、文件系统、模型或外部进程,Placeholder 应该先占住最终 Render 的版式,让用户知道即将看到什么,而不是只显示一个泛化 loading
9. **Streaming 只用于连续产物。** 搜索列表、日志、长文本、文件分析、分阶段计划适合 Streaming;一次性小结果不需要强行做 Streaming。Streaming UI 要能渐进追加,并且完成后自然过渡到最终 Render
10. **有风险的动作必须 Intervention。** 写文件、删除、发送、安装、执行命令、外部可见操作、权限敏感操作,都应该在执行前给出可理解的确认界面;确认文案要说明影响范围,而不是只问 “是否继续”。
11. **错误、空态和截断都是正式状态。** Render 不能在失败、无结果、超长结果时退化成空白。错误要说明发生在哪一步;空态要告诉用户没有产物;超长内容要明确 “展示前 N 项 / 还有 N 项”
12. **信息密度要克制。** 默认展示最有判断价值的部分:标题、来源、状态、摘要、少量关键字段。大对象、长列表、原文、调试数据放进可展开区域或 Portal,避免把聊天流撑成后台管理页
13. **视觉上融入聊天流** Tool UI 应该使用 `@lobehub/ui` / base-ui、`Flexbox``createStaticStyles``cssVar.*`,遵循现有间距、圆角、颜色、字号;不要为单个工具发明一套独立视觉语言
14. **Devtools fixture 是验收入口。** 新增或修改 Tool UI 时,应在 `/devtools` 里准备覆盖典型态、loading/streaming、空态、错误态、长内容态的 fixture;一个 API 如果在真实聊天里会出现,就不应该在 devtools 中缺席
15. **先做用户会看的 UI,再做调试 UI。** Raw JSON、trace、schema、内部 id 可以存在,但应默认收起或放到调试区;主界面先回答用户最关心的问题:工具做了什么,结果值不值得信任,下一步能做什么。
---
@@ -200,6 +201,7 @@ export default SearchInspector;
- Read both `args?.X` and `partialArgs?.X` together — `args` is final, `partialArgs` is in-stream.
- Use chips/tags for distinct facets (identifier, name, parent, status, count). Each chip should clip with `text-overflow: ellipsis` and have a `max-width` so long values don't blow out the chat bubble.
- Append `pluginState`-derived suffixes only **after** loading finishes — count or "(no results)" should not appear while still searching.
- **Switch copy by phase.** If the verb implies an ongoing action ("Creating", "Searching", "Listing"), define `<api>.loading` and `<api>.completed` keys and select via `isArgumentsStreaming || isLoading ? loadingKey : completedKey`. Inspector chips persist in chat history — leaving "Creating task" frozen on a finished call reads as if the tool is still running. Read-only labels that are already noun-form ("View task") can keep a single key. See `CallSubAgentInspector` for the canonical two-key pattern.
### Inspector registry — `client/Inspector/index.ts`
+1
View File
@@ -48,6 +48,7 @@ user-invocable: false
- Replace magic numbers/strings with well-named constants
- Defer formatting to tooling
- Prefer **named exports** over `export default` — keeps refactor renames and IDE auto-import in sync, and avoids the `default` re-naming drift you get with `import Foo from './foo'`. Reserve `export default` for files where the framework requires it (Next.js page/route/layout, React.lazy targets, config files like `vitest.config.ts`)
- Before adding local helpers for common guards/parsing/normalization (record checks, string extraction, empty-string handling, timing helpers, JSON-safe utilities, etc.), search `packages/utils` first. If the helper already exists or clearly belongs there, import it from `@lobechat/utils` (or the relevant `@lobechat/utils/*` subpath) instead of duplicating tiny helpers across feature files.
## UI and Theming
+148 -188
View File
@@ -1,6 +1,10 @@
# Issue Triage Guide
This guide is used for batch triaging GitHub issues - analyzing issues and applying appropriate labels.
This guide is used for triaging GitHub issues analyzing issues and applying only the most essential business-domain labels.
## Core Principle
**Each issue should have 1-3 labels that describe its core business domain.** Do NOT apply redundant labels that can be inferred from other labels. Less is more.
## Workflow
@@ -20,23 +24,76 @@ For each issue number, run:
gh issue view [ISSUE_NUMBER] --json number,title,body,labels,comments
```
### Step 3: Analyze and Select Labels
### Step 3: Select Labels (1-3 per issue)
Extract information from the issue template and content:
Only apply labels from these THREE categories:
#### Template Fields Mapping
#### Category 1: Technology Carrier
- 📦 Platform field → `platform:web/desktop/mobile`
- 💻 Operating System → `os:windows/macos/linux/ios`
- 🌐 Browser → `device:pc/mobile`
- 📦 Deployment mode → `deployment:server/client/pglite`
- Platform (hosting) → `hosting:cloud/self-host/vercel/zeabur/railway`
The runtime environment or technology wrapper where the issue occurs:
#### Provider Detection
| Label | When to apply |
|-------|--------------|
| `electron` | Desktop/Electron-specific issues. This REPLACES `platform:desktop`, `os:*`, `deployment:*`, `hosting:*` — do NOT add those. |
| `pwa` | PWA/mobile-app-specific issues |
| `docker` | Docker-specific deployment issues |
**IMPORTANT**: Always check issue title and body for provider mentions!
**Rule**: If `electron` is applied, do NOT add `platform:desktop`, `os:*`, `deployment:*`, or `hosting:*`. The `electron` label already implies all of these.
**Official Providers** (check for these keywords in title/body):
#### Category 2: Feature / Component
The functional area affected. Select the 1-2 MOST relevant:
Core Features:
- `feature:agent` - Agent/Assistant functionality
- `feature:topic` - Topic/Conversation management
- `feature:marketplace` - Agent/plugin marketplace
- `feature:settings` - Settings and configuration
Content & Knowledge:
- `feature:editor` - Lobe Editor / rich text / markdown rendering
- `feature:markdown` - Markdown rendering (if separate from editor)
- `feature:files` - File upload/management
- `feature:knowledge-base` - Knowledge base and RAG
- `feature:export` - Export functionality
Model Capabilities:
- `feature:tool` - Tool calling and function execution
- `feature:streaming` - Streaming responses
- `feature:vision` - Vision/multimodal capabilities
- `feature:image` - AI image generation
- `feature:tts` - Text-to-speech
Technical:
- `feature:api` - Backend API
- `feature:auth` - Authentication/authorization
- `feature:sync` - Cloud sync functionality
- `feature:search` - Search functionality
- `feature:mcp` - MCP integration
- `feature:thread` - Thread/Subtopic functionality
Collaboration:
- `feature:group-chat` - Group chat functionality
- `feature:memory` - Memory feature
- `feature:team-workspace` - Team workspace
- `feature:im-integration` - IM and bot integration
Other:
- `feature:schedule-task` - Scheduled task functionality
**Rule**: Pick only the 1-2 most specific feature labels. Don't stack multiple features unless the issue genuinely spans multiple areas.
#### Category 3: Model Provider
Only when the issue is SPECIFICALLY about a provider's behavior:
**Official Providers** (check title and body for these keywords):
- `openai`, `gpt``provider:openai`
- `gemini``provider:gemini`
@@ -57,197 +114,100 @@ Extract information from the issue template and content:
**Third-party Aggregation Providers**:
- `aihubmix`, `AIHubMix`, `AIHUBMIX``provider:aihubmix`
- Check environment variables like `AIHUBMIX_*` in issue body
- `zenmux``provider:zenmux`
**Multiple Providers**: If issue mentions multiple providers, add ALL applicable provider labels.
**Rule**: Only add a provider label if the issue is specifically about that provider's behavior (e.g., "Gemini returns error X"). Do NOT add provider labels just because the issue template mentions a provider.
### Label Categories
#### a) Issue Type (select ONE if applicable)
- `💄 Design` - UI/UX design issues
- `📝 Documentation` - Documentation improvements
- `⚡️ Performance` - Performance optimization
#### b) Priority (select ONE if applicable)
- `priority:high` - Critical issues, data loss, security, maintainer mentions "urgent"/"serious"/"critical"
- `priority:medium` - Important issues affecting multiple users, significant functionality impact
- `priority:low` - Nice to have, minor issues, edge cases
**Priority Guidelines**:
- Set `priority:high` for: data loss, authentication failures, deployment blockers, critical bugs
- Set `priority:medium` for: feature bugs affecting multiple users, workflow issues
- Set `priority:low` for: cosmetic issues, feature requests, configuration questions
#### c) Platform (select ALL applicable)
- `platform:web`
- `platform:desktop`
- `platform:mobile`
#### d) Device (for platform:web, select ONE)
- `device:pc`
- `device:mobile`
#### e) Operating System (select ALL applicable)
- `os:windows`
- `os:macos`
- `os:linux`
- `os:ios`
- `os:android`
#### f) Hosting Platform (select ONE)
- `hosting:cloud` - Official LobeHub Cloud
- `hosting:self-host` - Self-hosted deployment
- `hosting:vercel` - Vercel deployment
- `hosting:zeabur` - Zeabur deployment
- `hosting:railway` - Railway deployment
#### g) Deployment Mode (select ONE if mentioned)
- `deployment:server` - Server-side database mode
- `deployment:client` - Client-side database mode
- `deployment:pglite` - PGLite mode
**Additional deployment tags**:
- `docker` - If using Docker deployment
- `electron` - If desktop/Electron specific
#### h) Model Provider (select ALL applicable)
See "Provider Detection" section above for complete list.
**IMPORTANT**: Always scan issue title and body for provider keywords!
#### i) Feature/Component (select ALL applicable)
Core Features:
- `feature:settings` - Settings and configuration
- `feature:agent` - Agent/Assistant functionality
- `feature:topic` - Topic/Conversation management
- `feature:marketplace` - Agent marketplace
File & Knowledge:
- `feature:files` - File upload/management
- `feature:knowledge-base` - Knowledge base and RAG
- `feature:export` - Export functionality
Model Capabilities:
- `feature:streaming` - Streaming responses
- `feature:tool` - Tool calling
- `feature:vision` - Vision/multimodal capabilities
- `feature:image` - AI image generation
- `feature:dalle` - DALL-E specific
- `feature:tts` - Text-to-speech
Technical:
- `feature:api` - Backend API
- `feature:auth` - Authentication/authorization
- `feature:sync` - Cloud sync functionality
- `feature:search` - Search functionality
- `feature:mcp` - MCP integration
- `feature:editor` - Lobe Editor
- `feature:markdown` - Markdown rendering
- `feature:thread` - Thread/Subtopic functionality
Collaboration:
- `feature:group-chat` - Group chat functionality
- `feature:memory` - Memory feature
- `feature:team-workspace` - Team workspace
#### j) Workflow/Status
#### Special Labels (use sparingly)
- `i18n` - Internationalization / translation issues
- `Duplicate` - Only if duplicate of an OPEN issue (mention issue number)
- `needs-reproduction` - Cannot reproduce, needs more information
- `good-first-issue` - Good for first-time contributors
- `🤔 Need Reproduce` - Needs reproduction steps
- `good-first-issue` - Good for first-time contributors
### Step 4: Apply Labels
Add labels (comma-separated, no spaces after commas):
```bash
gh issue edit [ISSUE_NUMBER] --add-label "label1,label2,label3"
```
Remove "unconfirm" label if adding other labels:
```bash
gh issue edit [ISSUE_NUMBER] --add-label "label1,label2"
gh issue edit [ISSUE_NUMBER] --remove-label "unconfirm"
```
**Important**: Combine both commands when possible for efficiency.
### Step 5: Log Summary
For each issue, provide reasoning (2-4 sentences):
For each issue, provide a brief reasoning (1-2 sentences) explaining why each label was chosen.
- Labels applied and why
- Key factors from issue template/comments
- Provider detection reasoning (if applicable)
## What NOT to Label
These categories are INTENTIONALLY OMITTED — do NOT apply them:
| Do NOT apply | Reason |
|-------------|--------|
| `platform:web`, `platform:desktop`, `platform:mobile` | Inferred from `electron`/`pwa` or issue context |
| `os:windows`, `os:macos`, `os:linux`, `os:ios`, `os:android` | Low triage value; inferred from `electron` |
| `device:pc`, `device:mobile` | Redundant with platform |
| `hosting:cloud`, `hosting:self-host`, `hosting:vercel`, etc. | Low triage value unless deployment-specific |
| `deployment:server`, `deployment:client`, `deployment:pglite` | Low triage value; inferred from `electron` |
| `priority:high`, `priority:medium`, `priority:low` | Maintainers judge priority themselves |
| `🐛 Bug`, `💄 Design`, `📝 Documentation`, `⚡️ Performance` | Issue type is already indicated by GitHub issue template |
| `Inactive` | Handled separately; do NOT add during triage |
## Examples
### Example 1: Electron desktop bug
**Issue**: "Connection failure when executing tasks on macOS desktop app"
**Analysis**: Desktop Electron app issue with task scheduling.
**Labels**: `electron,feature:schedule-task`
**Why**: `electron` covers the desktop platform. `feature:schedule-task` identifies the affected feature. No need for `platform:desktop`, `os:macos`, `hosting:cloud`, `priority:*`, or `Bug`.
### Example 2: Provider-specific issue
**Issue**: "Gemini tool calling returns empty response on desktop"
**Analysis**: Desktop app issue, but the core problem is Gemini provider behavior with tool calling.
**Labels**: `electron,provider:gemini`
**Why**: `electron` for the desktop context. `provider:gemini` because the issue is about Gemini's behavior. The tool calling aspect is secondary — the provider is the key domain.
### Example 3: Feature-specific issue
**Issue**: "Underscore auto-escaped in markdown editor"
**Analysis**: Markdown rendering bug in the editor component.
**Labels**: `feature:markdown`
**Why**: Single label is sufficient — the issue is purely about markdown rendering. No need for platform, OS, or priority labels.
### Example 4: Web-only feature request
**Issue**: "Add search functionality to plugin marketplace"
**Analysis**: Feature request for marketplace search. Web platform, no specific provider.
**Labels**: `feature:marketplace,feature:search`
**Why**: Two feature labels capture the core domain. No platform label needed — it's a web app by default.
### Example 5: Ollama self-hosted issue
**Issue**: "Ollama model not loading on self-hosted Docker deployment"
**Analysis**: Provider-specific issue with Ollama on Docker.
**Labels**: `docker,provider:ollama`
**Why**: `docker` for the deployment context, `provider:ollama` for the model provider. No need for `hosting:self-host` or `platform:*`.
## Important Rules
1. **Read Carefully**: Read issue template fields AND issue body/title for complete context
2. **Provider Detection**: ALWAYS check title and body for provider keywords (including aihubmix, etc.)
3. **Multiple Categories**: Use ALL applicable labels from different categories
4. **Label Prefixes**: Always use proper prefixes (`feature:`, `provider:`, `os:`, `platform:`, etc.)
5. **Maintainer Comments**: Check maintainer comments for priority/status hints
6. **No Comments**: Only apply labels, DO NOT post comments to issues
7. **Batch Efficiency**: Process issues in parallel when possible
## Common Patterns
### Provider in Environment Variables
If issue body contains `AIHUBMIX_*`, add `provider:aihubmix`
### Multiple Provider Issues
If comparing providers (e.g., "works with OpenAI but not Gemini"), add both provider labels
### Desktop Issues
Desktop issues often need: `platform:desktop`, `electron`, specific `os:*`, and `deployment:client` or `deployment:server`
### Knowledge Base Issues
Usually need: `feature:knowledge-base`, often with `feature:files`, may need `provider:*` for embedding models
### Tool Calling Issues
Usually need: `feature:tool`, specific `provider:*`, may need `feature:mcp` if MCP-related
### Streaming Issues
Usually need: `feature:streaming`, specific `provider:*`, check for timeout/performance issues
## Example Triage
**Issue #8850**: "aihubmix 的优惠 app 没有生效"
**Analysis**:
- Title contains "aihubmix" → `provider:aihubmix`
- Template shows: Windows, Chrome, Docker, Client mode
- About API discount codes not working
**Labels Applied**:
```bash
gh issue edit 8850 --add-label "provider:aihubmix,platform:web,os:windows,deployment:client,hosting:self-host,docker"
gh issue edit 8850 --remove-label "unconfirm"
```
**Reasoning**: AIHubMix provider discount feature not working. Client mode deployment on Windows with Docker. Provider detection from title keyword "aihubmix".
1. **1-3 labels per issue** — Never exceed 3 labels. If you find yourself adding more, you're being too granular.
2. **`electron` replaces all platform/OS/deployment labels** — Never combine `electron` with `platform:desktop`, `os:*`, `deployment:*`, or `hosting:*`.
3. **Provider only when relevant** — Only add `provider:*` if the issue is specifically about that provider's behavior.
4. **No priority, no type** — Do NOT add `priority:*`, `🐛 Bug`, `💄 Design`, etc. Maintainers handle these.
5. **No comments** — Only apply labels. Do NOT post comments to issues.
6. **Remove `unconfirm`** — Always remove the `unconfirm` label when applying triage labels.
+3
View File
@@ -77,6 +77,9 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# use a proxy to connect to the Anthropic API
# ANTHROPIC_PROXY_URL=https://api.anthropic.com
# Anthropic SDK client timeout in milliseconds
# ANTHROPIC_CLIENT_TIMEOUT=295000
# ## Google AI ####
# GOOGLE_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+9 -1
View File
@@ -1,5 +1,13 @@
#!/usr/bin/env sh
set -e
[ "${HUSKY-}" = "0" ] && exit 0
export PATH="node_modules/.bin:$PATH"
BRANCH=$(git branch --show-current)
if [ "$BRANCH" = "dev" ] || [ "$BRANCH" = "main" ]; then
npm run type-check
fi
npx --no-install lint-staged
lint-staged
+6
View File
@@ -10,6 +10,10 @@ inputs:
description: Pass-through to actions/setup-node package-manager-cache
required: false
default: 'false'
bun-version:
description: Bun version
required: false
default: '1.3.2'
runs:
using: composite
@@ -21,6 +25,8 @@ runs:
- name: Install bun
uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ inputs.bun-version }}
- name: Setup Node.js
uses: actions/setup-node@v6
+8 -1
View File
@@ -59,7 +59,14 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v6
env:
REF_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
REPOSITORY: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
run: |
git init .
git remote add origin "https://github.com/${REPOSITORY}.git"
git fetch --no-tags --depth=1 origin "${REF_SHA}"
git checkout --force FETCH_HEAD
- name: Setup environment
uses: ./.github/actions/setup-env
+4 -11
View File
@@ -16,14 +16,14 @@ permissions:
jobs:
run:
permissions:
issues: write # for actions-cool/issues-helper to update issues
pull-requests: write # for actions-cool/issues-helper to update PRs
issues: write
pull-requests: write
runs-on: ubuntu-latest
steps:
- name: Auto Comment on Issues Closed
uses: wow-actions/auto-comment@v1
with:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN}}
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
issuesClosed: |
✅ @{{ author }}
@@ -51,11 +51,4 @@ jobs:
The growth of project is inseparable from user feedback and contribution, thanks for your contribution! If you are interesting with the lobehub developer community, please join our [discord](https://discord.com/invite/AYFPHvv2jT) and then dm @arvinxx or @canisminor1990. They will invite you to our private developer channel. We are talking about the lobe-chat development or sharing ai newsletter around the world.
emoji: 'hooray'
pr-emoji: '+1, heart'
- name: Remove inactive
if: github.event.issue.state == 'open' && github.actor == github.event.issue.user.login
uses: actions-cool/issues-helper@v3
with:
actions: 'remove-labels'
token: ${{ secrets.GH_TOKEN }}
issue-number: ${{ github.event.issue.number }}
labels: 'Inactive'
-63
View File
@@ -1,63 +0,0 @@
name: Issue Close Require
on:
schedule:
- cron: '0 0 * * *'
permissions:
contents: read
jobs:
issue-check-inactive:
permissions:
issues: write # for actions-cool/issues-helper to update issues
pull-requests: write # for actions-cool/issues-helper to update PRs
runs-on: ubuntu-latest
steps:
- name: check-inactive
uses: actions-cool/issues-helper@v3
with:
actions: 'check-inactive'
token: ${{ secrets.GH_TOKEN }}
inactive-label: 'Inactive'
inactive-day: 60
issue-close-require:
permissions:
issues: write # for actions-cool/issues-helper to update issues
pull-requests: write # for actions-cool/issues-helper to update PRs
runs-on: ubuntu-latest
steps:
- name: need reproduce
uses: actions-cool/issues-helper@v3
with:
actions: 'close-issues'
token: ${{ secrets.GH_TOKEN }}
labels: '✅ Fixed'
inactive-day: 3
body: |
👋 @{{ author }}
<br/>
Since the issue was labeled with `✅ Fixed`, but no response in 3 days. This issue will be closed. If you have any questions, you can comment and reply.
- name: need reproduce
uses: actions-cool/issues-helper@v3
with:
actions: 'close-issues'
token: ${{ secrets.GH_TOKEN }}
labels: '🤔 Need Reproduce'
inactive-day: 3
body: |
👋 @{{ author }}
<br/>
Since the issue was labeled with `🤔 Need Reproduce`, but no response in 3 days. This issue will be closed. If you have any questions, you can comment and reply.
- name: need reproduce
uses: actions-cool/issues-helper@v3
with:
actions: 'close-issues'
token: ${{ secrets.GH_TOKEN }}
labels: "🙅🏻‍♀️ WON'T DO"
inactive-day: 3
body: |
👋 @{{ github.event.issue.user.login }}
<br/>
Since the issue was labeled with `🙅🏻‍♀️ WON'T DO`, and no response in 3 days. This issue will be closed. If you have any questions, you can comment and reply.
+2 -2
View File
@@ -20,7 +20,7 @@ jobs:
- uses: actions/checkout@v6
- name: Clean issue notice
uses: actions-cool/issues-helper@v3
uses: actions-cool/issues-helper@e361abf610221f09495ad510cb1e69328d839e1c # v3.7.6
with:
actions: 'close-issues'
labels: '🚨 Sync Fail'
@@ -37,7 +37,7 @@ jobs:
- name: Sync check
if: failure()
uses: actions-cool/issues-helper@v3
uses: actions-cool/issues-helper@e361abf610221f09495ad510cb1e69328d839e1c # v3.7.6
with:
actions: 'create-issue'
title: '🚨 同步失败 | Sync Fail'
+45 -5
View File
@@ -35,7 +35,15 @@ jobs:
PACKAGES: '@lobechat/file-loaders @lobechat/prompts @lobechat/model-runtime @lobechat/web-crawler @lobechat/electron-server-ipc @lobechat/utils @lobechat/python-interpreter @lobechat/context-engine @lobechat/agent-runtime @lobechat/conversation-flow @lobechat/ssrf-safe-fetch @lobechat/memory-user-memory @lobechat/types @lobechat/builtin-tool-lobe-agent model-bank'
steps:
- uses: actions/checkout@v6
- name: Checkout
env:
REF_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
REPOSITORY: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
run: |
git init .
git remote add origin "https://github.com/${REPOSITORY}.git"
git fetch --no-tags --depth=1 origin "${REF_SHA}"
git checkout --force FETCH_HEAD
- name: Setup environment
uses: ./.github/actions/setup-env
@@ -101,7 +109,15 @@ jobs:
name: Test App (shard ${{ matrix.shard }}/3)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Checkout
env:
REF_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
REPOSITORY: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
run: |
git init .
git remote add origin "https://github.com/${REPOSITORY}.git"
git fetch --no-tags --depth=1 origin "${REF_SHA}"
git checkout --force FETCH_HEAD
- name: Setup environment
uses: ./.github/actions/setup-env
@@ -128,7 +144,15 @@ jobs:
name: Merge and Upload App Coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Checkout
env:
REF_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
REPOSITORY: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
run: |
git init .
git remote add origin "https://github.com/${REPOSITORY}.git"
git fetch --no-tags --depth=1 origin "${REF_SHA}"
git checkout --force FETCH_HEAD
- name: Setup environment
uses: ./.github/actions/setup-env
@@ -161,7 +185,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Checkout
env:
REF_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
REPOSITORY: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
run: |
git init .
git remote add origin "https://github.com/${REPOSITORY}.git"
git fetch --no-tags --depth=1 origin "${REF_SHA}"
git checkout --force FETCH_HEAD
- name: Setup environment
uses: ./.github/actions/setup-env
@@ -207,7 +239,15 @@ jobs:
- 5432:5432
steps:
- uses: actions/checkout@v6
- name: Checkout
env:
REF_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
REPOSITORY: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
run: |
git init .
git remote add origin "https://github.com/${REPOSITORY}.git"
git fetch --no-tags --depth=1 origin "${REF_SHA}"
git checkout --force FETCH_HEAD
- name: Setup environment
uses: ./.github/actions/setup-env
+2 -1
View File
@@ -96,7 +96,7 @@ sitemap*.xml
robots.txt
# Git hooks
.husky/prepare-commit-msg
.githooks/prepare-commit-msg
# Documents and media
*.pdf
@@ -106,6 +106,7 @@ vertex-ai-key.json
# Agent tracing snapshots
.agent-tracing/
.llm-generation-tracing/
# AI coding tools
.local/
+2 -2
View File
@@ -8,7 +8,7 @@
.history
.temp
.env.local
.husky
.githooks
.npmrc
.gitkeep
venv
@@ -59,4 +59,4 @@ Dockerfile*
# misc
# add other ignore file below
.next
.next
+1 -1
View File
@@ -219,7 +219,7 @@ ENV \
# AiHubMix
AIHUBMIX_API_KEY="" AIHUBMIX_MODEL_LIST="" \
# Anthropic
ANTHROPIC_API_KEY="" ANTHROPIC_MODEL_LIST="" ANTHROPIC_PROXY_URL="" \
ANTHROPIC_API_KEY="" ANTHROPIC_CLIENT_TIMEOUT="" ANTHROPIC_MODEL_LIST="" ANTHROPIC_PROXY_URL="" \
# Amazon Bedrock
ENABLED_AWS_BEDROCK="" AWS_ACCESS_KEY_ID="" AWS_SECRET_ACCESS_KEY="" AWS_REGION="" AWS_BEDROCK_MODEL_LIST="" \
# Azure OpenAI
+38 -453
View File
@@ -4,9 +4,11 @@
# LobeHub
LobeHub is the ultimate space for work and life: <br/>
to find, build, and collaborate with agent teammates that grow with you.<br/>
Were building the worlds largest humanagent co-evolving network.
LobeHub organizes your agents into 7×24 operation.
It hires, schedules, reports on your entire AI team.
You stay in charge — without staying online.
**English** · [简体中文](./README.zh-CN.md) · [Official Site][official-site] · [Changelog][changelog] · [Documents][docs] · [Blog][blog] · [Feedback][github-issues-link]
@@ -25,7 +27,6 @@ Were building the worlds largest humanagent co-evolving network.
[![][github-stars-shield]][github-stars-link]
[![][github-issues-shield]][github-issues-link]
[![][github-license-shield]][github-license-link]<br>
[![][sponsor-shield]][sponsor-link]
**Share LobeHub Repository**
@@ -37,9 +38,9 @@ Were building the worlds largest humanagent co-evolving network.
[![][share-mastodon-shield]][share-mastodon-link]
[![][share-linkedin-shield]][share-linkedin-link]
<sup>Agent teammates that grow with you</sup>
<sup>Your Chief Agent Operator</sup>
[![][github-trending-shield]][github-trending-url]
<a href="https://www.producthunt.com/products/lobehub?embed=true&amp;utm_source=badge-top-post-badge&amp;utm_medium=badge&amp;utm_campaign=badge-lobehub-2" target="_blank" rel="noopener noreferrer"><img alt="LobeHub - Your Chief Agent Operator for multi-agent work | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=1147569&amp;theme=light&amp;period=daily&amp;t=1779247564355"></a> <a href="https://trendshift.io/repositories/19224" target="_blank"><img src="https://trendshift.io/api/badge/repositories/19224" alt="lobehub%2Flobehub | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[![](https://vercel.com/oss/program-badge.svg)](https://vercel.com/oss)
@@ -52,30 +53,10 @@ Were building the worlds largest humanagent co-evolving network.
- [👋🏻 Getting Started & Join Our Community](#-getting-started--join-our-community)
- [✨ Features](#-features)
- [Operator: Agents as the Unit of Work](#operator-agents-as-the-unit-of-work)
- [Create: Agents as the Unit of Work](#create-agents-as-the-unit-of-work)
- [Collaborate: Scale New Forms of Collaboration Networks](#collaborate-scale-new-forms-of-collaboration-networks)
- [Evolve: Co-evolution of Humans and Agents](#evolve-co-evolution-of-humans-and-agents)
- [MCP Plugin One-Click Installation](#mcp-plugin-one-click-installation)
- [MCP Marketplace](#mcp-marketplace)
- [Desktop App](#desktop-app)
- [Smart Internet Search](#smart-internet-search)
- [Chain of Thought](#chain-of-thought)
- [Branching Conversations](#branching-conversations)
- [Artifacts Support](#artifacts-support)
- [File Upload /Knowledge Base](#file-upload-knowledge-base)
- [Multi-Model Service Provider Support](#multi-model-service-provider-support)
- [Local Large Language Model (LLM) Support](#local-large-language-model-llm-support)
- [Model Visual Recognition](#model-visual-recognition)
- [TTS & STT Voice Conversation](#tts--stt-voice-conversation)
- [Text to Image Generation](#text-to-image-generation)
- [Plugin System (Function Calling)](#plugin-system-function-calling)
- [Agent Market (GPTs)](#agent-market-gpts)
- [Support Local / Remote Database](#support-local--remote-database)
- [Support Multi-User Management](#support-multi-user-management)
- [Progressive Web App (PWA)](#progressive-web-app-pwa)
- [Mobile Device Adaptation](#mobile-device-adaptation)
- [Custom Themes](#custom-themes)
- [`*` What's more](#-whats-more)
- [🛳 Self Hosting](#-self-hosting)
- [`A` Deploying with Vercel, Zeabur , Sealos or Alibaba Cloud](#a-deploying-with-vercel-zeabur--sealos-or-alibaba-cloud)
- [`B` Deploying with Docker](#b-deploying-with-docker)
@@ -95,7 +76,7 @@ Were building the worlds largest humanagent co-evolving network.
<br/>
<https://github.com/user-attachments/assets/6710ad97-03d0-4175-bd75-adff9b55eca2>
<https://github.com/user-attachments/assets/0a33365f-b786-48b5-9ed6-f8af7927bccb>
## 👋🏻 Getting Started & Join Our Community
@@ -104,9 +85,9 @@ By adopting the Bootstrapping approach, we aim to provide developers and users w
Whether for users or professional developers, LobeHub will be your AI Agent playground. Please be aware that LobeHub is currently under active development, and feedback is welcome for any [issues][issues-link] encountered.
| [![](https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1065874&theme=light&t=1769347414733)](https://www.producthunt.com/products/lobehub?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-lobehub) | We are live on Product Hunt! We are thrilled to bring LobeHub to the world. If you believe in a future where humans and agents co-evolve, please support our journey. |
| :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [![][discord-shield-badge]][discord-link] | Join our Discord community! This is where you can connect with developers and other enthusiastic users of LobeHub. |
| [![](https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1065874&theme=light&t=1769347414733)](https://www.producthunt.com/products/lobehub?launch=lobehub-2&embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-lobehub) | We are live on Product Hunt! We are thrilled to bring LobeHub to the world. If you believe in a future where humans and agents co-evolve, please support our journey. |
| :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [![][discord-shield-badge]][discord-link] | Join our Discord community! This is where you can connect with developers and other enthusiastic users of LobeHub. |
> \[!IMPORTANT]
>
@@ -130,7 +111,26 @@ Todays agents are one-off, task-driven tools. They lack context, live in isol
LobeHub is a work-and-lifestyle space to find, build, and collaborate with agent teammates that grow with you. In LobeHub, we treat **Agents as the unit of work**, providing an infrastructure where humans and agents co-evolve.
![](https://hub-apac-1.lobeobjects.space/blog/assets/2204cde2228fb3f583f3f2c090bc49fb.webp)
![](https://github.com/user-attachments/assets/89d1c402-a62b-4794-82ea-17e5ee1a6165)
### Operator: Agents as the Unit of Work
Hires, schedules, and reports on your entire AI team.
- **More productivity. Fewer tools**: Bring all your agents under one roof.
- **IM Gateway**: Agents where you already chat.
![](https://github.com/user-attachments/assets/7b08d6d9-9dff-4b06-a919-324630554509)
[![][back-to-top]](#readme-top)
<div align="right">
[![][back-to-top]](#readme-top)
</div>
![](https://github.com/user-attachments/assets/81e89324-fc66-4024-99a3-aa8e16ec8184)
### Create: Agents as the Unit of Work
@@ -139,6 +139,8 @@ Building a personalized AI team starts with the **Agent Builder**. You can descr
- **Unified Intelligence**: Seamlessly access any model and any modality—all under your control.
- **10,000+ Skills**: Connect your agents to the skills you use every day with a library of over 10,000 tools and MCP-compatible plugins.
![](https://github.com/user-attachments/assets/949b8166-486d-4750-ad7a-cfe7bfcb84e3)
[![][back-to-top]](#readme-top)
<div align="right">
@@ -158,6 +160,8 @@ LobeHub introduces **Agent Groups**, allowing you to work with agents like real
- **Project**: Organize work by project to keep everything structured and easy to track.
- **Workspace**: A shared space for teams to collaborate with agents, ensuring clear ownership and visibility across the organization.
![](https://github.com/user-attachments/assets/e51526c6-e09c-4a5a-9cec-dcd3fd68a3a8)
[![][back-to-top]](#readme-top)
<div align="right">
@@ -175,113 +179,7 @@ The best AI is one that understands you deeply. LobeHub features **Personal Memo
- **Continual Learning**: Your agents learn from how you work, adapting their behavior to act at the right moment.
- **White-Box Memory**: We believe in transparency. Your agents use structured, editable memory, giving you full control over what they remember.
<div align="right">
[![][back-to-top]](#readme-top)
</div>
<details>
<summary>More Features</summary>
![][image-feat-mcp]
### MCP Plugin One-Click Installation
**Seamlessly Connect Your AI to the World**
Unlock the full potential of your AI by enabling smooth, secure, and dynamic interactions with external tools, data sources, and services. LobeHub's MCP (Model Context Protocol) plugin system breaks down the barriers between your AI and the digital ecosystem, allowing for unprecedented connectivity and functionality.
Transform your conversations into powerful workflows by connecting to databases, APIs, file systems, and more. Experience the freedom of AI that truly understands and interacts with your world.
[![][back-to-top]](#readme-top)
![][image-feat-mcp-market]
### MCP Marketplace
**Discover, Connect, Extend**
Browse a growing library of MCP plugins to expand your AI's capabilities and streamline your workflows effortlessly. Visit [lobehub.com/mcp](https://lobehub.com/mcp) to explore the MCP Marketplace, which offers a curated collection of integrations that enhance your AI's ability to work with various tools and services.
From productivity tools to development environments, discover new ways to extend your AI's reach and effectiveness. Connect with the community and find the perfect plugins for your specific needs.
[![][back-to-top]](#readme-top)
![][image-feat-desktop]
### Desktop App
**Peak Performance, Zero Distractions**
Get the full LobeHub experience without browser limitations—comprehensive, focused, and always ready to go. Our desktop application provides a dedicated environment for your AI interactions, ensuring optimal performance and minimal distractions.
Experience faster response times, better resource management, and a more stable connection to your AI assistant. The desktop app is designed for users who demand the best performance from their AI tools.
[![][back-to-top]](#readme-top)
![][image-feat-web-search]
### Smart Internet Search
**Online Knowledge On Demand**
With real-time internet access, your AI keeps up with the world—news, data, trends, and more. Stay informed and get the most current information available, enabling your AI to provide accurate and up-to-date responses.
Access live information, verify facts, and explore current events without leaving your conversation. Your AI becomes a gateway to the world's knowledge, always current and comprehensive.
[![][back-to-top]](#readme-top)
[![][image-feat-cot]][docs-feat-cot]
### [Chain of Thought][docs-feat-cot]
Experience AI reasoning like never before. Watch as complex problems unfold step by step through our innovative Chain of Thought (CoT) visualization. This breakthrough feature provides unprecedented transparency into AI's decision-making process, allowing you to observe how conclusions are reached in real-time.
By breaking down complex reasoning into clear, logical steps, you can better understand and validate the AI's problem-solving approach. Whether you're debugging, learning, or simply curious about AI reasoning, CoT visualization transforms abstract thinking into an engaging, interactive experience.
[![][back-to-top]](#readme-top)
[![][image-feat-branch]][docs-feat-branch]
### [Branching Conversations][docs-feat-branch]
Introducing a more natural and flexible way to chat with AI. With Branch Conversations, your discussions can flow in multiple directions, just like human conversations do. Create new conversation branches from any message, giving you the freedom to explore different paths while preserving the original context.
Choose between two powerful modes:
- **Continuation Mode:** Seamlessly extend your current discussion while maintaining valuable context
- **Standalone Mode:** Start fresh with a new topic based on any previous message
This groundbreaking feature transforms linear conversations into dynamic, tree-like structures, enabling deeper exploration of ideas and more productive interactions.
[![][back-to-top]](#readme-top)
[![][image-feat-artifacts]][docs-feat-artifacts]
### [Artifacts Support][docs-feat-artifacts]
Experience the power of Claude Artifacts, now integrated into LobeHub. This revolutionary feature expands the boundaries of AI-human interaction, enabling real-time creation and visualization of diverse content formats.
Create and visualize with unprecedented flexibility:
- Generate and display dynamic SVG graphics
- Build and render interactive HTML pages in real-time
- Produce professional documents in multiple formats
[![][back-to-top]](#readme-top)
[![][image-feat-knowledgebase]][docs-feat-knowledgebase]
### [File Upload /Knowledge Base][docs-feat-knowledgebase]
LobeHub supports file upload and knowledge base functionality. You can upload various types of files including documents, images, audio, and video, as well as create knowledge bases, making it convenient for users to manage and search for files. Additionally, you can utilize files and knowledge base features during conversations, enabling a richer dialogue experience.
<https://github.com/user-attachments/assets/faa8cf67-e743-4590-8bf6-ebf6ccc34175>
> \[!TIP]
>
> Learn more on [📘 LobeHub Knowledge Base Launch — From Now On, Every Step Counts](https://lobehub.com/blog/knowledge-base)
![](https://github.com/user-attachments/assets/5c6e16f0-7f47-4baf-9aeb-3a00deb8ff5b)
<div align="right">
@@ -289,277 +187,6 @@ LobeHub supports file upload and knowledge base functionality. You can upload va
</div>
[![][image-feat-privoder]][docs-feat-provider]
### [Multi-Model Service Provider Support][docs-feat-provider]
In the continuous development of LobeHub, we deeply understand the importance of diversity in model service providers for meeting the needs of the community when providing AI conversation services. Therefore, we have expanded our support to multiple model service providers, rather than being limited to a single one, in order to offer users a more diverse and rich selection of conversations.
In this way, LobeHub can more flexibly adapt to the needs of different users, while also providing developers with a wider range of choices.
#### Supported Model Service Providers
We have implemented support for the following model service providers:
<!-- PROVIDER LIST -->
<details><summary><kbd>See more providers (+-10)</kbd></summary>
</details>
> 📊 Total providers: [<kbd>**0**</kbd>](https://lobechat.com/discover/providers)
<!-- PROVIDER LIST -->
At the same time, we are also planning to support more model service providers. If you would like LobeHub to support your favorite service provider, feel free to join our [💬 community discussion](https://github.com/lobehub/lobehub/discussions/1284).
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-local]][docs-feat-local]
### [Local Large Language Model (LLM) Support][docs-feat-local]
To meet the specific needs of users, LobeHub also supports the use of local models based on [Ollama](https://ollama.ai), allowing users to flexibly use their own or third-party models.
> \[!TIP]
>
> Learn more about [📘 Using Ollama in LobeHub][docs-usage-ollama] by checking it out.
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-vision]][docs-feat-vision]
### [Model Visual Recognition][docs-feat-vision]
LobeHub now supports OpenAI's latest [`gpt-4-vision`](https://platform.openai.com/docs/guides/vision) model with visual recognition capabilities,
a multimodal intelligence that can perceive visuals. Users can easily upload or drag and drop images into the dialogue box,
and the agent will be able to recognize the content of the images and engage in intelligent conversation based on this,
creating smarter and more diversified chat scenarios.
This feature opens up new interactive methods, allowing communication to transcend text and include a wealth of visual elements.
Whether it's sharing images in daily use or interpreting images within specific industries, the agent provides an outstanding conversational experience.
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-tts]][docs-feat-tts]
### [TTS & STT Voice Conversation][docs-feat-tts]
LobeHub supports Text-to-Speech (TTS) and Speech-to-Text (STT) technologies, enabling our application to convert text messages into clear voice outputs,
allowing users to interact with our conversational agent as if they were talking to a real person. Users can choose from a variety of voices to pair with the agent.
Moreover, TTS offers an excellent solution for those who prefer auditory learning or desire to receive information while busy.
In LobeHub, we have meticulously selected a range of high-quality voice options (OpenAI Audio, Microsoft Edge Speech) to meet the needs of users from different regions and cultural backgrounds.
Users can choose the voice that suits their personal preferences or specific scenarios, resulting in a personalized communication experience.
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-t2i]][docs-feat-t2i]
### [Text to Image Generation][docs-feat-t2i]
With support for the latest text-to-image generation technology, LobeHub now allows users to invoke image creation tools directly within conversations with the agent. By leveraging the capabilities of AI tools such as [`DALL-E 3`](https://openai.com/dall-e-3), [`MidJourney`](https://www.midjourney.com/), and [`Pollinations`](https://pollinations.ai/), the agents are now equipped to transform your ideas into images.
This enables a more private and immersive creative process, allowing for the seamless integration of visual storytelling into your personal dialogue with the agent.
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-plugin]][docs-feat-plugin]
### [Plugin System (Function Calling)][docs-feat-plugin]
The plugin ecosystem of LobeHub is an important extension of its core functionality, greatly enhancing the practicality and flexibility of the LobeHub assistant.
<video controls src="https://github.com/lobehub/lobehub/assets/28616219/f29475a3-f346-4196-a435-41a6373ab9e2" muted="false"></video>
By utilizing plugins, LobeHub assistants can obtain and process real-time information, such as searching for web information and providing users with instant and relevant news.
In addition, these plugins are not limited to news aggregation, but can also extend to other practical functions, such as quickly searching documents, generating images, obtaining data from various platforms like Bilibili, Steam, and interacting with various third-party services.
> \[!TIP]
>
> Learn more about [📘 Plugin Usage][docs-usage-plugin] by checking it out.
<!-- PLUGIN LIST -->
| Recent Submits | Description |
| -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| [Shopping tools](https://lobechat.com/discover/plugin/ShoppingTools)<br/><sup>By **shoppingtools** on **2026-01-12**</sup> | Search for products on eBay & AliExpress, find eBay events & coupons. Get prompt examples.<br/>`shopping` `e-bay` `ali-express` `coupons` |
| [SEO Assistant](https://lobechat.com/discover/plugin/seo_assistant)<br/><sup>By **webfx** on **2026-01-12**</sup> | The SEO Assistant can generate search engine keyword information in order to aid the creation of content.<br/>`seo` `keyword` |
| [Video Captions](https://lobechat.com/discover/plugin/VideoCaptions)<br/><sup>By **maila** on **2025-12-13**</sup> | Convert Youtube links into transcribed text, enable asking questions, create chapters, and summarize its content.<br/>`video-to-text` `youtube` |
| [WeatherGPT](https://lobechat.com/discover/plugin/WeatherGPT)<br/><sup>By **steven-tey** on **2025-12-13**</sup> | Get current weather information for a specific location.<br/>`weather` |
> 📊 Total plugins: [<kbd>**40**</kbd>](https://lobechat.com/discover/plugins)
<!-- PLUGIN LIST -->
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-agent]][docs-feat-agent]
### [Agent Market (GPTs)][docs-feat-agent]
In LobeHub Agent Marketplace, creators can discover a vibrant and innovative community that brings together a multitude of well-designed agents,
which not only play an important role in work scenarios but also offer great convenience in learning processes.
Our marketplace is not just a showcase platform but also a collaborative space. Here, everyone can contribute their wisdom and share the agents they have developed.
> \[!TIP]
>
> By [🤖/🏪 Submit Agents][submit-agents-link], you can easily submit your agent creations to our platform.
> Importantly, LobeHub has established a sophisticated automated internationalization (i18n) workflow,
> capable of seamlessly translating your agent into multiple language versions.
> This means that no matter what language your users speak, they can experience your agent without barriers.
> \[!IMPORTANT]
>
> We welcome all users to join this growing ecosystem and participate in the iteration and optimization of agents.
> Together, we can create more interesting, practical, and innovative agents, further enriching the diversity and practicality of the agent offerings.
<!-- AGENT LIST -->
| Recent Submits | Description |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [Turtle Soup Host](https://lobechat.com/discover/assistant/lateral-thinking-puzzle)<br/><sup>By **[CSY2022](https://github.com/CSY2022)** on **2025-06-19**</sup> | A turtle soup host needs to provide the scenario, the complete story (truth of the event), and the key point (the condition for guessing correctly).<br/>`turtle-soup` `reasoning` `interaction` `puzzle` `role-playing` |
| [Academic Writing Assistant](https://lobechat.com/discover/assistant/academic-writing-assistant)<br/><sup>By **[swarfte](https://github.com/swarfte)** on **2025-06-17**</sup> | Expert in academic research paper writing and formal documentation<br/>`academic-writing` `research` `formal-style` |
| [Gourmet Reviewer🍟](https://lobechat.com/discover/assistant/food-reviewer)<br/><sup>By **[renhai-lab](https://github.com/renhai-lab)** on **2025-06-17**</sup> | Food critique expert<br/>`gourmet` `review` `writing` |
| [Minecraft Senior Developer](https://lobechat.com/discover/assistant/java-development)<br/><sup>By **[iamyuuk](https://github.com/iamyuuk)** on **2025-06-17**</sup> | Expert in advanced Java development and Minecraft mod and server plugin development<br/>`development` `programming` `minecraft` `java` |
> 📊 Total agents: [<kbd>**505**</kbd> ](https://lobechat.com/discover/assistants)
<!-- AGENT LIST -->
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-database]][docs-feat-database]
### [Support Local / Remote Database][docs-feat-database]
LobeHub supports the use of both server-side and local databases. Depending on your needs, you can choose the appropriate deployment solution:
- **Local database**: suitable for users who want more control over their data and privacy protection. LobeHub uses CRDT (Conflict-Free Replicated Data Type) technology to achieve multi-device synchronization. This is an experimental feature aimed at providing a seamless data synchronization experience.
- **Server-side database**: suitable for users who want a more convenient user experience. LobeHub supports PostgreSQL as a server-side database. For detailed documentation on how to configure the server-side database, please visit [Configure Server-side Database](https://lobehub.com/docs/self-hosting/advanced/server-database).
Regardless of which database you choose, LobeHub can provide you with an excellent user experience.
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-auth]][docs-feat-auth]
### [Support Multi-User Management][docs-feat-auth]
LobeHub supports multi-user management and provides flexible user authentication solutions:
- **Better Auth**: LobeHub integrates `Better Auth`, a modern and flexible authentication library that supports multiple authentication methods, including OAuth, email login, credential login, magic links, and more. With `Better Auth`, you can easily implement user registration, login, session management, social login, multi-factor authentication (MFA), and other functions to ensure the security and privacy of user data.
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-pwa]][docs-feat-pwa]
### [Progressive Web App (PWA)][docs-feat-pwa]
We deeply understand the importance of providing a seamless experience for users in today's multi-device environment.
Therefore, we have adopted Progressive Web Application ([PWA](https://support.google.com/chrome/answer/9658361)) technology,
a modern web technology that elevates web applications to an experience close to that of native apps.
Through PWA, LobeHub can offer a highly optimized user experience on both desktop and mobile devices while maintaining high-performance characteristics.
Visually and in terms of feel, we have also meticulously designed the interface to ensure it is indistinguishable from native apps,
providing smooth animations, responsive layouts, and adapting to different device screen resolutions.
> \[!NOTE]
>
> If you are unfamiliar with the installation process of PWA, you can add LobeHub as your desktop application (also applicable to mobile devices) by following these steps:
>
> - Launch the Chrome or Edge browser on your computer.
> - Visit the LobeHub webpage.
> - In the upper right corner of the address bar, click on the <kbd>Install</kbd> icon.
> - Follow the instructions on the screen to complete the PWA Installation.
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-mobile]][docs-feat-mobile]
### [Mobile Device Adaptation][docs-feat-mobile]
We have carried out a series of optimization designs for mobile devices to enhance the user's mobile experience. Currently, we are iterating on the mobile user experience to achieve smoother and more intuitive interactions. If you have any suggestions or ideas, we welcome you to provide feedback through GitHub Issues or Pull Requests.
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-theme]][docs-feat-theme]
### [Custom Themes][docs-feat-theme]
As a design-engineering-oriented application, LobeHub places great emphasis on users' personalized experiences,
hence introducing flexible and diverse theme modes, including a light mode for daytime and a dark mode for nighttime.
Beyond switching theme modes, a range of color customization options allow users to adjust the application's theme colors according to their preferences.
Whether it's a desire for a sober dark blue, a lively peach pink, or a professional gray-white, users can find their style of color choices in LobeHub.
> \[!TIP]
>
> The default configuration can intelligently recognize the user's system color mode and automatically switch themes to ensure a consistent visual experience with the operating system.
> For users who like to manually control details, LobeHub also offers intuitive setting options and a choice between chat bubble mode and document mode for conversation scenarios.
<div align="right">
[![][back-to-top]](#readme-top)
</div>
### `*` What's more
Beside these features, LobeHub also have much better basic technique underground:
- [x] 💨 **Quick Deployment**: Using the Vercel platform or docker image, you can deploy with just one click and complete the process within 1 minute without any complex configuration.
- [x] 🌐 **Custom Domain**: If users have their own domain, they can bind it to the platform for quick access to the dialogue agent from anywhere.
- [x] 🔒 **Privacy Protection**: All data is stored locally in the user's browser, ensuring user privacy.
- [x] 💎 **Exquisite UI Design**: With a carefully designed interface, it offers an elegant appearance and smooth interaction. It supports light and dark themes and is mobile-friendly. PWA support provides a more native-like experience.
- [x] 🗣️ **Smooth Conversation Experience**: Fluid responses ensure a smooth conversation experience. It fully supports Markdown rendering, including code highlighting, LaTex formulas, Mermaid flowcharts, and more.
</details>
> ✨ more features will be added when LobeHub evolve.
<div align="right">
@@ -855,28 +482,10 @@ This project is [LobeHub Community License](./LICENSE) licensed.
[docs-dev-guide]: https://lobehub.com/docs/development/start
[docs-docker]: https://lobehub.com/docs/self-hosting/server-database/docker-compose
[docs-env-var]: https://lobehub.com/docs/self-hosting/environment-variables
[docs-feat-agent]: https://lobehub.com/docs/usage/features/agent-market
[docs-feat-artifacts]: https://lobehub.com/docs/usage/features/artifacts
[docs-feat-auth]: https://lobehub.com/docs/usage/features/auth
[docs-feat-branch]: https://lobehub.com/docs/usage/features/branching-conversations
[docs-feat-cot]: https://lobehub.com/docs/usage/features/cot
[docs-feat-database]: https://lobehub.com/docs/usage/features/database
[docs-feat-knowledgebase]: https://lobehub.com/blog/knowledge-base
[docs-feat-local]: https://lobehub.com/docs/usage/features/local-llm
[docs-feat-mobile]: https://lobehub.com/docs/usage/features/mobile
[docs-feat-plugin]: https://lobehub.com/docs/usage/features/plugin-system
[docs-feat-provider]: https://lobehub.com/docs/usage/features/multi-ai-providers
[docs-feat-pwa]: https://lobehub.com/docs/usage/features/pwa
[docs-feat-t2i]: https://lobehub.com/docs/usage/features/text-to-image
[docs-feat-theme]: https://lobehub.com/docs/usage/features/theme
[docs-feat-tts]: https://lobehub.com/docs/usage/features/tts
[docs-feat-vision]: https://lobehub.com/docs/usage/features/vision
[docs-function-call]: https://lobehub.com/blog/openai-function-call
[docs-plugin-dev]: https://lobehub.com/docs/usage/plugins/development
[docs-self-hosting]: https://lobehub.com/docs/self-hosting/start
[docs-upstream-sync]: https://lobehub.com/docs/self-hosting/advanced/upstream-sync
[docs-usage-ollama]: https://lobehub.com/docs/usage/providers/ollama
[docs-usage-plugin]: https://lobehub.com/docs/usage/plugins/basic
[fossa-license-link]: https://app.fossa.com/projects/git%2Bgithub.com%2Flobehub%2Flobehub
[fossa-license-shield]: https://app.fossa.com/api/projects/git%2Bgithub.com%2Flobehub%2Flobehub.svg?type=large
[github-action-release-link]: https://github.com/actions/workflows/lobehub/lobehub/release.yml
@@ -898,29 +507,7 @@ This project is [LobeHub Community License](./LICENSE) licensed.
[github-releasedate-shield]: https://img.shields.io/github/release-date/lobehub/lobehub?labelColor=black&style=flat-square
[github-stars-link]: https://github.com/lobehub/lobehub/stargazers
[github-stars-shield]: https://img.shields.io/github/stars/lobehub/lobehub?color=ffcb47&labelColor=black&style=flat-square
[github-trending-shield]: https://trendshift.io/api/badge/repositories/2256
[github-trending-url]: https://trendshift.io/repositories/2256
[image-banner]: https://github.com/user-attachments/assets/0fe626a3-0ddc-4f67-b595-3c5b3f1701e0
[image-feat-agent]: https://github.com/user-attachments/assets/b3ab6e35-4fbc-468d-af10-e3e0c687350f
[image-feat-artifacts]: https://github.com/user-attachments/assets/7f95fad6-b210-4e6e-84a0-7f39e96f3a00
[image-feat-auth]: https://github.com/user-attachments/assets/80bb232e-19d1-4f97-98d6-e291f3585e6d
[image-feat-branch]: https://github.com/user-attachments/assets/92f72082-02bd-4835-9c54-b089aad7fd41
[image-feat-cot]: https://github.com/user-attachments/assets/f74f1139-d115-4e9c-8c43-040a53797a5e
[image-feat-database]: https://github.com/user-attachments/assets/f1697c8b-d1fb-4dac-ba05-153c6295d91d
[image-feat-desktop]: https://github.com/user-attachments/assets/a7bac8d3-ea96-4000-bb39-fadc9b610f96
[image-feat-knowledgebase]: https://github.com/user-attachments/assets/7da7a3b2-92fd-4630-9f4e-8560c74955ae
[image-feat-local]: https://github.com/user-attachments/assets/1239da50-d832-4632-a7ef-bd754c0f3850
[image-feat-mcp]: https://github.com/user-attachments/assets/1be85d36-3975-4413-931f-27e05e440995
[image-feat-mcp-market]: https://github.com/user-attachments/assets/bb114f9f-24c5-4000-a984-c10d187da5a0
[image-feat-mobile]: https://github.com/user-attachments/assets/32cf43c4-96bd-4a4c-bfb6-59acde6fe380
[image-feat-plugin]: https://github.com/user-attachments/assets/66a891ac-01b6-4e3f-b978-2eb07b489b1b
[image-feat-privoder]: https://github.com/user-attachments/assets/e553e407-42de-4919-977d-7dbfcf44a821
[image-feat-pwa]: https://github.com/user-attachments/assets/9647f70f-b71b-43b6-9564-7cdd12d1c24d
[image-feat-t2i]: https://github.com/user-attachments/assets/708274a7-2458-494b-a6ec-b73dfa1fa7c2
[image-feat-theme]: https://github.com/user-attachments/assets/b47c39f1-806f-492b-8fcb-b0fa973937c1
[image-feat-tts]: https://github.com/user-attachments/assets/50189597-2cc3-4002-b4c8-756a52ad5c0a
[image-feat-vision]: https://github.com/user-attachments/assets/18574a1f-46c2-4cbc-af2c-35a86e128a07
[image-feat-web-search]: https://github.com/user-attachments/assets/cfdc48ac-b5f8-4a00-acee-db8f2eba09ad
[image-banner]: https://github.com/user-attachments/assets/5f78ae58-ed4f-4d38-8037-96109fbba58c
[image-star]: https://github.com/user-attachments/assets/3216e25b-186f-4a54-9cb4-2f124aec0471
[issues-link]: https://img.shields.io/github/issues/lobehub/lobehub.svg?style=flat
[lobe-chat-plugins]: https://github.com/lobehub/lobe-chat-plugins
@@ -958,8 +545,6 @@ This project is [LobeHub Community License](./LICENSE) licensed.
[share-whatsapp-shield]: https://img.shields.io/badge/-share%20on%20whatsapp-black?labelColor=black&logo=whatsapp&logoColor=white&style=flat-square
[share-x-link]: https://x.com/intent/tweet?hashtags=chatbot%2CchatGPT%2CopenAI&text=Check%20this%20GitHub%20repository%20out%20%F0%9F%A4%AF%20LobeHub%20-%20An%20open-source%2C%20extensible%20%28Function%20Calling%29%2C%20high-performance%20chatbot%20framework.%20It%20supports%20one-click%20free%20deployment%20of%20your%20private%20ChatGPT%2FLLM%20web%20application.&url=https%3A%2F%2Fgithub.com%2Flobehub%2Flobehub
[share-x-shield]: https://img.shields.io/badge/-share%20on%20x-black?labelColor=black&logo=x&logoColor=white&style=flat-square
[sponsor-link]: https://opencollective.com/lobehub 'Become ❤️ LobeHub Sponsor'
[sponsor-shield]: https://img.shields.io/badge/-Sponsor%20LobeHub-f04f88?logo=opencollective&logoColor=white&style=flat-square
[submit-agents-link]: https://github.com/lobehub/lobe-chat-agents
[submit-agents-shield]: https://img.shields.io/badge/🤖/🏪_submit_agent-%E2%86%92-c4f042?labelColor=black&style=for-the-badge
[submit-plugin-link]: https://github.com/lobehub/lobe-chat-plugins
+39 -429
View File
@@ -4,8 +4,11 @@
# LobeHub
LobeHub 是一个工作与生活空间,用于发现、构建并与会随着您一起成长的 Agent 队友协作。<br/>
在 LobeHub 中,我们将 **Agent 视为工作单元**,提供一个让人类与 Agent 共同进化的基础设施。
LobeHub 帮你把专属 Agent 组织成 7×24 不打烊的高效队伍:
自动为你招募适配的 AI 队友、调度任务排班、汇总生成工作报告,
你始终掌控全局,从此不用再时刻在线盯守,真正解放自己的时间。
[English](./README.md) · **简体中文** · [官网][official-site] · [更新日志][changelog] · [文档][docs] · [博客][blog] · [反馈问题][github-issues-link]
@@ -24,7 +27,6 @@ LobeHub 是一个工作与生活空间,用于发现、构建并与会随着您
[![][github-stars-shield]][github-stars-link]
[![][github-issues-shield]][github-issues-link]
[![][github-license-shield]][github-license-link]<br>
[![][sponsor-shield]][sponsor-link]
**分享 LobeHub 给你的好友**
@@ -35,9 +37,9 @@ LobeHub 是一个工作与生活空间,用于发现、构建并与会随着您
[![][share-weibo-shield]][share-weibo-link]
[![][share-mastodon-shield]][share-mastodon-link]
<sup>Agent teammates that grow with you</sup>
<sup>你的首席 Agent 运营官</sup>
[![][github-trending-shield]][github-trending-url]
<a href="https://www.producthunt.com/products/lobehub?embed=true&amp;utm_source=badge-top-post-badge&amp;utm_medium=badge&amp;utm_campaign=badge-lobehub-2" target="_blank" rel="noopener noreferrer"><img alt="LobeHub - Your Chief Agent Operator for multi-agent work | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=1147569&amp;theme=light&amp;period=daily&amp;t=1779247564355"></a> <a href="https://trendshift.io/repositories/19224" target="_blank"><img src="https://trendshift.io/api/badge/repositories/19224" alt="lobehub%2Flobehub | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[![][github-hello-shield]][github-hello-url]
</div>
@@ -49,30 +51,10 @@ LobeHub 是一个工作与生活空间,用于发现、构建并与会随着您
- [👋🏻 开始使用 & 交流](#-开始使用--交流)
- [✨ 特性一览](#-特性一览)
- [运营:你制定策略,我们负责运行 Agent。](#运营你制定策略我们负责运行-agent)
- [创建:以 Agent 为工作单元](#创建以-agent-为工作单元)
- [协作:扩展新型协作网络](#协作扩展新型协作网络)
- [进化:人类与 Agent 的共生进化](#进化人类与-agent-的共生进化)
- [MCP](#mcp)
- [发现、连接、扩展](#发现连接扩展)
- [巅峰性能,零干扰](#巅峰性能零干扰)
- [在线知识,按需获取](#在线知识按需获取)
- [思维链 (CoT)](#思维链-cot)
- [分支对话](#分支对话)
- [支持白板 (Artifacts)](#支持白板-artifacts)
- [文件上传 / 知识库](#文件上传--知识库)
- [多模型服务商支持](#多模型服务商支持)
- [支持本地大语言模型 (LLM)](#支持本地大语言模型-llm)
- [模型视觉识别 (Model Visual)](#模型视觉识别-model-visual)
- [TTS & STT 语音会话](#tts--stt-语音会话)
- [Text to Image 文生图](#text-to-image-文生图)
- [插件系统 (Tools Calling)](#插件系统-tools-calling)
- [助手市场 (GPTs)](#助手市场-gpts)
- [支持本地 / 远程数据库](#支持本地--远程数据库)
- [支持多用户管理](#支持多用户管理)
- [渐进式 Web 应用 (PWA)](#渐进式-web-应用-pwa)
- [移动设备适配](#移动设备适配)
- [自定义主题](#自定义主题)
- [`*` 更多特性](#-更多特性)
- [🛳 开箱即用](#-开箱即用)
- [`A` 使用 Vercel、Zeabur 、Sealos 或 阿里云计算巢 部署](#a-使用-vercelzeabur-sealos-或-阿里云计算巢-部署)
- [`B` 使用 Docker 部署](#b-使用-docker-部署)
@@ -93,7 +75,7 @@ LobeHub 是一个工作与生活空间,用于发现、构建并与会随着您
<br/>
<https://github.com/user-attachments/assets/6710ad97-03d0-4175-bd75-adff9b55eca2>
<https://github.com/user-attachments/assets/0a33365f-b786-48b5-9ed6-f8af7927bccb>
## 👋🏻 开始使用 & 交流
@@ -102,9 +84,9 @@ LobeHub 是一个工作与生活空间,用于发现、构建并与会随着您
不论普通用户与专业开发者,LobeHub 旨在成为所有人的 AI Agent 实验场。LobeHub 目前正在积极开发中,有任何需求或者问题,欢迎提交 [issues][issues-link]
| [![](https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1065874&theme=light&t=1769347414733)](https://www.producthunt.com/products/lobehub?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-lobehub) | 我们已在 Product Hunt 上线!我们很高兴将 LobeHub 推向世界。如果您相信人类与 Agent 共同进化的未来,请支持我们的旅程。 |
| :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :------------------------------------------------------------------------------------------------------------------- |
| [![][discord-shield-badge]][discord-link] | 加入我们的 Discord 社区!这是你可以与开发者和其他 LobeHub 热衷用户交流的地方 |
| [![](https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1065874&theme=light&t=1769347414733)](https://www.producthunt.com/products/lobehub?launch=lobehub-2&embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-lobehub) | 我们已在 Product Hunt 上线!我们很高兴将 LobeHub 推向世界。如果您相信人类与 Agent 共同进化的未来,请支持我们的旅程。 |
| :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------- |
| [![][discord-shield-badge]][discord-link] | 加入我们的 Discord 社区!这是你可以与开发者和其他 LobeHub 热衷用户交流的地方 |
> \[!IMPORTANT]
>
@@ -127,7 +109,26 @@ LobeHub 是一个工作与生活空间,用于发现、构建并与会随着您
LobeHub 是一个工作与生活空间,用于发现、构建并与会随着您一起成长的 Agent 队友协作。在 LobeHub 中,我们将 **Agent 视为工作单元**,提供一个让人类与 Agent 共同进化的基础设施。
![](https://hub-apac-1.lobeobjects.space/blog/assets/2204cde2228fb3f583f3f2c090bc49fb.webp)
![](https://github.com/user-attachments/assets/89d1c402-a62b-4794-82ea-17e5ee1a6165)
### 运营:你制定策略,我们负责运行 Agent。
雇用、排程并汇报你整个 AI 团队的工作
- **更高生产力,更少工具**:将你所有的 Agent 集中在一个平台。
- **IM 网关**: Agent 连接到您每天使用的技能。
![](https://github.com/user-attachments/assets/7b08d6d9-9dff-4b06-a919-324630554509)
[![][back-to-top]](#readme-top)
<div align="right">
[![][back-to-top]](#readme-top)
</div>
![](https://github.com/user-attachments/assets/81e89324-fc66-4024-99a3-aa8e16ec8184)
### 创建:以 Agent 为工作单元
@@ -136,6 +137,8 @@ LobeHub 是一个工作与生活空间,用于发现、构建并与会随着您
- **统一智能**:无缝访问任何模型与任何模态 —— 全部由您掌控。
- **1 万 + 技能**:通过超过 10,000 个工具和与 MCP 兼容的插件,将 Agent 连接到您每天使用的技能。
![](https://github.com/user-attachments/assets/949b8166-486d-4750-ad7a-cfe7bfcb84e3)
[![][back-to-top]](#readme-top)
<div align="right">
@@ -155,6 +158,8 @@ LobeHub 引入了 **Agent Groups**,让您可以像对待真实队友一样与
- **项目(Project)**:按项目组织工作,保持一切结构化且易于跟踪。
- **工作区(Workspace**:供团队与 Agent 协作的共享空间,确保明确的所有权和组织内的可见性。
![](https://github.com/user-attachments/assets/e51526c6-e09c-4a5a-9cec-dcd3fd68a3a8)
[![][back-to-top]](#readme-top)
<div align="right">
@@ -172,105 +177,7 @@ LobeHub 引入了 **Agent Groups**,让您可以像对待真实队友一样与
- **持续学习**:您的 Agent 会从您的工作方式中学习,调整其行为以在恰当时刻采取行动。
- **白盒记忆**:我们相信透明性。您的 Agent 使用结构化、可编辑的记忆,让您完全掌控它们记住的内容。
<div align="right">
[![][back-to-top]](#readme-top)
</div>
<details>
<summary>更多特性</summary>
[![](https://github.com/user-attachments/assets/1be85d36-3975-4413-931f-27e05e440995)](https://lobehub.com/mcp)
### MCP
通过启用与外部工具、数据源和服务的平滑、安全和动态交互,释放你的 AI 的全部潜力。基于 MCP(模型上下文协议)的插件系统打破了 AI 与数字生态系统之间的壁垒,实现了前所未有的连接性和功能性。
将对话转化为强大的工作流程,连接数据库、API、文件系统等。体验真正理解并与你的世界互动的 AI Agent。
[![][back-to-top]](#readme-top)
![][image-feat-mcp-market]
### 发现、连接、扩展
浏览不断增长的 MCP 插件库,轻松扩展你的 AI 能力并简化工作流程。访问 [lobehub.com/mcp](https://lobehub.com/mcp) 探索 MCP 市场,提供精选的集成集合,增强你的 AI 与各种工具和服务协作的能力。
从生产力工具到开发环境,发现扩展 AI 覆盖范围和效率的新方式。与社区连接,找到满足特定需求的完美插件。
[![][back-to-top]](#readme-top)
![][image-feat-desktop]
### 巅峰性能,零干扰
获得完整的 LobeHub 体验,摆脱浏览器限制 —— 轻量级、专注且随时就绪。我们的桌面应用程序为你的 AI 交互提供专用环境,确保最佳性能和最小干扰。
体验更快的响应时间、更好的资源管理和与 AI 助手的更稳定连接。桌面应用专为要求 AI 工具最佳性能的用户设计。
[![][back-to-top]](#readme-top)
![][image-feat-web-search]
### 在线知识,按需获取
通过实时联网访问,你的 AI 与世界保持同步 —— 新闻、数据、趋势等。保持信息更新,获取最新可用信息,使你的 AI 能够提供准确和最新的回复。
访问实时信息,验证事实,探索当前事件,无需离开对话。你的 AI 成为通向世界知识的门户,始终保持最新和全面。
[![][back-to-top]](#readme-top)
[![][image-feat-cot]][docs-feat-cot]
### [思维链 (CoT)][docs-feat-cot]
体验前所未有的 AI 推理过程。通过创新的思维链(CoT)可视化功能,您可以实时观察复杂问题是如何一步步被解析的。这项突破性的功能为 AI 的决策过程提供了前所未有的透明度,让您能够清晰地了解结论是如何得出的。
通过将复杂的推理过程分解为清晰的逻辑步骤,您可以更好地理解和验证 AI 的解题思路。无论您是在调试问题、学习知识,还是单纯对 AI 推理感兴趣,思维链可视化都能将抽象思维转化为一种引人入胜的互动体验。
[![][back-to-top]](#readme-top)
[![][image-feat-branch]][docs-feat-branch]
### [分支对话][docs-feat-branch]
为您带来更自然、更灵活的 AI 对话方式。通过分支对话功能,您的讨论可以像人类对话一样自然延伸。在任意消息处创建新的对话分支,让您在保留原有上下文的同时,自由探索不同的对话方向。
两种强大模式任您选择:
- **延续模式**:无缝延展当前讨论,保持宝贵的对话上下文
- **独立模式**:基于任意历史消息,开启全新话题探讨
这项突破性功能将线性对话转变为动态的树状结构,让您能够更深入地探索想法,实现更高效的互动体验。
[![][back-to-top]](#readme-top)
[![][image-feat-artifacts]][docs-feat-artifacts]
### [支持白板 (Artifacts)][docs-feat-artifacts]
体验集成于 LobeHub 的 Claude Artifacts 能力。这项革命性功能突破了 AI 人机交互的边界,让您能够实时创建和可视化各种格式的内容。
以前所未有的灵活度进行创作与可视化:
- 生成并展示动态 SVG 图形
- 实时构建与渲染交互式 HTML 页面
- 输出多种格式的专业文档
[![][back-to-top]](#readme-top)
[![][image-feat-knowledgebase]][docs-feat-knowledgebase]
### [文件上传 / 知识库][docs-feat-knowledgebase]
LobeHub 支持文件上传与知识库功能,你可以上传文件、图片、音频、视频等多种类型的文件,以及创建知识库,方便用户管理和查找文件。同时在对话中使用文件和知识库功能,实现更加丰富的对话体验。
<https://github.com/user-attachments/assets/faa8cf67-e743-4590-8bf6-ebf6ccc34175>
> \[!TIP]
>
> 查阅 [📘 LobeHub 知识库上线 —— 此刻起,跬步千里](https://lobehub.com/zh/blog/knowledge-base) 了解详情。
![](https://github.com/user-attachments/assets/5c6e16f0-7f47-4baf-9aeb-3a00deb8ff5b)
<div align="right">
@@ -278,262 +185,6 @@ LobeHub 支持文件上传与知识库功能,你可以上传文件、图片、
</div>
[![][image-feat-privoder]][docs-feat-provider]
### [多模型服务商支持][docs-feat-provider]
在 LobeHub 的不断发展过程中,我们深刻理解到在提供 AI 会话服务时模型服务商的多样性对于满足社区需求的重要性。因此,我们不再局限于单一的模型服务商,而是拓展了对多种模型服务商的支持,以便为用户提供更为丰富和多样化的会话选择。
通过这种方式,LobeHub 能够更灵活地适应不同用户的需求,同时也为开发者提供了更为广泛的选择空间。
#### 已支持的模型服务商
我们已经实现了对以下模型服务商的支持:
<!-- PROVIDER LIST -->
<details><summary><kbd>See more providers (+-10)</kbd></summary>
</details>
> 📊 Total providers: [<kbd>**0**</kbd>](https://lobechat.com/discover/providers)
<!-- PROVIDER LIST -->
同时,我们也在计划支持更多的模型服务商,以进一步丰富我们的服务商库。如果你希望让 LobeHub 支持你喜爱的服务商,欢迎加入我们的 [💬 社区讨论](https://github.com/lobehub/lobehub/discussions/6157)。
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-local]][docs-feat-local]
### [支持本地大语言模型 (LLM)][docs-feat-local]
为了满足特定用户的需求,LobeHub 还基于 [Ollama](https://ollama.ai) 支持了本地模型的使用,让用户能够更灵活地使用自己的或第三方的模型。
> \[!TIP]
>
> 查阅 [📘 在 LobeHub 中使用 Ollama][docs-usage-ollama] 获得更多信息
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-vision]][docs-feat-vision]
### [模型视觉识别 (Model Visual)][docs-feat-vision]
LobeHub 已经支持 OpenAI 最新的 [`gpt-4-vision`](https://platform.openai.com/docs/guides/vision) 支持视觉识别的模型,这是一个具备视觉识别能力的多模态应用。
用户可以轻松上传图片或者拖拽图片到对话框中,助手将能够识别图片内容,并在此基础上进行智能对话,构建更智能、更多元化的聊天场景。
这一特性打开了新的互动方式,使得交流不再局限于文字,而是可以涵盖丰富的视觉元素。无论是日常使用中的图片分享,还是在特定行业内的图像解读,助手都能提供出色的对话体验。
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-tts]][docs-feat-tts]
### [TTS & STT 语音会话][docs-feat-tts]
LobeHub 支持文字转语音(Text-to-SpeechTTS)和语音转文字(Speech-to-Text,STT)技术,这使得我们的应用能够将文本信息转化为清晰的语音输出,用户可以像与真人交谈一样与我们的对话助手进行交流。
用户可以从多种声音中选择,给助手搭配合适的音源。 同时,对于那些倾向于听觉学习或者想要在忙碌中获取信息的用户来说,TTS 提供了一个极佳的解决方案。
在 LobeHub 中,我们精心挑选了一系列高品质的声音选项 (OpenAI Audio, Microsoft Edge Speech),以满足不同地域和文化背景用户的需求。用户可以根据个人喜好或者特定场景来选择合适的语音,从而获得个性化的交流体验。
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-t2i]][docs-feat-t2i]
### [Text to Image 文生图][docs-feat-t2i]
支持最新的文本到图片生成技术,LobeHub 现在能够让用户在与助手对话中直接调用文生图工具进行创作。
通过利用 [`DALL-E 3`](https://openai.com/dall-e-3)、[`MidJourney`](https://www.midjourney.com/) 和 [`Pollinations`](https://pollinations.ai/) 等 AI 工具的能力, 助手们现在可以将你的想法转化为图像。
同时可以更私密和沉浸式地完成你的创作过程。
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-plugin]][docs-feat-plugin]
### [插件系统 (Tools Calling)][docs-feat-plugin]
LobeHub 的插件生态系统是其核心功能的重要扩展,它极大地增强了 ChatGPT 的实用性和灵活性。
<video controls src="https://github.com/lobehub/lobehub/assets/28616219/f29475a3-f346-4196-a435-41a6373ab9e2" muted="false"></video>
通过利用插件,ChatGPT 能够实现实时信息的获取和处理,例如自动获取最新新闻头条,为用户提供即时且相关的资讯。
此外,这些插件不仅局限于新闻聚合,还可以扩展到其他实用的功能,如快速检索文档、生成图象、获取电商平台数据,以及其他各式各样的第三方服务。
> 通过文档了解更多 [📘 插件使用][docs-usage-plugin]
<!-- PLUGIN LIST -->
| 最近新增 | 描述 |
| -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| [购物工具](https://lobechat.com/discover/plugin/ShoppingTools)<br/><sup>By **shoppingtools** on **2026-01-12**</sup> | 在 eBay 和 AliExpress 上搜索产品,查找 eBay 活动和优惠券。获取快速示例。<br/>`购物` `e-bay` `ali-express` `优惠券` |
| [SEO 助手](https://lobechat.com/discover/plugin/seo_assistant)<br/><sup>By **webfx** on **2026-01-12**</sup> | SEO 助手可以生成搜索引擎关键词信息,以帮助创建内容。<br/>`seo` `关键词` |
| [视频字幕](https://lobechat.com/discover/plugin/VideoCaptions)<br/><sup>By **maila** on **2025-12-13**</sup> | 将 Youtube 链接转换为转录文本,使其能够提问,创建章节,并总结其内容。<br/>`视频转文字` `you-tube` |
| [天气 GPT](https://lobechat.com/discover/plugin/WeatherGPT)<br/><sup>By **steven-tey** on **2025-12-13**</sup> | 获取特定位置的当前天气信息。<br/>`天气` |
> 📊 Total plugins: [<kbd>**40**</kbd>](https://lobechat.com/discover/plugins)
<!-- PLUGIN LIST -->
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-agent]][docs-feat-agent]
### [助手市场 (GPTs)][docs-feat-agent]
在 LobeHub 的助手市场中,创作者们可以发现一个充满活力和创新的社区,它汇聚了众多精心设计的助手,这些助手不仅在工作场景中发挥着重要作用,也在学习过程中提供了极大的便利。
我们的市场不仅是一个展示平台,更是一个协作的空间。在这里,每个人都可以贡献自己的智慧,分享个人开发的助手。
> \[!TIP]
>
> 通过 [🤖/🏪 提交助手][submit-agents-link] ,你可以轻松地将你的助手作品提交到我们的平台。我们特别强调的是,LobeHub 建立了一套精密的自动化国际化(i18n)工作流程, 它的强大之处在于能够无缝地将你的助手转化为多种语言版本。
> 这意味着,不论你的用户使用何种语言,他们都能无障碍地体验到你的助手。
> \[!IMPORTANT]
>
> 我欢迎所有用户加入这个不断成长的生态系统,共同参与到助手的迭代与优化中来。共同创造出更多有趣、实用且具有创新性的助手,进一步丰富助手的多样性和实用性。
<!-- AGENT LIST -->
| 最近新增 | 描述 |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| [海龟汤主持人](https://lobechat.com/discover/assistant/lateral-thinking-puzzle)<br/><sup>By **[CSY2022](https://github.com/CSY2022)** on **2025-06-19**</sup> | 一个海龟汤主持人,需要自己提供汤面,汤底与关键点(猜中的判定条件)。<br/>`海龟汤` `推理` `互动` `谜题` `角色扮演` |
| [学术写作助手](https://lobechat.com/discover/assistant/academic-writing-assistant)<br/><sup>By **[swarfte](https://github.com/swarfte)** on **2025-06-17**</sup> | 专业的学术研究论文写作和正式文档编写专家<br/>`学术写作` `研究` `正式风格` |
| [美食评论员🍟](https://lobechat.com/discover/assistant/food-reviewer)<br/><sup>By **[renhai-lab](https://github.com/renhai-lab)** on **2025-06-17**</sup> | 美食评价专家<br/>`美食` `评价` `写作` |
| [Minecraft 资深开发者](https://lobechat.com/discover/assistant/java-development)<br/><sup>By **[iamyuuk](https://github.com/iamyuuk)** on **2025-06-17**</sup> | 擅长高级 Java 开发及 Minecraft 开发<br/>`开发` `编程` `minecraft` `java` |
> 📊 Total agents: [<kbd>**505**</kbd> ](https://lobechat.com/discover/assistants)
<!-- AGENT LIST -->
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-database]][docs-feat-database]
### [支持本地 / 远程数据库][docs-feat-database]
LobeHub 支持同时使用服务端数据库和本地数据库。根据您的需求,您可以选择合适的部署方案:
- 本地数据库:适合希望对数据有更多掌控感和隐私保护的用户。LobeHub 采用了 CRDT (Conflict-Free Replicated Data Type) 技术,实现了多端同步功能。这是一项实验性功能,旨在提供无缝的数据同步体验。
- 服务端数据库:适合希望更便捷使用体验的用户。LobeHub 支持 PostgreSQL 作为服务端数据库。关于如何配置服务端数据库的详细文档,请前往 [配置服务端数据库](https://lobehub.com/zh/docs/self-hosting/advanced/server-database)。
无论您选择哪种数据库,LobeHub 都能为您提供卓越的用户体验。
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-auth]][docs-feat-auth]
### [支持多用户管理][docs-feat-auth]
LobeHub 支持多用户管理,提供了灵活的用户认证方案:
- **Better Auth**LobeHub 集成了 `Better Auth`,一个现代化且灵活的身份验证库,支持多种身份验证方式,包括 OAuth、邮件登录、凭证登录、魔法链接等。通过 `Better Auth`,您可以轻松实现用户的注册、登录、会话管理、社交登录、多因素认证 (MFA) 等功能,确保用户数据的安全性和隐私性。
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-pwa]][docs-feat-pwa]
### [渐进式 Web 应用 (PWA)][docs-feat-pwa]
我们深知在当今多设备环境下为用户提供无缝体验的重要性。为此,我们采用了渐进式 Web 应用 [PWA](https://support.google.com/chrome/answer/9658361) 技术,
这是一种能够将网页应用提升至接近原生应用体验的现代 Web 技术。通过 PWA,LobeHub 能够在桌面和移动设备上提供高度优化的用户体验,同时保持轻量级和高性能的特点。
在视觉和感觉上,我们也经过精心设计,以确保它的界面与原生应用无差别,提供流畅的动画、响应式布局和适配不同设备的屏幕分辨率。
> \[!NOTE]
>
> 若您未熟悉 PWA 的安装过程,您可以按照以下步骤将 LobeHub 添加为您的桌面应用(也适用于移动设备):
>
> - 在电脑上运行 Chrome 或 Edge 浏览器 .
> - 访问 LobeHub 网页 .
> - 在地址栏的右上角,单击 <kbd>安装</kbd> 图标 .
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-mobile]][docs-feat-mobile]
### [移动设备适配][docs-feat-mobile]
针对移动设备进行了一系列的优化设计,以提升用户的移动体验。目前,我们正在对移动端的用户体验进行版本迭代,以实现更加流畅和直观的交互。如果您有任何建议或想法,我们非常欢迎您通过 GitHub Issues 或者 Pull Requests 提供反馈。
<div align="right">
[![][back-to-top]](#readme-top)
</div>
[![][image-feat-theme]][docs-feat-theme]
### [自定义主题][docs-feat-theme]
作为设计工程师出身,LobeHub 在界面设计上充分考虑用户的个性化体验,因此引入了灵活多变的主题模式,其中包括日间的亮色模式和夜间的深色模式。
除了主题模式的切换,还提供了一系列的颜色定制选项,允许用户根据自己的喜好来调整应用的主题色彩。无论是想要沉稳的深蓝,还是希望活泼的桃粉,或者是专业的灰白,用户都能够在 LobeHub 中找到匹配自己风格的颜色选择。
> \[!TIP]
>
> 默认配置能够智能地识别用户系统的颜色模式,自动进行主题切换,以确保应用界面与操作系统保持一致的视觉体验。对于喜欢手动调控细节的用户,LobeHub 同样提供了直观的设置选项,针对聊天场景也提供了对话气泡模式和文档模式的选择。
<div align="right">
<div align="right">
[![][back-to-top]](#readme-top)
</div>
</div>
### `*` 更多特性
除了上述功能特性以外,LobeHub 所具有的设计和技术能力将为你带来更多使用保障:
- [x] 💎 **精致 UI 设计**:经过精心设计的界面,具有优雅的外观和流畅的交互效果,支持亮暗色主题,适配移动端。支持 PWA,提供更加接近原生应用的体验。
- [x] 🗣️ **流畅的对话体验**:流式响应带来流畅的对话体验,并且支持完整的 Markdown 渲染,包括代码高亮、LaTex 公式、Mermaid 流程图等。
- [x] 💨 **快速部署**:使用 Vercel 平台或者我们的 Docker 镜像,只需点击一键部署按钮,即可在 1 分钟内完成部署,无需复杂的配置过程。
- [x] 🔒 **隐私安全**:所有数据保存在用户浏览器本地,保证用户的隐私安全。
- [x] 🌐 **自定义域名**:如果用户拥有自己的域名,可以将其绑定到平台上,方便在任何地方快速访问对话助手。
</details>
> ✨ 随着产品迭代持续更新,我们将会带来更多更多令人激动的功能!
<div align="right">
@@ -867,28 +518,10 @@ This project is [LobeHub Community License](./LICENSE) licensed.
[docs-dev-guide]: https://lobehub.com/docs/development/start
[docs-docker]: https://lobehub.com/zh/docs/self-hosting/server-database/docker-compose
[docs-env-var]: https://lobehub.com/docs/self-hosting/environment-variables
[docs-feat-agent]: https://lobehub.com/docs/usage/features/agent-market
[docs-feat-artifacts]: https://lobehub.com/docs/usage/features/artifacts
[docs-feat-auth]: https://lobehub.com/docs/usage/features/auth
[docs-feat-branch]: https://lobehub.com/docs/usage/features/branching-conversations
[docs-feat-cot]: https://lobehub.com/docs/usage/features/cot
[docs-feat-database]: https://lobehub.com/docs/usage/features/database
[docs-feat-knowledgebase]: https://lobehub.com/blog/knowledge-base
[docs-feat-local]: https://lobehub.com/docs/usage/features/local-llm
[docs-feat-mobile]: https://lobehub.com/docs/usage/features/mobile
[docs-feat-plugin]: https://lobehub.com/docs/usage/features/plugin-system
[docs-feat-provider]: https://lobehub.com/docs/usage/features/multi-ai-providers
[docs-feat-pwa]: https://lobehub.com/docs/usage/features/pwa
[docs-feat-t2i]: https://lobehub.com/docs/usage/features/text-to-image
[docs-feat-theme]: https://lobehub.com/docs/usage/features/theme
[docs-feat-tts]: https://lobehub.com/docs/usage/features/tts
[docs-feat-vision]: https://lobehub.com/docs/usage/features/vision
[docs-function-call]: https://lobehub.com/zh/blog/openai-function-call
[docs-plugin-dev]: https://lobehub.com/docs/usage/plugins/development
[docs-self-hosting]: https://lobehub.com/docs/self-hosting/start
[docs-upstream-sync]: https://lobehub.com/docs/self-hosting/advanced/upstream-sync
[docs-usage-ollama]: https://lobehub.com/docs/usage/providers/ollama
[docs-usage-plugin]: https://lobehub.com/docs/usage/plugins/basic
[fossa-license-link]: https://app.fossa.com/projects/git%2Bgithub.com%2Flobehub%2Flobehub
[fossa-license-shield]: https://app.fossa.com/api/projects/git%2Bgithub.com%2Flobehub%2Flobehub.svg?type=large
[github-action-release-link]: https://github.com/lobehub/lobehub/actions/workflows/release.yml
@@ -911,29 +544,8 @@ This project is [LobeHub Community License](./LICENSE) licensed.
[github-releasedate-link]: https://github.com/lobehub/lobehub/releases
[github-releasedate-shield]: https://img.shields.io/github/release-date/lobehub/lobehub?labelColor=black&style=flat-square
[github-stars-link]: https://github.com/lobehub/lobehub/stargazers
[github-stars-shield]: https://github.com/user-attachments/assets/3216e25b-186f-4a54-9cb4-2f124aec0471
[github-trending-shield]: https://trendshift.io/api/badge/repositories/2256
[github-trending-url]: https://trendshift.io/repositories/2256
[image-banner]: https://github.com/user-attachments/assets/0fe626a3-0ddc-4f67-b595-3c5b3f1701e0
[image-feat-agent]: https://github.com/user-attachments/assets/b3ab6e35-4fbc-468d-af10-e3e0c687350f
[image-feat-artifacts]: https://github.com/user-attachments/assets/7f95fad6-b210-4e6e-84a0-7f39e96f3a00
[image-feat-auth]: https://github.com/user-attachments/assets/80bb232e-19d1-4f97-98d6-e291f3585e6d
[image-feat-branch]: https://github.com/user-attachments/assets/92f72082-02bd-4835-9c54-b089aad7fd41
[image-feat-cot]: https://github.com/user-attachments/assets/f74f1139-d115-4e9c-8c43-040a53797a5e
[image-feat-database]: https://github.com/user-attachments/assets/f1697c8b-d1fb-4dac-ba05-153c6295d91d
[image-feat-desktop]: https://github.com/user-attachments/assets/a7bac8d3-ea96-4000-bb39-fadc9b610f96
[image-feat-knowledgebase]: https://github.com/user-attachments/assets/7da7a3b2-92fd-4630-9f4e-8560c74955ae
[image-feat-local]: https://github.com/user-attachments/assets/1239da50-d832-4632-a7ef-bd754c0f3850
[image-feat-mcp-market]: https://github.com/user-attachments/assets/bb114f9f-24c5-4000-a984-c10d187da5a0
[image-feat-mobile]: https://github.com/user-attachments/assets/32cf43c4-96bd-4a4c-bfb6-59acde6fe380
[image-feat-plugin]: https://github.com/user-attachments/assets/66a891ac-01b6-4e3f-b978-2eb07b489b1b
[image-feat-privoder]: https://github.com/user-attachments/assets/e553e407-42de-4919-977d-7dbfcf44a821
[image-feat-pwa]: https://github.com/user-attachments/assets/9647f70f-b71b-43b6-9564-7cdd12d1c24d
[image-feat-t2i]: https://github.com/user-attachments/assets/708274a7-2458-494b-a6ec-b73dfa1fa7c2
[image-feat-theme]: https://github.com/user-attachments/assets/b47c39f1-806f-492b-8fcb-b0fa973937c1
[image-feat-tts]: https://github.com/user-attachments/assets/50189597-2cc3-4002-b4c8-756a52ad5c0a
[image-feat-vision]: https://github.com/user-attachments/assets/18574a1f-46c2-4cbc-af2c-35a86e128a07
[image-feat-web-search]: https://github.com/user-attachments/assets/cfdc48ac-b5f8-4a00-acee-db8f2eba09ad
[github-stars-shield]: https://img.shields.io/github/stars/lobehub/lobehub?color=ffcb47&labelColor=black&style=flat-square
[image-banner]: https://github.com/user-attachments/assets/5f78ae58-ed4f-4d38-8037-96109fbba58c
[image-star]: https://github.com/user-attachments/assets/c3b482e7-cef5-4e94-bef9-226900ecfaab
[issues-link]: https://img.shields.io/github/issues/lobehub/lobehub.svg?style=flat
[lobe-chat-plugins]: https://github.com/lobehub/lobe-chat-plugins
@@ -969,8 +581,6 @@ This project is [LobeHub Community License](./LICENSE) licensed.
[share-whatsapp-shield]: https://img.shields.io/badge/-share%20on%20whatsapp-black?labelColor=black&logo=whatsapp&logoColor=white&style=flat-square
[share-x-link]: https://x.com/intent/tweet?hashtags=chatbot%2CchatGPT%2CopenAI&text=%E6%8E%A8%E8%8D%90%E4%B8%80%E4%B8%AA%20GitHub%20%E5%BC%80%E6%BA%90%E9%A1%B9%E7%9B%AE%20%F0%9F%A4%AF%20LobeHub%20-%20%E5%BC%80%E6%BA%90%E7%9A%84%E3%80%81%E5%8F%AF%E6%89%A9%E5%B1%95%E7%9A%84%EF%BC%88Function%20Calling%EF%BC%89%E9%AB%98%E6%80%A7%E8%83%BD%E8%81%8A%E5%A4%A9%E6%9C%BA%E5%99%A8%E4%BA%BA%E6%A1%86%E6%9E%B6%E3%80%82%0A%E5%AE%83%E6%94%AF%E6%8C%81%E4%B8%80%E9%94%AE%E5%85%8D%E8%B4%B9%E9%83%A8%E7%BD%B2%E7%A7%81%E4%BA%BA%20ChatGPT%2FLLM%20%E7%BD%91%E9%A1%B5%E5%BA%94%E7%94%A8%E7%A8%8B%E5%BA%8F&url=https%3A%2F%2Fgithub.com%2Flobehub%2Flobehub
[share-x-shield]: https://img.shields.io/badge/-share%20on%20x-black?labelColor=black&logo=x&logoColor=white&style=flat-square
[sponsor-link]: https://opencollective.com/lobehub 'Become ❤ LobeHub Sponsor'
[sponsor-shield]: https://img.shields.io/badge/-Sponsor%20LobeHub-f04f88?logo=opencollective&logoColor=white&style=flat-square
[submit-agents-link]: https://github.com/lobehub/lobe-chat-agents
[submit-agents-shield]: https://img.shields.io/badge/🤖/🏪_submit_agent-%E2%86%92-c4f042?labelColor=black&style=for-the-badge
[submit-plugin-link]: https://github.com/lobehub/lobe-chat-plugins
+1 -1
View File
@@ -1,6 +1,6 @@
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
.\" Manual command details come from the Commander command tree.
.TH LH 1 "" "@lobehub/cli 0.0.15" "User Commands"
.TH LH 1 "" "@lobehub/cli 0.0.20" "User Commands"
.SH NAME
lh \- LobeHub CLI \- manage and connect to LobeHub services
.SH SYNOPSIS
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lobehub/cli",
"version": "0.0.15",
"version": "0.0.20",
"type": "module",
"bin": {
"lh": "./dist/index.js",
+5 -1
View File
@@ -6,6 +6,7 @@ import { getTrpcClient } from '../api/client';
import { confirm, outputJson, printBoxTable, printTable, timeAgo } from '../utils/format';
import { log } from '../utils/logger';
import { registerBotMessageCommands } from './botMessage';
import { registerBotMessengersCommands } from './botMessengers';
// ── Access policy helpers ──────────────────────────────
@@ -475,6 +476,9 @@ export function registerBotCommand(program: Command) {
// Register message subcommand group
registerBotMessageCommands(bot);
// Register messengers subcommand group (System Bot installations + account links)
registerBotMessengersCommands(bot);
// ── platforms ───────────────────────────────────────────
bot
@@ -806,7 +810,7 @@ export function registerBotCommand(program: Command) {
name: 'group-allowlist',
});
// ── watch-keywords (LOBE-8891) ────────────────────────
// ── watch-keywords () ────────────────────────
registerWatchKeywordsCommand(bot);
+417
View File
@@ -0,0 +1,417 @@
import { mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { registerBotMessageCommands } from './botMessage';
const { mockTrpcClient } = vi.hoisted(() => ({
mockTrpcClient: {
botMessage: {
replyToThread: { mutate: vi.fn() },
sendDirectMessage: { mutate: vi.fn() },
sendMessage: { mutate: vi.fn() },
},
},
}));
const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({
getTrpcClient: vi.fn(),
}));
vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient }));
vi.mock('../utils/logger', () => ({
log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() },
setVerbose: vi.fn(),
}));
describe('bot message send --attachment', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
mockTrpcClient.botMessage.sendMessage.mutate.mockReset();
mockTrpcClient.botMessage.sendMessage.mutate.mockResolvedValue({ messageId: 'm-1' });
mockTrpcClient.botMessage.sendDirectMessage.mutate.mockReset();
mockTrpcClient.botMessage.sendDirectMessage.mutate.mockResolvedValue({
channelId: 'dm-1',
messageId: 'm-dm-1',
});
mockTrpcClient.botMessage.replyToThread.mutate.mockReset();
mockTrpcClient.botMessage.replyToThread.mutate.mockResolvedValue({ messageId: 'm-tr-1' });
});
afterEach(() => {
exitSpy.mockRestore();
consoleSpy.mockRestore();
});
function createProgram() {
const program = new Command();
program.exitOverride();
const bot = program.command('bot');
registerBotMessageCommands(bot);
return program;
}
it('passes a remote URL through as fetchUrl', async () => {
const program = createProgram();
await program.parseAsync([
'node',
'test',
'bot',
'message',
'send',
'bot-1',
'--target',
'ch-1',
'--message',
'hi',
'--attachment',
'https://cdn.example.com/foo.png',
]);
expect(mockTrpcClient.botMessage.sendMessage.mutate).toHaveBeenCalledWith(
expect.objectContaining({
attachments: [
expect.objectContaining({
fetchUrl: 'https://cdn.example.com/foo.png',
mimeType: 'image/png',
name: 'foo.png',
type: 'image',
}),
],
botId: 'bot-1',
channelId: 'ch-1',
content: 'hi',
}),
);
});
it('base64-encodes a local file path', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'lh-cli-attach-'));
const filePath = path.join(dir, 'tiny.txt');
await writeFile(filePath, 'hello');
const program = createProgram();
await program.parseAsync([
'node',
'test',
'bot',
'message',
'send',
'bot-1',
'--target',
'ch-1',
'--message',
'm',
'--attachment',
filePath,
]);
const call = mockTrpcClient.botMessage.sendMessage.mutate.mock.calls[0][0];
expect(call.attachments).toHaveLength(1);
expect(call.attachments[0]).toMatchObject({
mimeType: 'text/plain',
name: 'tiny.txt',
type: 'file',
});
expect(call.attachments[0].data).toBe(Buffer.from('hello').toString('base64'));
expect(call.attachments[0].fetchUrl).toBeUndefined();
});
it('accepts multiple --attachment flags', async () => {
const program = createProgram();
await program.parseAsync([
'node',
'test',
'bot',
'message',
'send',
'bot-1',
'--target',
'ch-1',
'--message',
'm',
'--attachment',
'https://cdn.example.com/a.png',
'--attachment',
'https://cdn.example.com/b.pdf',
]);
const call = mockTrpcClient.botMessage.sendMessage.mutate.mock.calls[0][0];
expect(call.attachments).toHaveLength(2);
expect(call.attachments[0]).toMatchObject({ type: 'image', name: 'a.png' });
expect(call.attachments[1]).toMatchObject({ type: 'file', name: 'b.pdf' });
});
it('omits attachments field when no flag is given', async () => {
const program = createProgram();
await program.parseAsync([
'node',
'test',
'bot',
'message',
'send',
'bot-1',
'--target',
'ch-1',
'--message',
'm',
]);
const call = mockTrpcClient.botMessage.sendMessage.mutate.mock.calls[0][0];
expect(call.attachments).toBeUndefined();
});
});
describe('bot message dm --attachment', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
mockTrpcClient.botMessage.sendDirectMessage.mutate.mockReset();
mockTrpcClient.botMessage.sendDirectMessage.mutate.mockResolvedValue({
channelId: 'dm-1',
messageId: 'm-dm-1',
});
});
afterEach(() => {
exitSpy.mockRestore();
consoleSpy.mockRestore();
});
function createProgram() {
const program = new Command();
program.exitOverride();
const bot = program.command('bot');
registerBotMessageCommands(bot);
return program;
}
it('sends a DM with a remote-URL attachment', async () => {
const program = createProgram();
await program.parseAsync([
'node',
'test',
'bot',
'message',
'dm',
'bot-1',
'--user-id',
'u-1',
'--message',
'hi',
'--attachment',
'https://cdn.example.com/foo.png',
]);
expect(mockTrpcClient.botMessage.sendDirectMessage.mutate).toHaveBeenCalledWith(
expect.objectContaining({
attachments: [
expect.objectContaining({
fetchUrl: 'https://cdn.example.com/foo.png',
type: 'image',
}),
],
botId: 'bot-1',
content: 'hi',
userId: 'u-1',
}),
);
});
it('omits attachments when no flag is given', async () => {
const program = createProgram();
await program.parseAsync([
'node',
'test',
'bot',
'message',
'dm',
'bot-1',
'--user-id',
'u-1',
'--message',
'plain',
]);
const call = mockTrpcClient.botMessage.sendDirectMessage.mutate.mock.calls[0][0];
expect(call.attachments).toBeUndefined();
});
});
describe('bot message thread reply --attachment', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
mockTrpcClient.botMessage.replyToThread.mutate.mockReset();
mockTrpcClient.botMessage.replyToThread.mutate.mockResolvedValue({ messageId: 'm-tr-1' });
});
afterEach(() => {
exitSpy.mockRestore();
consoleSpy.mockRestore();
});
function createProgram() {
const program = new Command();
program.exitOverride();
const bot = program.command('bot');
registerBotMessageCommands(bot);
return program;
}
it('replies to a thread with attachments', async () => {
const program = createProgram();
await program.parseAsync([
'node',
'test',
'bot',
'message',
'thread',
'reply',
'bot-1',
'--thread-id',
'th-1',
'--message',
'reply',
'--attachment',
'https://cdn.example.com/a.png',
]);
expect(mockTrpcClient.botMessage.replyToThread.mutate).toHaveBeenCalledWith(
expect.objectContaining({
attachments: [
expect.objectContaining({
fetchUrl: 'https://cdn.example.com/a.png',
type: 'image',
}),
],
botId: 'bot-1',
content: 'reply',
threadId: 'th-1',
}),
);
});
});
describe('bot message send via System Bot messenger install (@id)', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
mockTrpcClient.botMessage.sendMessage.mutate.mockReset();
mockTrpcClient.botMessage.sendMessage.mutate.mockResolvedValue({ messageId: 'm-mi-1' });
mockTrpcClient.botMessage.sendDirectMessage.mutate.mockReset();
mockTrpcClient.botMessage.sendDirectMessage.mutate.mockResolvedValue({ messageId: 'm-mi-2' });
mockTrpcClient.botMessage.replyToThread.mutate.mockReset();
mockTrpcClient.botMessage.replyToThread.mutate.mockResolvedValue({ messageId: 'm-mi-3' });
});
afterEach(() => {
exitSpy.mockRestore();
consoleSpy.mockRestore();
});
function createProgram() {
const program = new Command();
program.exitOverride();
const bot = program.command('bot');
registerBotMessageCommands(bot);
return program;
}
it('@-prefixed positional arg routes to messengerInstallationId on send', async () => {
const program = createProgram();
await program.parseAsync([
'node',
'test',
'bot',
'message',
'send',
'@inst_abc',
'--target',
'C1',
'--message',
'hi',
]);
const call = mockTrpcClient.botMessage.sendMessage.mutate.mock.calls[0][0];
expect(call.messengerInstallationId).toBe('inst_abc');
expect(call.botId).toBeUndefined();
expect(call.channelId).toBe('C1');
});
it('@-prefixed routes on dm', async () => {
const program = createProgram();
await program.parseAsync([
'node',
'test',
'bot',
'message',
'dm',
'@inst_xyz',
'--user-id',
'U1',
'--message',
'hi',
]);
const call = mockTrpcClient.botMessage.sendDirectMessage.mutate.mock.calls[0][0];
expect(call.messengerInstallationId).toBe('inst_xyz');
expect(call.botId).toBeUndefined();
});
it('@-prefixed routes on thread reply', async () => {
const program = createProgram();
await program.parseAsync([
'node',
'test',
'bot',
'message',
'thread',
'reply',
'@inst_thr',
'--thread-id',
'T1',
'--message',
'r',
]);
const call = mockTrpcClient.botMessage.replyToThread.mutate.mock.calls[0][0];
expect(call.messengerInstallationId).toBe('inst_thr');
});
it('plain (non-@) positional stays as botId', async () => {
const program = createProgram();
await program.parseAsync([
'node',
'test',
'bot',
'message',
'send',
'uuid-bot-id',
'--target',
'C1',
'--message',
'hi',
]);
const call = mockTrpcClient.botMessage.sendMessage.mutate.mock.calls[0][0];
expect(call.botId).toBe('uuid-bot-id');
expect(call.messengerInstallationId).toBeUndefined();
});
});
+216 -18
View File
@@ -1,3 +1,6 @@
import { readFile } from 'node:fs/promises';
import { basename, extname } from 'node:path';
import { DEFAULT_BOT_HISTORY_LIMIT } from '@lobechat/const';
import type { Command } from 'commander';
import pc from 'picocolors';
@@ -6,6 +9,111 @@ import { getTrpcClient } from '../api/client';
import { confirm, outputJson, printTable, truncate } from '../utils/format';
import { log } from '../utils/logger';
type AttachmentInput = {
data?: string;
fetchUrl?: string;
mimeType?: string;
name?: string;
type: 'image' | 'file' | 'video' | 'audio';
};
const MIME_EXT_MAP: Record<string, string> = {
'.bmp': 'image/bmp',
'.gif': 'image/gif',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.m4a': 'audio/mp4',
'.mp3': 'audio/mpeg',
'.mp4': 'video/mp4',
'.ogg': 'audio/ogg',
'.pdf': 'application/pdf',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.txt': 'text/plain',
'.wav': 'audio/wav',
'.webm': 'video/webm',
'.webp': 'image/webp',
};
const inferMime = (path: string): string | undefined => MIME_EXT_MAP[extname(path).toLowerCase()];
const inferAttachmentType = (mimeType?: string): AttachmentInput['type'] => {
if (!mimeType) return 'file';
if (mimeType.startsWith('image/')) return 'image';
if (mimeType.startsWith('video/')) return 'video';
if (mimeType.startsWith('audio/')) return 'audio';
return 'file';
};
/**
* Resolve a list of `--attachment` flag values into `AttachmentInput[]`. Each
* entry is either a URL or a local file path. Returns `undefined` when no
* flags were passed so callers can omit the field on the wire entirely (the
* TRPC schema treats absent vs empty differently). Bails the process on
* load failures — a silently-dropped attachment would be worse than a
* loud error here.
*/
const resolveAttachmentFlags = async (flags: string[]): Promise<AttachmentInput[] | undefined> => {
if (flags.length === 0) return undefined;
const out: AttachmentInput[] = [];
for (const raw of flags) {
try {
out.push(await parseAttachmentArg(raw));
} catch (error) {
log.error(`Failed to load attachment "${raw}": ${(error as Error).message}`);
process.exit(1);
}
}
return out;
};
/**
* Parse a single `--attachment <value>` argument. Accepted forms:
* - `https://…` / `http://…` → fetchUrl, type inferred from extension
* - any other string → treated as a local file path;
* bytes are read + base64-encoded
*/
const parseAttachmentArg = async (raw: string): Promise<AttachmentInput> => {
if (/^https?:\/\//.test(raw)) {
const pathname = new URL(raw).pathname;
const mimeType = inferMime(pathname);
return {
fetchUrl: raw,
mimeType,
name: basename(pathname) || undefined,
type: inferAttachmentType(mimeType),
};
}
const bytes = await readFile(raw);
const mimeType = inferMime(raw);
return {
data: bytes.toString('base64'),
mimeType,
name: basename(raw),
type: inferAttachmentType(mimeType),
};
};
/**
* Resolve the `<botIdOrAtKey>` positional argument into a `{ botId? |
* messengerInstallationId? }` shape that matches the TRPC send procedures'
* `exactly-one-of` constraint.
*
* Convention: a value prefixed with `@` is treated as a System Bot
* messenger installation id (e.g. `@inst_abc123`); anything else is a
* per-agent bot id. The `@` was chosen because `agent_bot_providers`.id is
* always a UUID — no UUID starts with `@`, so the prefix unambiguously
* disambiguates without breaking the existing UUID-only call sites.
*/
const resolveSendTargetArg = (
value: string,
): { botId?: string; messengerInstallationId?: string } => {
if (value.startsWith('@')) {
return { messengerInstallationId: value.slice(1) };
}
return { botId: value };
};
export function registerBotMessageCommands(bot: Command) {
const message = bot
.command('message')
@@ -14,20 +122,40 @@ export function registerBotMessageCommands(bot: Command) {
// ── send ────────────────────────────────────────────────
message
.command('send <botId>')
.description('Send a message to a channel')
.command('send <botIdOrAtKey>')
.description(
'Send a message to a channel. Pass a per-agent bot id, or "@<messenger-install-id>" ' +
'to send through a System Bot messenger installation (see `lh bot messengers list`).',
)
.requiredOption('--target <channelId>', 'Target channel / conversation ID')
.requiredOption('--message <text>', 'Message content')
.option(
'--attachment <pathOrUrl>',
'Attach a file by local path or remote URL (repeatable). ' +
'Local paths are base64-encoded; http(s) URLs are passed as fetchUrl.',
collectOptions,
[],
)
.option('--reply-to <messageId>', 'Reply to a specific message')
.option('--json', 'Output JSON')
.action(
async (
botId: string,
options: { json?: boolean; message: string; replyTo?: string; target: string },
botIdOrAtKey: string,
options: {
attachment: string[];
json?: boolean;
message: string;
replyTo?: string;
target: string;
},
) => {
const attachments = await resolveAttachmentFlags(options.attachment);
const target = resolveSendTargetArg(botIdOrAtKey);
const client = await getTrpcClient();
const result = await client.botMessage.sendMessage.mutate({
botId,
...target,
attachments,
channelId: options.target,
content: options.message,
replyTo: options.replyTo,
@@ -39,8 +167,56 @@ export function registerBotMessageCommands(bot: Command) {
}
const r = result as any;
const suffix = attachments?.length ? ` with ${attachments.length} attachment(s)` : '';
console.log(
`${pc.green('✓')} Message sent${r.messageId ? ` (${pc.dim(r.messageId)})` : ''}`,
`${pc.green('✓')} Message sent${r.messageId ? ` (${pc.dim(r.messageId)})` : ''}${suffix}`,
);
},
);
// ── dm (direct message) ─────────────────────────────────
message
.command('dm <botIdOrAtKey>')
.description(
'Send a direct message to a platform user. Pass a per-agent bot id, or ' +
'"@<messenger-install-id>" for a System Bot install.',
)
.requiredOption('--user-id <id>', 'Target user ID on the platform')
.requiredOption('--message <text>', 'Message content')
.option(
'--attachment <pathOrUrl>',
'Attach a file by local path or remote URL (repeatable). ' +
'Local paths are base64-encoded; http(s) URLs are passed as fetchUrl.',
collectOptions,
[],
)
.option('--json', 'Output JSON')
.action(
async (
botIdOrAtKey: string,
options: { attachment: string[]; json?: boolean; message: string; userId: string },
) => {
const attachments = await resolveAttachmentFlags(options.attachment);
const target = resolveSendTargetArg(botIdOrAtKey);
const client = await getTrpcClient();
const result = await client.botMessage.sendDirectMessage.mutate({
...target,
attachments,
content: options.message,
userId: options.userId,
});
if (options.json) {
outputJson(result);
return;
}
const r = result as any;
const suffix = attachments?.length ? ` with ${attachments.length} attachment(s)` : '';
console.log(
`${pc.green('✓')} DM sent${r.messageId ? ` (${pc.dim(r.messageId)})` : ''}${suffix}`,
);
},
);
@@ -450,21 +626,43 @@ export function registerBotMessageCommands(bot: Command) {
});
thread
.command('reply <botId>')
.description('Reply to a thread')
.command('reply <botIdOrAtKey>')
.description(
'Reply to a thread. Pass a per-agent bot id, or "@<messenger-install-id>" ' +
'for a System Bot install.',
)
.requiredOption('--thread-id <id>', 'Thread ID')
.requiredOption('--message <text>', 'Reply content')
.action(async (botId: string, options: { message: string; threadId: string }) => {
const client = await getTrpcClient();
const result = await client.botMessage.replyToThread.mutate({
botId,
content: options.message,
threadId: options.threadId,
});
.option(
'--attachment <pathOrUrl>',
'Attach a file by local path or remote URL (repeatable). ' +
'Local paths are base64-encoded; http(s) URLs are passed as fetchUrl.',
collectOptions,
[],
)
.action(
async (
botIdOrAtKey: string,
options: { attachment: string[]; message: string; threadId: string },
) => {
const attachments = await resolveAttachmentFlags(options.attachment);
const target = resolveSendTargetArg(botIdOrAtKey);
const r = result as any;
console.log(`${pc.green('✓')} Reply sent${r.messageId ? ` (${pc.dim(r.messageId)})` : ''}`);
});
const client = await getTrpcClient();
const result = await client.botMessage.replyToThread.mutate({
...target,
attachments,
content: options.message,
threadId: options.threadId,
});
const r = result as any;
const suffix = attachments?.length ? ` with ${attachments.length} attachment(s)` : '';
console.log(
`${pc.green('✓')} Reply sent${r.messageId ? ` (${pc.dim(r.messageId)})` : ''}${suffix}`,
);
},
);
// ── channel (subcommand group) ──────────────────────────
+365
View File
@@ -0,0 +1,365 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type * as FormatModule from '../utils/format';
import { registerBotMessengersCommands } from './botMessengers';
const { mockTrpcClient } = vi.hoisted(() => ({
mockTrpcClient: {
messenger: {
availablePlatforms: { query: vi.fn() },
getMyLink: { query: vi.fn() },
listMyInstallations: { query: vi.fn() },
listMyLinks: { query: vi.fn() },
setActiveAgent: { mutate: vi.fn() },
unlink: { mutate: vi.fn() },
uninstallInstallation: { mutate: vi.fn() },
},
},
}));
const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({
getTrpcClient: vi.fn(),
}));
vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient }));
vi.mock('../utils/logger', () => ({
log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() },
setVerbose: vi.fn(),
}));
// `confirm` always answers yes — we test uninstall/unlink under the explicit
// `--yes` flag too, but for the prompt path we want a deterministic answer.
vi.mock('../utils/format', async () => {
const actual = await vi.importActual<typeof FormatModule>('../utils/format');
return { ...actual, confirm: vi.fn(async () => true) };
});
describe('bot messengers', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
let consoleSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
for (const fn of [
mockTrpcClient.messenger.availablePlatforms.query,
mockTrpcClient.messenger.getMyLink.query,
mockTrpcClient.messenger.listMyInstallations.query,
mockTrpcClient.messenger.listMyLinks.query,
mockTrpcClient.messenger.setActiveAgent.mutate,
mockTrpcClient.messenger.unlink.mutate,
mockTrpcClient.messenger.uninstallInstallation.mutate,
]) {
fn.mockReset();
}
});
afterEach(() => {
exitSpy.mockRestore();
consoleSpy.mockRestore();
errorSpy.mockRestore();
});
function createProgram() {
const program = new Command();
program.exitOverride();
const bot = program.command('bot');
registerBotMessengersCommands(bot);
return program;
}
function renderedOutput(): string {
return consoleSpy.mock.calls.map((c) => String(c[0])).join('\n');
}
// ── installations ──────────────────────────────────────
describe('list', () => {
it('renders the installation table with SEND ARG hint', async () => {
mockTrpcClient.messenger.listMyInstallations.query.mockResolvedValueOnce([
{
applicationId: 'A1',
id: 'inst_abc',
installedAt: '2026-01-15T00:00:00Z',
platform: 'slack',
tenantId: 'T1',
tenantName: 'Acme Corp',
},
]);
await createProgram().parseAsync(['node', 'test', 'bot', 'messengers', 'list']);
const out = renderedOutput();
expect(mockTrpcClient.messenger.listMyInstallations.query).toHaveBeenCalled();
expect(out).toContain('inst_abc');
expect(out).toContain('Acme Corp');
// The hint should explain how to use the id with the send commands
expect(out).toContain('@<INSTALLATION ID>');
});
it('reports empty state with install guidance', async () => {
mockTrpcClient.messenger.listMyInstallations.query.mockResolvedValueOnce([]);
await createProgram().parseAsync(['node', 'test', 'bot', 'messengers', 'list']);
const out = renderedOutput();
expect(out).toContain('No System Bot installations connected.');
expect(out).toContain('Settings → Messenger');
});
it('--json passes through the payload', async () => {
const payload = [{ id: 'inst_only', platform: 'discord', tenantId: 'g1' }];
mockTrpcClient.messenger.listMyInstallations.query.mockResolvedValueOnce(payload);
await createProgram().parseAsync(['node', 'test', 'bot', 'messengers', 'list', '--json']);
expect(renderedOutput()).toContain('"id": "inst_only"');
});
});
describe('view', () => {
it('prints details for a matching install', async () => {
mockTrpcClient.messenger.listMyInstallations.query.mockResolvedValueOnce([
{
applicationId: 'A1',
id: 'inst_match',
installedAt: '2026-01-15T00:00:00Z',
platform: 'slack',
scope: 'chat:write,users:read',
tenantId: 'T1',
tenantName: 'Acme Corp',
},
]);
await createProgram().parseAsync(['node', 'test', 'bot', 'messengers', 'view', 'inst_match']);
const out = renderedOutput();
expect(out).toContain('inst_match');
expect(out).toContain('slack');
expect(out).toContain('Acme Corp');
expect(out).toContain('chat:write,users:read');
});
it('exits non-zero when install missing', async () => {
mockTrpcClient.messenger.listMyInstallations.query.mockResolvedValueOnce([]);
await createProgram().parseAsync([
'node',
'test',
'bot',
'messengers',
'view',
'inst_missing',
]);
expect(errorSpy).toHaveBeenCalled();
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('--json missing install emits JSON null + exit 1 (scriptable)', async () => {
mockTrpcClient.messenger.listMyInstallations.query.mockResolvedValueOnce([]);
await createProgram().parseAsync([
'node',
'test',
'bot',
'messengers',
'view',
'inst_missing',
'--json',
]);
// No human-readable error log; the JSON-pipe consumer gets `null`.
expect(errorSpy).not.toHaveBeenCalled();
expect(consoleSpy.mock.calls.map((c) => String(c[0])).join('\n')).toContain('null');
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe('uninstall', () => {
it('--yes skips confirm and calls the mutation', async () => {
mockTrpcClient.messenger.uninstallInstallation.mutate.mockResolvedValueOnce({
success: true,
});
await createProgram().parseAsync([
'node',
'test',
'bot',
'messengers',
'uninstall',
'inst_abc',
'--yes',
]);
expect(mockTrpcClient.messenger.uninstallInstallation.mutate).toHaveBeenCalledWith({
installationId: 'inst_abc',
});
expect(renderedOutput()).toContain('revoked');
});
it('confirms before calling when --yes is omitted', async () => {
mockTrpcClient.messenger.listMyInstallations.query.mockResolvedValueOnce([
{ id: 'inst_abc', platform: 'slack', tenantId: 'T1', tenantName: 'Acme' },
]);
mockTrpcClient.messenger.uninstallInstallation.mutate.mockResolvedValueOnce({
success: true,
});
await createProgram().parseAsync([
'node',
'test',
'bot',
'messengers',
'uninstall',
'inst_abc',
]);
// Mocked confirm returns true → mutation still fires
expect(mockTrpcClient.messenger.uninstallInstallation.mutate).toHaveBeenCalled();
});
});
describe('platforms', () => {
it('renders the platforms table', async () => {
mockTrpcClient.messenger.availablePlatforms.query.mockResolvedValueOnce([
{ appId: 'A123', id: 'slack', name: 'Slack' },
{ botUsername: 'lobehub_bot', id: 'telegram', name: 'Telegram' },
]);
await createProgram().parseAsync(['node', 'test', 'bot', 'messengers', 'platforms']);
const out = renderedOutput();
expect(out).toContain('slack');
expect(out).toContain('A123');
expect(out).toContain('lobehub_bot');
});
it('handles empty platform list gracefully', async () => {
mockTrpcClient.messenger.availablePlatforms.query.mockResolvedValueOnce([]);
await createProgram().parseAsync(['node', 'test', 'bot', 'messengers', 'platforms']);
expect(renderedOutput()).toContain('No System Bot platforms');
});
});
// ── links ──────────────────────────────────────────────
describe('links list', () => {
it('renders the links table', async () => {
mockTrpcClient.messenger.listMyLinks.query.mockResolvedValueOnce([
{
activeAgentId: 'agent_1',
platform: 'slack',
platformUserId: 'U1',
platformUsername: 'alice',
tenantId: 'T1',
},
]);
await createProgram().parseAsync(['node', 'test', 'bot', 'messengers', 'links', 'list']);
const out = renderedOutput();
expect(out).toContain('agent_1');
expect(out).toContain('alice');
});
it('reports empty state', async () => {
mockTrpcClient.messenger.listMyLinks.query.mockResolvedValueOnce([]);
await createProgram().parseAsync(['node', 'test', 'bot', 'messengers', 'links', 'list']);
expect(renderedOutput()).toContain('No account links yet');
});
});
describe('links view', () => {
it('shows the link detail', async () => {
mockTrpcClient.messenger.getMyLink.query.mockResolvedValueOnce({
activeAgentId: 'agent_2',
platform: 'slack',
platformUserId: 'U2',
platformUsername: 'bob',
tenantId: 'T2',
});
await createProgram().parseAsync([
'node',
'test',
'bot',
'messengers',
'links',
'view',
'slack',
'--tenant',
'T2',
]);
expect(mockTrpcClient.messenger.getMyLink.query).toHaveBeenCalledWith({
platform: 'slack',
tenantId: 'T2',
});
expect(renderedOutput()).toContain('agent_2');
});
it('exits non-zero on missing link', async () => {
mockTrpcClient.messenger.getMyLink.query.mockResolvedValueOnce(null);
await createProgram().parseAsync([
'node',
'test',
'bot',
'messengers',
'links',
'view',
'discord',
]);
expect(errorSpy).toHaveBeenCalled();
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe('links set-agent', () => {
it('passes agentId through to setActiveAgent', async () => {
mockTrpcClient.messenger.setActiveAgent.mutate.mockResolvedValueOnce({ success: true });
await createProgram().parseAsync([
'node',
'test',
'bot',
'messengers',
'links',
'set-agent',
'slack',
'--agent',
'agent_xyz',
'--tenant',
'T1',
]);
expect(mockTrpcClient.messenger.setActiveAgent.mutate).toHaveBeenCalledWith({
agentId: 'agent_xyz',
platform: 'slack',
tenantId: 'T1',
});
});
it('clears the agent when --agent none is passed', async () => {
mockTrpcClient.messenger.setActiveAgent.mutate.mockResolvedValueOnce({ success: true });
await createProgram().parseAsync([
'node',
'test',
'bot',
'messengers',
'links',
'set-agent',
'telegram',
'--agent',
'none',
]);
expect(mockTrpcClient.messenger.setActiveAgent.mutate).toHaveBeenCalledWith({
agentId: null,
platform: 'telegram',
tenantId: undefined,
});
});
});
describe('links unlink', () => {
it('passes platform + tenant to the unlink mutation with --yes', async () => {
mockTrpcClient.messenger.unlink.mutate.mockResolvedValueOnce({ success: true });
await createProgram().parseAsync([
'node',
'test',
'bot',
'messengers',
'links',
'unlink',
'slack',
'--tenant',
'T1',
'--yes',
]);
expect(mockTrpcClient.messenger.unlink.mutate).toHaveBeenCalledWith({
platform: 'slack',
tenantId: 'T1',
});
});
});
});
+305
View File
@@ -0,0 +1,305 @@
/**
* `lh bot messengers ...` — manages the user's System Bot installations
* (Slack workspaces, Discord guilds, Telegram), distinct from per-agent bots.
*
* Mirrors `bot ...` (per-agent CRUD) and `bot message ...` (send/read), but
* operates on `messenger_installations` (workspace-scoped) and
* `messenger_account_links` (per-user routing). Subcommands talk directly to
* `lambdaClient.messenger.*` — there's no shared CLI-side service layer for
* this domain yet.
*
* **uninstall vs unlink** (recurring confusion — surface in command help):
* - `uninstall <installationId>` revokes the install for the **whole
* workspace**. Other users in that workspace can no longer use the bot.
* - `links unlink <platform>` only removes the **current user's** account
* binding. Workspace stays installed; colleagues are unaffected.
*/
import type { Command } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../api/client';
import { confirm, outputJson, printTable } from '../utils/format';
const PLATFORMS = ['telegram', 'slack', 'discord'] as const;
type MessengerPlatform = (typeof PLATFORMS)[number];
const validatePlatform = (value: string): MessengerPlatform => {
if (!(PLATFORMS as readonly string[]).includes(value)) {
throw new Error(`Unknown messenger platform: ${value}. Valid values: ${PLATFORMS.join(', ')}.`);
}
return value as MessengerPlatform;
};
export function registerBotMessengersCommands(bot: Command) {
const messengers = bot
.command('messengers')
.description(
'Manage System Bot messenger installations (Slack workspaces, Discord guilds, Telegram) ' +
'and per-user account links',
);
// ── installations ──────────────────────────────────────
messengers
.command('list')
.description('List all System Bot installations the current user has connected.')
.option('--json', 'Output JSON')
.action(async (options: { json?: boolean }) => {
const client = await getTrpcClient();
const installations = await client.messenger.listMyInstallations.query();
if (options.json) {
outputJson(installations);
return;
}
if (installations.length === 0) {
console.log('No System Bot installations connected.');
console.log(
`\nRun ${pc.dim('lh bot messengers platforms')} to see what's available, then install via ` +
`${pc.dim('Settings → Messenger')} (OAuth requires a browser).`,
);
return;
}
const rows = installations.map((i: any) => [
i.id || '',
i.platform || '',
i.tenantName || i.tenantId || '(global)',
i.applicationId || '',
i.installedAt ? new Date(i.installedAt).toISOString().slice(0, 10) : '',
]);
printTable(rows, ['INSTALLATION ID', 'PLATFORM', 'TENANT', 'APP ID', 'INSTALLED']);
console.log(
`\nUse ${pc.dim('@<INSTALLATION ID>')} as the positional argument on ` +
`${pc.dim('lh bot message send/dm/thread reply')} to route through a System Bot install.`,
);
});
messengers
.command('view <installationId>')
.description('Show detail for one installation.')
.option('--json', 'Output JSON')
.action(async (installationId: string, options: { json?: boolean }) => {
const client = await getTrpcClient();
const installations = (await client.messenger.listMyInstallations.query()) as any[];
const install = installations.find((i) => i.id === installationId);
if (!install) {
// Under `--json`, scripts need a parseable output even on miss —
// emit `null` to stdout (rather than a human-readable error), then
// exit non-zero so error handling still works in pipelines.
if (options.json) {
outputJson(null);
} else {
console.error(pc.red(`Installation not found: ${installationId}`));
}
process.exit(1);
return;
}
if (options.json) {
outputJson(install);
return;
}
console.log(`${pc.bold('Installation')} ${pc.dim(install.id)}`);
console.log(` Platform: ${install.platform}`);
console.log(` Tenant: ${install.tenantName || install.tenantId || '(global)'}`);
if (install.tenantId && install.tenantName) {
console.log(` Tenant ID: ${install.tenantId}`);
}
console.log(` Application ID: ${install.applicationId}`);
if (install.scope) console.log(` OAuth Scope: ${install.scope}`);
if (install.installedAt) {
console.log(` Installed: ${new Date(install.installedAt).toISOString()}`);
}
if (install.enterpriseId) {
console.log(` Enterprise ID: ${install.enterpriseId}`);
}
if (install.isEnterpriseInstall) {
console.log(` Enterprise: yes`);
}
});
messengers
.command('uninstall <installationId>')
.description(
'Revoke a workspace install. AFFECTS EVERY USER IN THAT WORKSPACE — for Slack this freezes ' +
'the bot; for Discord it removes the audit entry (a guild admin must remove the bot ' +
'separately). To disconnect only your own account, use `bot messengers links unlink`.',
)
.option('--yes', 'Skip confirmation prompt')
.action(async (installationId: string, options: { yes?: boolean }) => {
const client = await getTrpcClient();
if (!options.yes) {
const installations = (await client.messenger.listMyInstallations.query()) as any[];
const install = installations.find((i) => i.id === installationId);
const label = install
? `${install.platform} (${install.tenantName || install.tenantId || 'global'})`
: installationId;
const ok = await confirm(
`${pc.yellow('⚠')} Uninstall ${pc.bold(label)} — this revokes the install for the whole workspace. Continue?`,
);
if (!ok) {
console.log('Aborted.');
return;
}
}
await client.messenger.uninstallInstallation.mutate({ installationId });
console.log(`${pc.green('✓')} Installation ${pc.dim(installationId)} revoked.`);
});
messengers
.command('platforms')
.description('List the platforms available for System Bot OAuth install.')
.option('--json', 'Output JSON')
.action(async (options: { json?: boolean }) => {
const client = await getTrpcClient();
const platforms = await client.messenger.availablePlatforms.query();
if (options.json) {
outputJson(platforms);
return;
}
if (platforms.length === 0) {
console.log('No System Bot platforms are configured on this deployment.');
return;
}
const rows = platforms.map((p: any) => [
p.id || '',
p.name || '',
p.appId || '',
p.botUsername || '',
]);
printTable(rows, ['ID', 'NAME', 'APP ID', 'BOT USERNAME']);
console.log(
`\nInstalls are initiated via ${pc.dim('Settings → Messenger')} in the web UI ` +
'(OAuth needs a browser).',
);
});
// ── account links ──────────────────────────────────────
const links = messengers
.command('links')
.description('Manage per-user account links — routing of inbound IM to your agents');
links
.command('list')
.description('List all your account links across platforms and tenants.')
.option('--json', 'Output JSON')
.action(async (options: { json?: boolean }) => {
const client = await getTrpcClient();
const linkRows = await client.messenger.listMyLinks.query();
if (options.json) {
outputJson(linkRows);
return;
}
if (linkRows.length === 0) {
console.log('No account links yet. Complete verify-im on a platform first.');
return;
}
const rows = linkRows.map((l: any) => [
l.platform || '',
l.tenantId || '(global)',
l.activeAgentId || pc.dim('(unset)'),
l.platformUsername || l.platformUserId || '',
]);
printTable(rows, ['PLATFORM', 'TENANT', 'ACTIVE AGENT', 'PLATFORM USER']);
});
links
.command('view <platform>')
.description('Show one account link.')
.option('--tenant <id>', 'Tenant scope (Slack workspace id). Omit for global-bot platforms.')
.option('--json', 'Output JSON')
.action(async (platform: string, options: { json?: boolean; tenant?: string }) => {
const client = await getTrpcClient();
const platformValidated = validatePlatform(platform);
const link = await client.messenger.getMyLink.query({
platform: platformValidated,
tenantId: options.tenant,
});
if (!link) {
console.error(
pc.red(
`No link found for ${platform}${options.tenant ? ` (tenant ${options.tenant})` : ''}`,
),
);
process.exit(1);
return;
}
if (options.json) {
outputJson(link);
return;
}
console.log(`${pc.bold('Link')} ${pc.dim(link.platform)}`);
if (link.tenantId) console.log(` Tenant ID: ${link.tenantId}`);
console.log(` Platform User ID: ${link.platformUserId}`);
if (link.platformUsername) {
console.log(` Platform User: ${link.platformUsername}`);
}
console.log(` Active Agent: ${link.activeAgentId ?? pc.dim('(unset)')}`);
});
links
.command('set-agent <platform>')
.description('Change which agent receives inbound IM on a platform link.')
.requiredOption('--agent <id>', 'Agent id to route to, or "none" to clear the active agent.')
.option('--tenant <id>', 'Tenant scope (Slack workspace id). Omit for global-bot platforms.')
.action(async (platform: string, options: { agent: string; tenant?: string }) => {
const client = await getTrpcClient();
const platformValidated = validatePlatform(platform);
const agentId = options.agent === 'none' ? null : options.agent;
await client.messenger.setActiveAgent.mutate({
agentId,
platform: platformValidated,
tenantId: options.tenant,
});
const scope = options.tenant ? ` (tenant ${options.tenant})` : '';
const target = agentId === null ? 'cleared' : `set to agent ${pc.dim(agentId)}`;
console.log(`${pc.green('✓')} Active agent for ${platform}${scope} ${target}.`);
});
links
.command('unlink <platform>')
.description(
'Remove your account link for a platform. Workspace install is unaffected — colleagues ' +
'can still use the bot.',
)
.option('--tenant <id>', 'Tenant scope (Slack workspace id). Omit for global-bot platforms.')
.option('--yes', 'Skip confirmation prompt')
.action(async (platform: string, options: { tenant?: string; yes?: boolean }) => {
const client = await getTrpcClient();
const platformValidated = validatePlatform(platform);
if (!options.yes) {
const ok = await confirm(
`Unlink your account from ${pc.bold(platform)}${options.tenant ? ` (tenant ${options.tenant})` : ''}?`,
);
if (!ok) {
console.log('Aborted.');
return;
}
}
await client.messenger.unlink.mutate({
platform: platformValidated,
tenantId: options.tenant,
});
console.log(`${pc.green('✓')} Unlinked.`);
});
}
+195
View File
@@ -341,4 +341,199 @@ describe('hetero exec command', () => {
expect(exitSpy).toHaveBeenCalledWith(2);
expect(mockSpawnAgent).not.toHaveBeenCalled();
});
describe('--resume auto-retry on session-not-found', () => {
it('retries without --resume when the error stream event indicates the session is gone', async () => {
// First spawn: exits non-zero, emits a resume-not-found error event
const resumeNotFoundEvent = {
data: {
error: 'No conversation found with session ID cc-stale',
message: 'No conversation found with session ID cc-stale',
},
operationId: 'op-r1',
stepIndex: 0,
timestamp: 1,
type: 'error',
};
mockSpawnAgent
.mockReturnValueOnce(createFakeHandle({ events: [resumeNotFoundEvent], exitCode: 1 }))
// Second spawn: succeeds
.mockReturnValueOnce(createFakeHandle({ exitCode: 0 }));
await runCmd([
'hetero',
'exec',
'--type',
'claude-code',
'--prompt',
'do the thing',
'--resume',
'cc-stale',
'--operation-id',
'op-r1',
]);
// Two spawns: first with --resume, retry without
expect(mockSpawnAgent).toHaveBeenCalledTimes(2);
expect(mockSpawnAgent.mock.calls[0][0]).toMatchObject({ resumeSessionId: 'cc-stale' });
expect(mockSpawnAgent.mock.calls[1][0]).not.toHaveProperty('resumeSessionId');
expect(mockSpawnAgent.mock.calls[1][0].resumeSessionId).toBeUndefined();
// Final exit code comes from the retry (0 → success)
expect(exitSpy).toHaveBeenCalledWith(0);
});
it('retries without --resume when stderr contains a session-not-found message', async () => {
// First spawn: exits non-zero with no events, but stderr has the pattern
mockSpawnAgent
.mockReturnValueOnce(
createFakeHandle({
exitCode: 1,
stderrChunks: ['Error: No conversation found with session ID xyz\n'],
}),
)
.mockReturnValueOnce(createFakeHandle({ exitCode: 0 }));
await runCmd([
'hetero',
'exec',
'--type',
'claude-code',
'--prompt',
'continue',
'--resume',
'xyz',
'--operation-id',
'op-r2',
]);
expect(mockSpawnAgent).toHaveBeenCalledTimes(2);
expect(mockSpawnAgent.mock.calls[1][0].resumeSessionId).toBeUndefined();
expect(exitSpy).toHaveBeenCalledWith(0);
});
it('retries without --resume when the error indicates context overflow', async () => {
const contextOverflowEvent = {
data: {
error: 'prompt is too long: 215168 tokens > 200000 maximum',
message: 'prompt is too long: 215168 tokens > 200000 maximum',
},
operationId: 'op-ctx',
stepIndex: 0,
timestamp: 1,
type: 'error',
};
mockSpawnAgent
.mockReturnValueOnce(createFakeHandle({ events: [contextOverflowEvent], exitCode: 1 }))
.mockReturnValueOnce(createFakeHandle({ exitCode: 0 }));
await runCmd([
'hetero',
'exec',
'--type',
'claude-code',
'--prompt',
'next question',
'--resume',
'cc-longctx',
'--operation-id',
'op-ctx',
]);
expect(mockSpawnAgent).toHaveBeenCalledTimes(2);
expect(mockSpawnAgent.mock.calls[0][0]).toMatchObject({ resumeSessionId: 'cc-longctx' });
expect(mockSpawnAgent.mock.calls[1][0].resumeSessionId).toBeUndefined();
expect(exitSpy).toHaveBeenCalledWith(0);
});
it('does NOT retry on a non-resume error exit', async () => {
// Exit code 1 but no resume-related error message
mockSpawnAgent.mockReturnValueOnce(
createFakeHandle({ exitCode: 1, stderrChunks: ['rate limit exceeded\n'] }),
);
await runCmd([
'hetero',
'exec',
'--type',
'claude-code',
'--prompt',
'hi',
'--resume',
'cc-valid',
]);
expect(mockSpawnAgent).toHaveBeenCalledTimes(1);
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('does NOT retry when --resume is not provided', async () => {
const errorEvent = {
data: { error: 'No conversation found', message: 'No conversation found' },
operationId: 'op-nr',
stepIndex: 0,
timestamp: 1,
type: 'error',
};
mockSpawnAgent.mockReturnValueOnce(createFakeHandle({ events: [errorEvent], exitCode: 1 }));
await runCmd([
'hetero',
'exec',
'--type',
'claude-code',
'--prompt',
'fresh run',
'--operation-id',
'op-nr',
]);
// No --resume → no interception → no retry
expect(mockSpawnAgent).toHaveBeenCalledTimes(1);
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('does NOT suppress the resume-error event from JSONL output', async () => {
const resumeNotFoundEvent = {
data: {
error: 'No conversation found with session ID old',
message: 'No conversation found with session ID old',
},
operationId: 'op-jsonl',
stepIndex: 0,
timestamp: 1,
type: 'error',
};
mockSpawnAgent
.mockReturnValueOnce(createFakeHandle({ events: [resumeNotFoundEvent], exitCode: 1 }))
.mockReturnValueOnce(createFakeHandle({ exitCode: 0 }));
await runCmd([
'hetero',
'exec',
'--type',
'claude-code',
'--prompt',
'do thing',
'--resume',
'old',
'--render',
'jsonl',
]);
// The error event is still emitted to JSONL (for observability) even
// though it was withheld from the ingester.
const lines = stdoutSpy.mock.calls
.map((c) => c[0])
.filter((s): s is string => typeof s === 'string');
const errorLine = lines.find((l) => {
try {
return JSON.parse(l).type === 'error';
} catch {
return false;
}
});
expect(errorLine).toBeDefined();
});
});
});
+239 -84
View File
@@ -17,6 +17,40 @@ import { TrpcIngestSink } from '../utils/TrpcIngestSink';
const SUPPORTED_AGENT_TYPES = new Set(['claude-code', 'codex']);
/**
* Patterns that indicate a `--resume <sessionId>` run should be retried
* without `--resume`. Two classes of failure:
*
* 1. Session file missing (sandbox recycled): the container is ephemeral
* (~1 h idle TTL), so a new sandbox has an empty `~/.claude/projects/`
* and the stored session id is stale.
*
* 2. Context overflow (long conversation): the resumed session carries all
* accumulated history; when the combined token count exceeds the model's
* context window the API rejects the request immediately after CC
* initialises. Starting fresh (no `--resume`) drops the old history and
* lets CC respond to the new prompt alone.
*
* Checked against:
* - `error` stream events emitted by the CC adapter from CC's result event
* - Accumulated stderr output (fallback when CC exits without a result event)
*/
const RESUME_RETRY_PATTERNS = [
// Session file missing — sandbox was recycled
/no conversation found/i,
/session.*not found/i,
/conversation.*not found/i,
/resume.*not found/i,
// Context overflow — API rejected the resumed session's accumulated history
/prompt.*too long/i,
/context.*too long/i,
/context window.*exceed/i,
/maximum.*context.*length/i,
] as const;
const looksLikeNeedsRetryWithoutResume = (text: string): boolean =>
RESUME_RETRY_PATTERNS.some((p) => p.test(text));
interface ExecOptions {
command?: string;
cwd?: string;
@@ -218,95 +252,204 @@ const exec = async (options: ExecOptions): Promise<void> => {
}
const ingester = new BatchIngester(sink);
// `spawnAgent` is async and can reject DURING image normalization — fetch
// failures, missing local --image paths, decode errors. Surface those as a
// clean error + exit code instead of an unhandled promise rejection / stack
// trace, mirroring the validation try/catch above.
let handle: Awaited<ReturnType<typeof spawnAgent>>;
try {
handle = await spawnAgent({
/**
* Spawn one agent process and stream all its events into `ingester`.
*
* When `interceptResumeErrors` is true, any `error`-type event whose
* message matches `RESUME_RETRY_PATTERNS` is withheld from the
* ingester and signals a retry instead. This keeps the server's
* operation state clean: no terminal error event is pushed, so the
* retry's events land on the same operationId without confusing the
* renderer.
*
* Returns:
* code / signal — child exit info
* sessionId — CC session id from `system.init` (undefined on resume failure)
* ingestError — true when a batch could not be flushed after retries
* resumeNotFound — true when a resume-not-found error was intercepted
* stderrContent — accumulated stderr (only when interceptResumeErrors=true)
*/
const runOneAgent = async (
spawnOpts: Parameters<typeof spawnAgent>[0],
interceptResumeErrors: boolean,
): Promise<{
code: number | null;
ingestError: boolean;
resumeNotFound: boolean;
sessionId: string | undefined;
signal: NodeJS.Signals | null;
stderrContent: string;
}> => {
// `spawnAgent` is async and can reject DURING image normalization — fetch
// failures, missing local --image paths, decode errors.
let handle: Awaited<ReturnType<typeof spawnAgent>>;
try {
handle = await spawnAgent(spawnOpts);
} catch (err) {
log.error('Failed to start agent:', err instanceof Error ? err.message : String(err));
process.exit(1);
}
// Always collect stderr — used for resume-error detection AND for
// surfacing a meaningful error message to the server when CC fails
// without emitting a structured error event. Cap at 8 KB so the
// collector doesn't grow unboundedly on a chatty run.
// Always pipe to process.stderr too so users see auth prompts / warnings.
const STDERR_CAP = 8 * 1024;
let stderrContent = '';
handle.stderr.on('data', (chunk: Buffer) => {
if (stderrContent.length < STDERR_CAP) {
stderrContent += chunk.toString();
}
});
handle.stderr.pipe(process.stderr);
// Ctrl-C → SIGINT to the child's process group.
// Repeated Ctrl-C escalates to SIGKILL.
let interrupted = false;
const onSigint = async () => {
if (interrupted) {
handle.kill('SIGKILL');
return;
}
interrupted = true;
handle.kill('SIGINT');
if (serverIngest) {
try {
await ingester.drain();
await sink.finish({ result: 'cancelled' });
} catch {
// best-effort; process is exiting anyway
}
}
};
const onSigterm = async () => {
handle.kill('SIGTERM');
if (serverIngest) {
try {
await ingester.drain();
await sink.finish({ result: 'cancelled' });
} catch {
// best-effort
}
}
};
process.on('SIGINT', onSigint);
process.on('SIGTERM', onSigterm);
// Stream events. Each event is optionally written as JSONL and pushed
// into the ingester. When intercepting resume errors, a matching
// `error` event is withheld from the ingester and flags a retry instead.
let resumeNotFound = false;
const ingestError = false;
try {
for await (const event of handle.events) {
if (interceptResumeErrors && event.type === 'error') {
const data = event.data as Record<string, unknown> | undefined;
const msg = String(data?.message ?? data?.error ?? '');
if (looksLikeNeedsRetryWithoutResume(msg)) {
resumeNotFound = true;
// Emit to JSONL for observability but do NOT push to ingester —
// we are about to retry; the server must not see a terminal error.
if (emitJsonl) process.stdout.write(`${JSON.stringify(event)}\n`);
continue;
}
}
if (emitJsonl) process.stdout.write(`${JSON.stringify(event)}\n`);
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({
error: { message: String(err), type: 'stream_error' },
result: 'error',
});
} catch {
// best-effort
}
}
process.exit(1);
} finally {
process.off('SIGINT', onSigint);
process.off('SIGTERM', onSigterm);
}
const { code, signal } = await handle.exit;
// Fallback stderr detection: CC may exit non-zero without emitting a
// result event (e.g. it writes to stderr and quits immediately).
if (
interceptResumeErrors &&
!resumeNotFound &&
code !== 0 &&
looksLikeNeedsRetryWithoutResume(stderrContent)
) {
resumeNotFound = true;
}
return {
code,
ingestError,
resumeNotFound,
sessionId: handle.sessionId,
signal,
stderrContent,
};
};
// ─── First run (with --resume if provided) ───────────────────────────────
const interceptResume = !!options.resume;
const first = await runOneAgent(
{
agentType: options.type,
command: options.command,
cwd: options.cwd || process.cwd(),
operationId,
prompt: resolved.prompt,
resumeSessionId: options.resume,
});
} catch (err) {
log.error('Failed to start agent:', err instanceof Error ? err.message : String(err));
process.exit(1);
},
interceptResume,
);
// ─── Auto-retry without --resume when the session cannot be used ─────────
//
// Two classes of failure detected via `RESUME_RETRY_PATTERNS`:
// A. Sandbox recycled: container is ephemeral (~1 h idle TTL); new sandbox
// has no CC session files so `--resume <staleId>` is rejected with a
// "no conversation found" error.
// B. Context overflow: the resumed session carries accumulated history that
// pushes the combined token count past the model limit; the API rejects
// the call with a "prompt is too long" error.
//
// In both cases we transparently restart CC without `--resume` so it starts a
// fresh session. The server's `heteroSessionId` is updated with the new id,
// breaking the stale-session loop.
let result = first;
if (first.resumeNotFound) {
log.info('Resume failed (session not found or context overflow) — retrying without --resume');
result = await runOneAgent(
{
agentType: options.type,
command: options.command,
cwd: options.cwd || process.cwd(),
operationId,
prompt: resolved.prompt,
// No resumeSessionId — start fresh
},
false, // no need to intercept resume errors on a fresh run
);
}
// Forward the child's stderr to ours so users see CLI errors / warnings
// (auth prompts, missing-binary errors, etc.) in the terminal.
handle.stderr.pipe(process.stderr);
// ─── Drain + finish ───────────────────────────────────────────────────────
// Ctrl-C → SIGINT to the child's process group so the spawned CLI gets a
// chance to clean up. Repeated Ctrl-C escalates to SIGKILL via the
// standard "double-tap" pattern most CLIs implement themselves.
// In server-ingest mode, drain the ingester and call heteroFinish before
// exiting so the server knows the operation was cancelled.
let interrupted = false;
const onSigint = async () => {
if (interrupted) {
handle.kill('SIGKILL');
return;
}
interrupted = true;
handle.kill('SIGINT');
if (serverIngest) {
try {
await ingester.drain();
await sink.finish({ result: 'cancelled' });
} catch {
// best-effort; process is exiting anyway
}
}
};
process.on('SIGINT', onSigint);
process.on('SIGTERM', async () => {
handle.kill('SIGTERM');
if (serverIngest) {
try {
await ingester.drain();
await sink.finish({ result: 'cancelled' });
} catch {
// best-effort
}
}
});
// Stream events. Each event is optionally written as JSONL and always
// pushed into the ingester (which batches and sends to the server).
let ingestError = false;
try {
for await (const event of handle.events) {
if (emitJsonl) {
process.stdout.write(`${JSON.stringify(event)}\n`);
}
ingester.push(event);
}
} catch (err) {
log.error('Stream error from agent process:', err instanceof Error ? err.message : String(err));
if (serverIngest) {
try {
await ingester.drain();
await sink.finish({
result: 'error',
error: { message: String(err), type: 'stream_error' },
});
} catch {
// best-effort
}
}
process.exit(1);
} finally {
process.off('SIGINT', onSigint);
}
// Pass the child's exit code through. In server-ingest mode, drain the
// ingester and call heteroFinish before exiting.
const { code, signal } = await handle.exit;
const { code, signal, sessionId } = result;
if (serverIngest) {
try {
@@ -316,21 +459,33 @@ const exec = async (options: ExecOptions): Promise<void> => {
'Failed to flush events to server:',
err instanceof Error ? err.message : String(err),
);
ingestError = true;
result = { ...result, ingestError: true };
}
const exitedClean = !ingestError && (code === 0 || signal === 'SIGTERM');
const exitedClean = !result.ingestError && (code === 0 || signal === 'SIGTERM');
// When the run failed, pass stderr as the error detail so the server can
// surface a useful message instead of the generic "Agent execution failed"
// fallback. Trim to the last 1 KB — the tail is most informative and
// keeps the tRPC payload small.
const stderrTail = result.stderrContent.trim();
const finishError =
!exitedClean && stderrTail
? { message: stderrTail.slice(-1024), type: 'AgentRuntimeError' }
: undefined;
try {
await sink.finish({
error: finishError,
result: exitedClean ? 'success' : 'error',
sessionId: handle.sessionId,
sessionId,
});
} catch (err) {
log.error('Failed to send heteroFinish:', err instanceof Error ? err.message : String(err));
}
}
if (code !== null) process.exit(ingestError ? 1 : code);
if (code !== null) process.exit(result.ingestError ? 1 : code);
if (signal === 'SIGINT') process.exit(130);
if (signal === 'SIGTERM') process.exit(143);
if (signal === 'SIGKILL') process.exit(137);
+26 -1
View File
@@ -12,16 +12,38 @@ export function registerNotifyCommand(program: Command) {
.requiredOption('-c, --content <content>', 'Message content')
.option('--agent-id <agentId>', 'Agent ID (overrides topic default)')
.option('--thread-id <threadId>', 'Thread ID for threaded conversations')
.option(
'--role <role>',
'Message role: user (default, triggers agent reply) | assistant (writes directly as agent message)',
'user',
)
.option(
'--message-id <messageId>',
'When --role assistant: update an existing message instead of creating a new one (keeps a single bubble)',
)
.option(
'--continue',
'When --role assistant: trigger a follow-up agent turn after writing the message',
)
.option('--json', 'Output JSON')
.action(
async (options: {
agentId?: string;
content: string;
continue?: boolean;
json?: boolean;
messageId?: string;
role?: 'assistant' | 'user';
threadId?: string;
topic: string;
}) => {
log.debug('notify: topic=%s, agentId=%s', options.topic, options.agentId);
log.debug(
'notify: topic=%s, agentId=%s, role=%s, messageId=%s',
options.topic,
options.agentId,
options.role,
options.messageId,
);
const client = await getTrpcClient();
@@ -29,6 +51,9 @@ export function registerNotifyCommand(program: Command) {
const result = await client.agentNotify.notify.mutate({
agentId: options.agentId,
content: options.content,
continue: options.continue,
messageId: options.messageId,
role: options.role,
threadId: options.threadId,
topicId: options.topic,
});
+51
View File
@@ -0,0 +1,51 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
export interface TaskEntry {
agentId?: string;
agentType: 'hermes' | 'openclaw';
operationId: string;
pid: number;
startedAt: string;
taskId: string;
topicId: string;
}
function getRegistryPath(): string {
return path.join(os.homedir(), '.lobehub', 'task-registry.json');
}
function readRegistry(): Record<string, TaskEntry> {
try {
return JSON.parse(fs.readFileSync(getRegistryPath(), 'utf8')) as Record<string, TaskEntry>;
} catch {
return {};
}
}
function writeRegistry(entries: Record<string, TaskEntry>): void {
const dir = path.dirname(getRegistryPath());
fs.mkdirSync(dir, { mode: 0o700, recursive: true });
fs.writeFileSync(getRegistryPath(), JSON.stringify(entries, null, 2), { mode: 0o600 });
}
export function saveTask(entry: TaskEntry): void {
const registry = readRegistry();
registry[entry.taskId] = entry;
writeRegistry(registry);
}
export function getTask(taskId: string): TaskEntry | undefined {
return readRegistry()[taskId];
}
export function removeTask(taskId: string): void {
const registry = readRegistry();
delete registry[taskId];
writeRegistry(registry);
}
export function listTasks(): TaskEntry[] {
return Object.values(readRegistry());
}
+8 -1
View File
@@ -1,3 +1,10 @@
import { createProgram } from './program';
import { log } from './utils/logger';
createProgram().parse(process.argv, { from: 'node' });
void createProgram()
.parseAsync(process.argv, { from: 'node' })
.catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
log.error(message);
process.exit(1);
});
@@ -0,0 +1,251 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { removeTask, saveTask } from '../../daemon/taskRegistry';
import { runHeteroTask } from '../heteroTask';
// ─── Mocks ───
const spawnMock = vi.hoisted(() => vi.fn());
const execFileSyncMock = vi.hoisted(() => vi.fn());
vi.mock('node:child_process', () => ({
execFileSync: execFileSyncMock,
spawn: spawnMock,
}));
// task registry — use real implementation backed by a temporary in-memory map
const taskStore: Record<string, any> = {};
vi.mock('../../daemon/taskRegistry', () => ({
getTask: vi.fn((id: string) => taskStore[id]),
listTasks: vi.fn(() => Object.values(taskStore)),
removeTask: vi.fn((id: string) => {
delete taskStore[id];
}),
saveTask: vi.fn((entry: any) => {
taskStore[entry.taskId] = entry;
}),
}));
vi.mock('../../api/client', () => ({
getTrpcClient: vi.fn().mockResolvedValue({
agentNotify: {
notify: { mutate: vi.fn().mockResolvedValue(undefined) },
},
}),
}));
vi.mock('../../utils/logger', () => ({
log: { error: vi.fn(), info: vi.fn(), warn: vi.fn() },
}));
// ─── Helpers ───
function makeMockChild(pid = 9999) {
const listeners: Record<string, Array<(...a: any[]) => void>> = {};
return {
on: vi.fn((event: string, cb: (...a: any[]) => void) => {
(listeners[event] ??= []).push(cb);
}),
pid,
unref: vi.fn(),
_emit: (event: string, ...args: any[]) => listeners[event]?.forEach((cb) => cb(...args)),
};
}
// ─── Tests ───
describe('runHeteroTask (openclaw)', () => {
beforeEach(() => {
vi.clearAllMocks();
// Clear task store
for (const key of Object.keys(taskStore)) delete taskStore[key];
execFileSyncMock.mockReturnValue('/usr/local/bin/lh\n');
});
afterEach(() => {
vi.restoreAllMocks();
});
it('always injects buildNotifyProtocol into the prompt regardless of session history', async () => {
const child = makeMockChild();
spawnMock.mockReturnValue(child);
await runHeteroTask({
agentType: 'openclaw',
operationId: 'op-1',
prompt: 'what time is it',
taskId: 'task-1',
topicId: 'topic-1',
});
expect(spawnMock).toHaveBeenCalledTimes(1);
const [, spawnArgs] = spawnMock.mock.calls[0] as [string, string[]];
const msgIdx = spawnArgs.indexOf('--message');
const messageArg = spawnArgs[msgIdx + 1];
expect(messageArg).toContain('what time is it');
expect(messageArg).toContain('lh notify');
expect(messageArg).toContain('MSG_ID');
});
it('always injects protocol even on the second turn of the same session', async () => {
const child1 = makeMockChild(1111);
const child2 = makeMockChild(2222);
spawnMock.mockReturnValueOnce(child1).mockReturnValueOnce(child2);
// First turn
await runHeteroTask({
agentType: 'openclaw',
operationId: 'op-1',
prompt: 'hello',
taskId: 'task-1',
topicId: 'topic-1',
});
// Simulate process exit so task is removed
child1._emit('close', 0, null);
// Second turn (same topicId)
await runHeteroTask({
agentType: 'openclaw',
operationId: 'op-2',
prompt: 'follow up',
taskId: 'task-2',
topicId: 'topic-1',
});
expect(spawnMock).toHaveBeenCalledTimes(2);
for (const call of spawnMock.mock.calls) {
const args = call[1] as string[];
const msg = args[args.indexOf('--message') + 1];
expect(msg).toContain('lh notify');
}
});
it('kills an existing concurrent process for the same topicId before spawning', async () => {
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
const child1 = makeMockChild(1111);
spawnMock.mockReturnValueOnce(child1);
await runHeteroTask({
agentType: 'openclaw',
operationId: 'op-1',
prompt: 'msg1',
taskId: 'task-1',
topicId: 'topic-same',
});
// task-1 is still "running" (close not fired)
const child2 = makeMockChild(2222);
spawnMock.mockReturnValueOnce(child2);
await runHeteroTask({
agentType: 'openclaw',
operationId: 'op-2',
prompt: 'msg2',
taskId: 'task-2',
topicId: 'topic-same',
});
expect(killSpy).toHaveBeenCalledWith(1111, 'SIGTERM');
expect(spawnMock).toHaveBeenCalledTimes(2);
});
it('does not kill processes for a different topicId', async () => {
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
const child1 = makeMockChild(3333);
spawnMock.mockReturnValueOnce(child1);
await runHeteroTask({
agentType: 'openclaw',
operationId: 'op-1',
prompt: 'a',
taskId: 'task-a',
topicId: 'topic-A',
});
const child2 = makeMockChild(4444);
spawnMock.mockReturnValueOnce(child2);
await runHeteroTask({
agentType: 'openclaw',
operationId: 'op-2',
prompt: 'b',
taskId: 'task-b',
topicId: 'topic-B',
});
expect(killSpy).not.toHaveBeenCalled();
});
it('saves task entry with correct fields after spawn', async () => {
const child = makeMockChild(5555);
spawnMock.mockReturnValue(child);
await runHeteroTask({
agentId: 'agent-1',
agentType: 'openclaw',
operationId: 'op-x',
prompt: 'test',
taskId: 'task-x',
topicId: 'topic-x',
});
expect(saveTask).toHaveBeenCalledWith(
expect.objectContaining({
agentType: 'openclaw',
pid: 5555,
taskId: 'task-x',
topicId: 'topic-x',
}),
);
});
it('passes --session-id and --agent args to openclaw', async () => {
const child = makeMockChild();
spawnMock.mockReturnValue(child);
await runHeteroTask({
agentType: 'openclaw',
operationId: 'op-1',
prompt: 'hello',
taskId: 'task-1',
topicId: 'my-topic-id',
});
const [, spawnArgs] = spawnMock.mock.calls[0] as [string, string[]];
expect(spawnArgs).toContain('--session-id');
expect(spawnArgs[spawnArgs.indexOf('--session-id') + 1]).toBe('my-topic-id');
expect(spawnArgs).toContain('--agent');
expect(spawnArgs).toContain('--local');
});
it('removes task and ignores already-exited process when killing concurrent task', async () => {
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => {
throw new Error('No such process');
});
const child1 = makeMockChild(7777);
spawnMock.mockReturnValueOnce(child1);
await runHeteroTask({
agentType: 'openclaw',
operationId: 'op-1',
prompt: 'msg1',
taskId: 'task-1',
topicId: 'topic-gone',
});
const child2 = makeMockChild(8888);
spawnMock.mockReturnValueOnce(child2);
// Should not throw even though kill fails
await expect(
runHeteroTask({
agentType: 'openclaw',
operationId: 'op-2',
prompt: 'msg2',
taskId: 'task-2',
topicId: 'topic-gone',
}),
).resolves.not.toThrow();
expect(removeTask).toHaveBeenCalledWith('task-1');
killSpy.mockRestore();
});
});
@@ -0,0 +1,70 @@
import { execFileSync } from 'node:child_process';
import { getHermesPort } from './heteroTask';
export interface CheckPlatformCapabilityParams {
platform: 'hermes' | 'openclaw';
}
export interface CheckPlatformCapabilityResult {
available: boolean;
reason?: string;
version?: string;
}
/**
* Probe whether a specific agent platform is available on this device.
* Dispatched by the server via `device.checkCapability` tRPC procedure.
*
* - openclaw: runs `openclaw --version` and parses the output
* - hermes: hits the gateway health endpoint on the configured port
*/
export async function checkPlatformCapability(
params: CheckPlatformCapabilityParams,
): Promise<CheckPlatformCapabilityResult> {
const { platform } = params;
if (platform === 'openclaw') {
try {
const output = execFileSync('openclaw', ['--version'], {
encoding: 'utf8',
timeout: 5000,
}).trim();
// output is typically "openclaw x.y.z"
const version = output.split(/\s+/).at(-1);
return { available: true, version };
} catch (err) {
return {
available: false,
reason: err instanceof Error ? err.message : 'openclaw not found or failed to run',
};
}
}
if (platform === 'hermes') {
const port = getHermesPort();
try {
const res = await fetch(`http://localhost:${port}/health`, {
signal: AbortSignal.timeout(5000),
});
if (res.ok) {
let version: string | undefined;
try {
const body = (await res.json()) as { version?: string };
version = body.version;
} catch {
/* ignore parse errors */
}
return { available: true, version };
}
return { available: false, reason: `Hermes gateway returned HTTP ${res.status}` };
} catch (err) {
return {
available: false,
reason: err instanceof Error ? err.message : `Hermes gateway not reachable on port ${port}`,
};
}
}
return { available: false, reason: `Unknown platform: ${platform as string}` };
}
+104
View File
@@ -0,0 +1,104 @@
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import type { RemoteHeterogeneousAgentType } from '@lobechat/heterogeneous-agents';
export interface GetAgentProfileParams {
/** Agent ID to query (openclaw only). Defaults to the default agent. */
agentId?: string;
platform: RemoteHeterogeneousAgentType;
}
export interface AgentProfileResult {
avatar?: string;
description?: string;
title?: string;
}
// Files to look for a description (tried in order)
const IDENTITY_FILES = ['IDENTITY.md', 'SOUL.md'];
/**
* Try to extract a description from the workspace identity file.
* Looks for Creature / Vibe / Description fields in IDENTITY.md or SOUL.md.
*/
function readDescriptionFromWorkspace(workspacePath: string): string | undefined {
for (const filename of IDENTITY_FILES) {
const filePath = path.join(workspacePath, filename);
if (!fs.existsSync(filePath)) continue;
const content = fs.readFileSync(filePath, 'utf8');
const match = content.match(/\*{0,2}(?:Creature|Vibe|Description):?\*{0,2}\s*(.+)/i);
if (!match) continue;
const value = match[1].trim();
// Skip unfilled template placeholders like _(pick something)_ or (TBD)
if (/^[_*(].*[)*_]$|^(?:tbd|todo|n\/?a|none|待定|未定)$/i.test(value)) continue;
return value;
}
}
interface OpenClawAgentEntry {
id: string;
identityEmoji?: string;
identityName?: string;
isDefault?: boolean;
workspace?: string;
}
function getOpenClawProfile(agentId?: string): AgentProfileResult {
let output: string;
try {
output = execFileSync('openclaw', ['agents', 'list', '--json'], {
encoding: 'utf8',
timeout: 5000,
});
} catch {
return {};
}
let agents: OpenClawAgentEntry[];
try {
agents = JSON.parse(output) as OpenClawAgentEntry[];
} catch {
return {};
}
const agent = agentId
? agents.find((a) => a.id === agentId)
: (agents.find((a) => a.isDefault) ?? agents[0]);
if (!agent) return {};
const title = agent.identityName || undefined;
const avatar = agent.identityEmoji || '🦞'; // OpenClaw brand mascot as default
// Description is not exposed by the CLI — read from the workspace IDENTITY.md
const description = agent.workspace ? readDescriptionFromWorkspace(agent.workspace) : undefined;
return { avatar, description, title };
}
/**
* Fetch the agent profile (title, avatar, description) from the platform
* installed on this device. Dispatched by the server via `device.getAgentProfile`.
*
* - openclaw: `openclaw agents list --json` for name + emoji, workspace
* IDENTITY.md for description fallback
* - hermes: not yet implemented — returns empty profile
*/
export async function getAgentProfile(params: GetAgentProfileParams): Promise<AgentProfileResult> {
const { platform, agentId } = params;
if (platform === 'openclaw') {
return getOpenClawProfile(agentId);
}
if (platform === 'hermes') {
// Profile fetch not yet implemented for Hermes — return empty
return {};
}
return {};
}
+314
View File
@@ -0,0 +1,314 @@
import { execFileSync, spawn } from 'node:child_process';
import type { RemoteHeterogeneousAgentType } from '@lobechat/heterogeneous-agents';
import { getTrpcClient } from '../api/client';
import { getTask, listTasks, removeTask, saveTask } from '../daemon/taskRegistry';
import { log } from '../utils/logger';
const DEFAULT_HERMES_PORT = 3456;
/** Resolve the absolute path to the `lh` binary to avoid PATH issues in child processes. */
function resolveLhPath(): string {
try {
return execFileSync('which', ['lh'], { encoding: 'utf8' }).trim();
} catch {
return 'lh';
}
}
export interface RunHeteroTaskParams {
agentId?: string;
agentType: RemoteHeterogeneousAgentType;
cwd?: string;
operationId: string;
prompt: string;
taskId: string;
topicId: string;
}
export interface CancelHeteroTaskParams {
signal?: 'SIGINT' | 'SIGKILL' | 'SIGTERM';
taskId: string;
}
export function getHermesPort(): number {
const env = process.env.HERMES_GATEWAY_PORT;
if (env) {
const parsed = Number.parseInt(env, 10);
if (!Number.isNaN(parsed)) return parsed;
}
return DEFAULT_HERMES_PORT;
}
async function isHermesGatewayRunning(port: number): Promise<boolean> {
try {
const res = await fetch(`http://localhost:${port}/health`);
return res.ok;
} catch {
return false;
}
}
async function startHermesGateway(port: number): Promise<void> {
const child = spawn('hermes', ['gateway', 'start'], {
detached: true,
env: { ...process.env },
stdio: 'ignore',
});
child.unref();
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
await new Promise<void>((r) => setTimeout(r, 500));
if (await isHermesGatewayRunning(port)) return;
}
throw new Error(`Hermes gateway did not start within 10s on port ${port}`);
}
async function sendAutoNotify(
topicId: string,
taskId: string,
text: string,
agentId?: string,
): Promise<void> {
try {
const client = await getTrpcClient();
await client.agentNotify.notify.mutate({
agentId,
content: text,
role: 'assistant',
topicId,
});
} catch (err) {
log.error('Failed to send auto-notify:', err instanceof Error ? err.message : String(err));
}
}
/**
* Signal remote hetero task completion to the server so it can publish
* `agent_runtime_end` to the gateway WS and close the frontend subscription.
* Called on clean process exit (code=0, no signal) — error exits go through
* `sendAutoNotify` which writes an error message AND triggers completion via
* the `done` flag.
*/
async function sendDoneSignal(topicId: string, agentId?: string): Promise<void> {
try {
const client = await getTrpcClient();
await client.agentNotify.notify.mutate({
agentId,
content: '',
done: true,
role: 'assistant',
topicId,
});
} catch (err) {
log.error('Failed to send done signal:', err instanceof Error ? err.message : String(err));
}
}
/**
* Build the notify protocol injected into the first message of a new hetero-agent session.
* Tells the agent how to push updates back to the LobeHub user via `lh notify`.
*/
function buildNotifyProtocol(lhPath: string, topicId: string): string {
return (
`## Context: This task was dispatched by LobeHub\n\n` +
`This conversation / task was sent to you by the **LobeHub platform** on behalf of a user. You are running as a background agent; the user is waiting for your response inside the LobeHub chat interface.\n\n` +
`**When to call notify**: any time you have something meaningful to tell the user — a key finding, a decision you made, a result, a question, or your final answer. Think of it as speaking directly to the user in the chat window.\n\n` +
`**What to hide**: internal work details such as tool call sequences, file reads, intermediate command output, retries, or low-level reasoning steps. The user cares about outcomes and insights, not your step-by-step mechanics.\n\n` +
`## Sending messages back to the user\n\n` +
`Use the \`${lhPath} notify\` command. All your updates appear as a **single message bubble** in the UI — create it once and update it in place.\n\n` +
`**Step 1 — Open the bubble on your first meaningful update** (captures the messageId):\n` +
`\`\`\`\n` +
`MSG_ID=$(${lhPath} notify --topic ${topicId} --role assistant --content "Starting..." --json | grep -o '"messageId":"[^"]*"' | cut -d'"' -f4)\n` +
`\`\`\`\n\n` +
`**Step 2 — Update the same bubble as you make progress**:\n` +
`\`\`\`\n` +
`${lhPath} notify --topic ${topicId} --role assistant --message-id "$MSG_ID" --content "Still working..."\n` +
`\`\`\`\n\n` +
`**Step 3 — Replace with your complete, final response when done**:\n` +
`\`\`\`\n` +
`${lhPath} notify --topic ${topicId} --role assistant --message-id "$MSG_ID" --content "<your full response here>"\n` +
`\`\`\`\n\n` +
`Rules:\n` +
`- Always use \`--json\` on the first call and capture \`messageId\` from the output.\n` +
`- Always pass \`--message-id\` on every subsequent call so updates overwrite the same bubble.\n` +
`- Write what matters to the user — not implementation steps or internal tool calls.\n` +
`- Call notify at least once when the task is done, even if there were no intermediate updates.`
);
}
export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string> {
const { agentId, agentType, cwd, operationId, prompt, taskId, topicId } = params;
const workDir = cwd || process.cwd();
const lhPath = resolveLhPath();
if (agentType === 'openclaw') {
// openclaw agent --local is one-shot: each invocation processes one message and exits.
// The --session-id links turns into the same conversation history on disk.
// Requires the `openclaw` binary to be on PATH with Node >=22.19.
const openclawAgent = process.env.OPENCLAW_AGENT_ID ?? 'main';
// Always inject the notify protocol so openclaw knows how to report results
// back to the LobeHub UI — even if the previous turn failed and the session
// history was not cleanly committed.
const enrichedPrompt = `${prompt}\n\n${buildNotifyProtocol(lhPath, topicId)}`;
// Kill any existing openclaw process for this topicId before spawning a new one.
// openclaw serialises session writes; a concurrent process holding the session
// lock will cause the new one to exit with code 1.
for (const existing of listTasks()) {
if (existing.topicId === topicId && existing.agentType === 'openclaw') {
try {
process.kill(existing.pid, 'SIGTERM');
} catch {
// Already exited — nothing to do.
}
removeTask(existing.taskId);
}
}
const child = spawn(
'openclaw',
[
'agent',
'--agent',
openclawAgent,
'--session-id',
topicId,
'--message',
enrichedPrompt,
'--local',
],
{
cwd: workDir,
detached: true,
env: { ...process.env },
stdio: 'ignore',
},
);
const pid = child.pid;
if (pid === undefined) {
throw new Error('Failed to get PID for openclaw process');
}
child.unref();
saveTask({
agentId,
agentType,
operationId,
pid,
startedAt: new Date().toISOString(),
taskId,
topicId,
});
log.info(`OpenClaw task started: taskId=${taskId} pid=${pid} agent=${openclawAgent}`);
// On exit: notify the server so it can close the frontend gateway WS subscription.
// - Abnormal exit (signal or non-zero code): write an error message bubble.
// - Clean exit (code=0, no signal): openclaw already sent its final message via
// `lh notify`; just send a done signal to publish `agent_runtime_end`.
child.on('close', (code, signal) => {
removeTask(taskId);
if (code !== 0 || signal !== null) {
const text = signal
? `Task cancelled (signal: ${signal})`
: `Task failed (exit code: ${code})`;
// Send error message first, THEN signal done (sequential).
// Fire-and-forget both, but ensure done is always sent even if notify fails.
void sendAutoNotify(topicId, taskId, text, agentId).finally(() =>
sendDoneSignal(topicId, agentId),
);
} else {
// Clean exit — openclaw already sent its final message; just signal done.
void sendDoneSignal(topicId, agentId);
}
});
return JSON.stringify({ pid, taskId });
}
if (agentType === 'hermes') {
const port = getHermesPort();
if (!(await isHermesGatewayRunning(port))) {
log.info(`Hermes gateway not running on port ${port}, starting...`);
await startHermesGateway(port);
}
const res = await fetch(`http://localhost:${port}/message`, {
body: JSON.stringify({ content: prompt, operationId }),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
if (!res.ok) {
throw new Error(`Hermes gateway returned ${res.status}: ${await res.text()}`);
}
// pid is 0 for Hermes — the gateway is long-lived and cancellation uses
// the HTTP /stop API rather than direct signal delivery.
saveTask({
agentId,
agentType,
operationId,
pid: 0,
startedAt: new Date().toISOString(),
taskId,
topicId,
});
log.info(`Hermes task dispatched: taskId=${taskId} operationId=${operationId}`);
return JSON.stringify({ operationId, taskId });
}
throw new Error(`Unsupported agentType: ${agentType as string}`);
}
export async function cancelHeteroTask(params: CancelHeteroTaskParams): Promise<string> {
const { signal = 'SIGINT', taskId } = params;
const entry = getTask(taskId);
if (!entry) {
return JSON.stringify({ message: `No task found with taskId: ${taskId}`, success: false });
}
if (entry.agentType === 'hermes') {
const port = getHermesPort();
try {
await fetch(`http://localhost:${port}/stop`, {
body: JSON.stringify({ operationId: entry.operationId }),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
} catch (err) {
log.warn(
`Failed to send /stop to Hermes gateway: ${err instanceof Error ? err.message : String(err)}`,
);
}
removeTask(taskId);
await sendAutoNotify(entry.topicId, taskId, 'Task cancelled', entry.agentId);
return JSON.stringify({ taskId });
}
// OpenClaw: kill by PID and let the child's close handler send the notify.
try {
process.kill(entry.pid, signal);
} catch (err) {
// Process already exited — exit handler won't fire; clean up manually.
log.warn(
`Failed to send ${signal} to pid ${entry.pid}: ${err instanceof Error ? err.message : String(err)}`,
);
removeTask(taskId);
await sendAutoNotify(
entry.topicId,
taskId,
'Task already completed or cancelled',
entry.agentId,
);
}
return JSON.stringify({ pid: entry.pid, signal, taskId });
}
+7
View File
@@ -1,4 +1,5 @@
import { log } from '../utils/logger';
import { checkPlatformCapability } from './checkPlatformCapability';
import {
editLocalFile,
globLocalFiles,
@@ -8,9 +9,14 @@ import {
searchLocalFiles,
writeLocalFile,
} from './file';
import { getAgentProfile } from './getAgentProfile';
import { cancelHeteroTask, runHeteroTask } from './heteroTask';
import { getCommandOutput, killCommand, runCommand } from './shell';
const methodMap: Record<string, (args: any) => Promise<unknown>> = {
cancelHeteroTask,
checkPlatformCapability,
getAgentProfile,
editFile: editLocalFile,
getCommandOutput,
globFiles: globLocalFiles,
@@ -19,6 +25,7 @@ const methodMap: Record<string, (args: any) => Promise<unknown>> = {
listFiles: listLocalFiles,
readFile: readLocalFile,
runCommand,
runHeteroTask,
searchFiles: searchLocalFiles,
writeFile: writeLocalFile,
+29
View File
@@ -427,6 +427,35 @@ describe('streamAgentEventsViaWebSocket', () => {
).rejects.toThrow('Gateway auth failed');
});
it('should reject when websocket onerror fires', async () => {
const promise = streamAgentEventsViaWebSocket({
gatewayUrl: 'https://gw.test.com',
operationId: 'op-1',
token: 'test-token',
});
await flush();
capturedWs!.onerror?.({ message: 'socket exploded', type: 'error' });
await expect(promise).rejects.toThrow('Agent gateway WebSocket failed: [object Object]');
});
it('should reject when websocket closes before completion', async () => {
const promise = streamAgentEventsViaWebSocket({
gatewayUrl: 'https://gw.test.com',
operationId: 'op-1',
token: 'test-token',
});
await flush();
capturedWs!.readyState = MockWebSocket.CLOSED;
capturedWs!.onclose?.({ code: 1011, reason: 'gateway shutdown', type: 'close' });
await expect(promise).rejects.toThrow(
'Agent gateway WebSocket closed before completion: [object Object]',
);
});
it('should resolve on session_complete', async () => {
const promise = streamAgentEventsViaWebSocket({
gatewayUrl: 'https://gw.test.com',
+20 -6
View File
@@ -187,6 +187,7 @@ export async function streamAgentEventsViaWebSocket(
const ctx = createRenderContext();
let lastEventId = '';
let heartbeatTimer: ReturnType<typeof setInterval> | undefined;
let isSettled = false;
let jsonPrinted = false;
const cleanup = () => {
@@ -243,6 +244,8 @@ export async function streamAgentEventsViaWebSocket(
} else if (!streamOpts.json) {
renderEnd(agentEvent);
}
if (isSettled) return;
isSettled = true;
cleanup();
resolve();
return;
@@ -266,6 +269,8 @@ export async function streamAgentEventsViaWebSocket(
jsonPrinted = true;
console.log(JSON.stringify(jsonEvents, null, 2));
}
if (isSettled) return;
isSettled = true;
cleanup();
resolve();
}
@@ -273,16 +278,25 @@ export async function streamAgentEventsViaWebSocket(
ws.onerror = (err) => {
cleanup();
reject(err);
};
ws.onclose = () => {
if (heartbeatTimer) clearInterval(heartbeatTimer);
if (isSettled) return;
if (streamOpts.json && jsonEvents.length > 0 && !jsonPrinted) {
jsonPrinted = true;
console.log(JSON.stringify(jsonEvents, null, 2));
}
resolve();
isSettled = true;
reject(new Error(`Agent gateway WebSocket failed: ${String(err)}`));
};
ws.onclose = (event) => {
if (heartbeatTimer) clearInterval(heartbeatTimer);
if (isSettled) return;
if (streamOpts.json && jsonEvents.length > 0 && !jsonPrinted) {
jsonPrinted = true;
console.log(JSON.stringify(jsonEvents, null, 2));
}
isSettled = true;
reject(new Error(`Agent gateway WebSocket closed before completion: ${String(event)}`));
};
});
}
+1 -3
View File
@@ -64,14 +64,12 @@
"@lobehub/i18n-cli": "^1.25.1",
"@modelcontextprotocol/sdk": "^1.24.3",
"@t3-oss/env-core": "^0.13.8",
"@types/async-retry": "^1.4.9",
"@types/resolve": "^1.20.6",
"@types/semver": "^7.7.1",
"@types/set-cookie-parser": "^2.4.10",
"@typescript/native-preview": "7.0.0-dev.20251210.1",
"@vanilla-extract/css": "^1.17.4",
"@vanilla-extract/vite-plugin": "^5.1.0",
"async-retry": "^1.3.3",
"consola": "^3.4.2",
"cookie": "^1.1.1",
"cross-env": "^10.1.0",
@@ -109,7 +107,7 @@
"typescript": "^5.9.3",
"undici": "^7.16.0",
"uuid": "^14.0.0",
"vite": "8.0.12",
"vite": "8.0.14",
"vitest": "^3.2.4",
"zod": "^3.25.76"
},
+1
View File
@@ -37,6 +37,7 @@ export const STORE_DEFAULTS: ElectronMainStore = {
locale: 'auto',
localFileWorkspaceRoots: [],
networkProxy: defaultProxySettings,
pendingRestoreRoute: '',
shortcuts: DEFAULT_ELECTRON_DESKTOP_SHORTCUTS,
storagePath: appStorageDir,
themeMode: 'system',
+19 -53
View File
@@ -21,7 +21,12 @@ const logger = createLogger('controllers:AuthCtr');
const MAX_POLL_TIME = 2 * 60 * 1000; // 2 minutes (reduced from 5 minutes for better UX)
const POLL_INTERVAL = 3000; // 3 seconds
const TOKEN_REFRESH_DEBOUNCE = 5 * 60 * 1000; // 5 minutes - debounce interval to prevent excessive refreshes on rapid app restarts
// Refresh the access token only once it is within this window of its expiry. Kept
// small (minutes) on purpose: a buffer that is large relative to the server's
// access-token lifetime makes the token look "expiring soon" right after login,
// refreshing on every launch/activation and churning refresh-token rotations.
const TOKEN_REFRESH_BUFFER = 10 * 60 * 1000; // 10 minutes
/**
* Authentication Controller
@@ -292,8 +297,7 @@ export default class AuthCtr extends ControllerModule {
this.autoRefreshTimer = setInterval(async () => {
try {
// Check if token is expiring soon (refresh 5 minutes in advance)
if (!this.remoteServerConfigCtr.isTokenExpiringSoon()) {
if (!this.remoteServerConfigCtr.isTokenExpiringSoon(TOKEN_REFRESH_BUFFER)) {
return;
}
const expiresAt = this.remoteServerConfigCtr.getTokenExpiresAt();
@@ -683,7 +687,7 @@ export default class AuthCtr extends ControllerModule {
/**
* Initialize auto-refresh functionality
* Checks for valid token at app startup and starts auto-refresh timer if token exists
* Proactively refreshes token on every startup (with 5-minute debounce to prevent rapid restart issues)
* Proactively refreshes the token only when it is expired or near expiry
*/
private async initializeAutoRefresh() {
try {
@@ -711,26 +715,18 @@ export default class AuthCtr extends ControllerModule {
return;
}
const currentTime = Date.now();
// Check if token has already expired
if (currentTime >= expiresAt) {
logger.info('Token has expired, attempting to refresh it');
await this.performProactiveRefresh();
return;
}
// Proactively refresh token if it hasn't been refreshed in the last 6 hours
// This ensures token validity even if the server has revoked it
if (this.shouldProactivelyRefresh()) {
logger.info('Token refresh interval exceeded, proactively refreshing token on startup');
// Refresh proactively only when the token is actually near expiry. The access
// token is long-lived; refreshing on every launch just multiplies refresh-token
// rotations — and the chance of a lost-response logout — for no benefit.
if (this.remoteServerConfigCtr.isTokenExpiringSoon(TOKEN_REFRESH_BUFFER)) {
logger.info('Token is expired or expiring soon, refreshing on startup');
await this.performProactiveRefresh();
return;
}
// Start auto-refresh timer
logger.info(
`Token is valid and recently refreshed, starting auto-refresh timer. Token expires at: ${new Date(expiresAt).toISOString()}`,
`Token is valid, starting auto-refresh timer. Token expires at: ${new Date(expiresAt).toISOString()}`,
);
this.startAutoRefresh();
} catch (error) {
@@ -738,36 +734,6 @@ export default class AuthCtr extends ControllerModule {
}
}
/**
* Check if token should be proactively refreshed
* Returns true if the token hasn't been refreshed recently (within debounce interval)
* This ensures we refresh on every app launch while preventing excessive refreshes on rapid restarts
*/
private shouldProactivelyRefresh(): boolean {
const lastRefreshAt = this.remoteServerConfigCtr.getLastTokenRefreshAt();
// If never refreshed, should refresh
if (!lastRefreshAt) {
logger.debug('No last refresh time found, should proactively refresh');
return true;
}
const timeSinceLastRefresh = Date.now() - lastRefreshAt;
const shouldRefresh = timeSinceLastRefresh >= TOKEN_REFRESH_DEBOUNCE;
if (shouldRefresh) {
logger.debug(
`Time since last refresh: ${Math.round(timeSinceLastRefresh / 1000 / 60)} minutes, exceeds ${TOKEN_REFRESH_DEBOUNCE / 1000 / 60} minutes debounce threshold`,
);
} else {
logger.debug(
`Time since last refresh: ${Math.round(timeSinceLastRefresh / 1000 / 60)} minutes, within ${TOKEN_REFRESH_DEBOUNCE / 1000 / 60} minutes debounce threshold, skipping refresh`,
);
}
return shouldRefresh;
}
/**
* Perform proactive token refresh (used on startup and app activation)
*/
@@ -796,7 +762,7 @@ export default class AuthCtr extends ControllerModule {
/**
* Handle app activation event (e.g., Mac dock click, window focus)
* Proactively refresh token if needed (respects 6-hour interval)
* Proactively refresh token if it is expired or near expiry
*/
async onAppActivate(): Promise<void> {
logger.debug('App activated, checking if token refresh is needed');
@@ -817,12 +783,12 @@ export default class AuthCtr extends ControllerModule {
return;
}
// Only refresh if interval has passed
if (this.shouldProactivelyRefresh()) {
logger.info('Token refresh interval exceeded on app activation, refreshing token');
// Refresh only when the token is actually near expiry (see initializeAutoRefresh).
if (this.remoteServerConfigCtr.isTokenExpiringSoon(TOKEN_REFRESH_BUFFER)) {
logger.info('Token is expiring soon on app activation, refreshing token');
await this.performProactiveRefresh();
} else {
logger.debug('Token was recently refreshed, skipping activation refresh');
logger.debug('Token is still valid, skipping activation refresh');
}
} catch (error) {
logger.error('Error during app activation refresh check:', error);
@@ -1,3 +1,7 @@
import { execFileSync, execSync, spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import type { AgentRunRequestMessage } from '@lobechat/device-gateway-client';
import type { GatewayConnectionStatus } from '@lobechat/electron-client-ipc';
@@ -9,6 +13,48 @@ import LocalFileCtr from './LocalFileCtr';
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
import ShellCommandCtr from './ShellCommandCtr';
const DEFAULT_HERMES_PORT = 3456;
/**
* Inject the lh-notify protocol into the first turn of a new hetero-agent session.
* Tells the agent binary how to push results back to the LobeHub chat UI via `lh notify`.
* Ported directly from apps/cli/src/tools/heteroTask.ts so desktop and CLI stay in sync.
*/
function buildNotifyProtocol(lhPath: string, topicId: string): string {
return (
`## Context: This task was dispatched by LobeHub\n\n` +
`This conversation / task was sent to you by the **LobeHub platform** on behalf of a user. You are running as a background agent; the user is waiting for your response inside the LobeHub chat interface.\n\n` +
`**When to call notify**: any time you have something meaningful to tell the user — a key finding, a decision you made, a result, a question, or your final answer.\n\n` +
`**What to hide**: internal work details such as tool call sequences, file reads, intermediate command output, retries, or low-level reasoning steps.\n\n` +
`## Sending messages back to the user\n\n` +
`Use the \`${lhPath} notify\` command. All your updates appear as a **single message bubble** in the UI — create it once and update it in place.\n\n` +
`**Step 1 — Open the bubble on your first meaningful update** (captures the messageId):\n` +
`\`\`\`\n` +
`MSG_ID=$(${lhPath} notify --topic ${topicId} --role assistant --content "Starting..." --json | grep -o '"messageId":"[^"]*"' | cut -d'"' -f4)\n` +
`\`\`\`\n\n` +
`**Step 2 — Update the same bubble as you make progress**:\n` +
`\`\`\`\n` +
`${lhPath} notify --topic ${topicId} --role assistant --message-id "$MSG_ID" --content "Still working..."\n` +
`\`\`\`\n\n` +
`**Step 3 — Replace with your complete, final response when done**:\n` +
`\`\`\`\n` +
`${lhPath} notify --topic ${topicId} --role assistant --message-id "$MSG_ID" --content "<your full response here>"\n` +
`\`\`\`\n\n` +
`Rules:\n` +
`- Always use \`--json\` on the first call and capture \`messageId\` from the output.\n` +
`- Always pass \`--message-id\` on every subsequent call so updates overwrite the same bubble.\n` +
`- Call notify at least once when the task is done, even if there were no intermediate updates.`
);
}
interface PlatformTaskEntry {
agentId?: string;
agentType: string;
operationId: string;
pid: number;
topicId: string;
}
/**
* GatewayConnectionCtr
*
@@ -17,6 +63,9 @@ import ShellCommandCtr from './ShellCommandCtr';
export default class GatewayConnectionCtr extends ControllerModule {
static override readonly groupName = 'gatewayConnection';
/** In-memory registry for running platform agent tasks (openclaw / hermes). */
private readonly platformTasks = new Map<string, PlatformTaskEntry>();
// ─── Service Accessor ───
private get service() {
@@ -125,11 +174,15 @@ export default class GatewayConnectionCtr extends ControllerModule {
try {
const ctr = this.heterogeneousAgentCtr;
// Map agentType to binary name.
// claude-code → `claude` CLI; all other platforms use their type name as the binary.
const command = request.agentType === 'claude-code' ? 'claude' : request.agentType;
// Create a session for the hetero agent.
const { sessionId } = await ctr.startSession({
agentType: request.agentType,
args: [],
command: request.agentType === 'codex' ? 'codex' : 'claude',
command,
cwd: request.cwd,
// Inject LOBEHUB_JWT so the CLI authenticates against heteroIngest.
env: { LOBEHUB_JWT: request.jwt },
@@ -192,6 +245,14 @@ export default class GatewayConnectionCtr extends ControllerModule {
renameLocalFile: () => this.localFileCtr.handleRenameFile(args),
searchLocalFiles: searchFiles,
writeLocalFile: writeFile,
// Platform agent capability probing
checkPlatformCapability: () => this.checkPlatformCapability(args),
getAgentProfile: () => this.getAgentProfile(args),
// Platform agent task execution (openclaw / hermes)
cancelHeteroTask: () => this.cancelHeteroTask(args),
runHeteroTask: () => this.runHeteroTask(args),
};
const handler = methodMap[apiName];
@@ -203,4 +264,331 @@ export default class GatewayConnectionCtr extends ControllerModule {
return handler();
}
// ─── Platform Capability Probing ───
private async checkPlatformCapability(args: {
platform: string;
}): Promise<{ available: boolean; reason?: string; version?: string }> {
const { platform } = args;
const binaryMap: Record<string, string> = {
hermes: 'hermes',
openclaw: 'openclaw',
};
const binary = binaryMap[platform];
if (!binary) {
return { available: false, reason: `Unknown platform: ${platform}` };
}
const whichCmd = process.platform === 'win32' ? `where ${binary}` : `which ${binary}`;
try {
execSync(whichCmd, { stdio: 'pipe' });
} catch {
return { available: false, reason: `${platform} is not installed on this device` };
}
try {
const raw = execSync(`${binary} --version`, {
encoding: 'utf8',
stdio: 'pipe',
}).trim();
return { available: true, version: raw };
} catch {
return { available: true };
}
}
private async getAgentProfile(args: { agentId?: string; platform: string }): Promise<{
avatar?: string;
description?: string;
title?: string;
}> {
const { platform, agentId } = args;
if (platform === 'openclaw') {
return this.getOpenClawProfile(agentId);
}
// hermes and unknown platforms: not yet implemented
return {};
}
private getOpenClawProfile(agentId?: string): {
avatar?: string;
description?: string;
title?: string;
} {
let output: string;
try {
output = execFileSync('openclaw', ['agents', 'list', '--json'], {
encoding: 'utf8',
timeout: 5000,
});
} catch {
return {};
}
let agents: Array<{
id: string;
identityEmoji?: string;
identityName?: string;
isDefault?: boolean;
workspace?: string;
}>;
try {
agents = JSON.parse(output) as typeof agents;
} catch {
return {};
}
const agent = agentId
? agents.find((a) => a.id === agentId)
: (agents.find((a) => a.isDefault) ?? agents[0]);
if (!agent) return {};
const title = agent.identityName || undefined;
const avatar = agent.identityEmoji || '🦞';
const description = agent.workspace
? this.readDescriptionFromWorkspace(agent.workspace)
: undefined;
return { avatar, description, title };
}
private readDescriptionFromWorkspace(workspacePath: string): string | undefined {
for (const filename of ['IDENTITY.md', 'SOUL.md']) {
const filePath = path.join(workspacePath, filename);
if (!fs.existsSync(filePath)) continue;
const content = fs.readFileSync(filePath, 'utf8');
const match = content.match(/\*{0,2}(?:Creature|Vibe|Description):?\*{0,2}\s*(.+)/i);
if (!match) continue;
const value = match[1].trim();
if (/^[_*(].*[)*_]$|^(?:tbd|todo|n\/?a|none|待定|未定)$/i.test(value)) continue;
return value;
}
}
// ─── Platform Agent Task Execution ───
//
// Ported from apps/cli/src/tools/heteroTask.ts so that devices connected via
// the desktop gateway can execute openclaw/hermes tasks without requiring `lh connect`.
private async runHeteroTask(args: {
agentId?: string;
agentType: string;
cwd?: string;
operationId: string;
prompt: string;
taskId: string;
topicId: string;
}): Promise<string> {
const { agentId, agentType, cwd, operationId, prompt, taskId, topicId } = args;
const workDir = cwd || process.cwd();
if (agentType === 'openclaw') {
const lhPath = this.resolveLhPath();
const openclawAgent = process.env['OPENCLAW_AGENT_ID'] ?? 'main';
// Always inject the notify protocol so openclaw knows how to report results
// back to the LobeHub UI — even if the previous turn failed and the session
// history was not cleanly committed.
const enrichedPrompt = `${prompt}\n\n${buildNotifyProtocol(lhPath, topicId)}`;
// Kill any existing openclaw process for this topicId before spawning a new one.
// openclaw serialises session writes; a concurrent process holding the session
// lock will cause the new one to exit with code 1.
for (const [existingTaskId, entry] of this.platformTasks) {
if (entry.topicId === topicId && entry.agentType === 'openclaw') {
try {
process.kill(entry.pid, 'SIGTERM');
} catch {
// Already exited — nothing to do.
}
this.platformTasks.delete(existingTaskId);
}
}
const child = spawn(
'openclaw',
[
'agent',
'--agent',
openclawAgent,
'--session-id',
topicId,
'--message',
enrichedPrompt,
'--local',
],
{ cwd: workDir, detached: true, env: { ...process.env }, stdio: 'ignore' },
);
const pid = child.pid;
if (pid === undefined) throw new Error('Failed to get PID for openclaw process');
child.unref();
this.platformTasks.set(taskId, { agentId, agentType, operationId, pid, topicId });
child.on('close', (code, signal) => {
this.platformTasks.delete(taskId);
if (code !== 0 || signal !== null) {
const text = signal
? `Task cancelled (signal: ${signal})`
: `Task failed (exit code: ${code})`;
void this.sendNotify({ agentId, content: text, role: 'assistant', topicId }).finally(() =>
this.sendNotify({ agentId, content: '', done: true, role: 'assistant', topicId }),
);
} else {
void this.sendNotify({ agentId, content: '', done: true, role: 'assistant', topicId });
}
});
return JSON.stringify({ pid, taskId });
}
if (agentType === 'hermes') {
const port = this.getHermesPort();
if (!(await this.isHermesRunning(port))) {
await this.startHermesGateway(port);
}
const res = await fetch(`http://localhost:${port}/message`, {
body: JSON.stringify({ content: prompt, operationId }),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
if (!res.ok) throw new Error(`Hermes gateway returned ${res.status}: ${await res.text()}`);
this.platformTasks.set(taskId, { agentId, agentType, operationId, pid: 0, topicId });
return JSON.stringify({ operationId, taskId });
}
throw new Error(`Unsupported agentType: ${agentType}`);
}
private async cancelHeteroTask(args: { signal?: string; taskId: string }): Promise<string> {
const { signal = 'SIGINT', taskId } = args;
const entry = this.platformTasks.get(taskId);
if (!entry) {
return JSON.stringify({ message: `No task found with taskId: ${taskId}`, success: false });
}
if (entry.agentType === 'hermes') {
const port = this.getHermesPort();
try {
await fetch(`http://localhost:${port}/stop`, {
body: JSON.stringify({ operationId: entry.operationId }),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
} catch {
// Hermes gateway may already have stopped; ignore
}
this.platformTasks.delete(taskId);
await this.sendNotify({
agentId: entry.agentId,
content: 'Task cancelled',
role: 'assistant',
topicId: entry.topicId,
});
return JSON.stringify({ taskId });
}
// openclaw: kill by PID; the close handler sends the done signal.
try {
process.kill(entry.pid, signal);
} catch {
this.platformTasks.delete(taskId);
await this.sendNotify({
agentId: entry.agentId,
content: 'Task already completed or cancelled',
role: 'assistant',
topicId: entry.topicId,
});
}
return JSON.stringify({ pid: entry.pid, signal, taskId });
}
/**
* Send a notify message to the server so the frontend receives agent output or
* a completion signal. Uses the tRPC agentNotify.notify endpoint directly
* this is the desktop counterpart to `lh notify` used by the CLI path.
*/
private async sendNotify(params: {
agentId?: string;
content: string;
done?: boolean;
role: string;
topicId: string;
}): Promise<void> {
try {
const [serverUrl, token] = await Promise.all([
this.remoteServerConfigCtr.getRemoteServerUrl(),
this.remoteServerConfigCtr.getAccessToken(),
]);
if (!serverUrl || !token) return;
await fetch(`${serverUrl}/trpc/agentNotify.notify`, {
body: JSON.stringify({ json: params }),
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
method: 'POST',
});
} catch {
// Fire-and-forget: openclaw's own `lh notify` calls are the primary channel.
}
}
// ─── Platform Agent Helpers ───
private resolveLhPath(): string {
try {
return execFileSync('which', ['lh'], { encoding: 'utf8' }).trim();
} catch {
return 'lh';
}
}
private getHermesPort(): number {
const env = process.env['HERMES_GATEWAY_PORT'];
if (env) {
const parsed = Number.parseInt(env, 10);
if (!Number.isNaN(parsed)) return parsed;
}
return DEFAULT_HERMES_PORT;
}
private async isHermesRunning(port: number): Promise<boolean> {
try {
return (await fetch(`http://localhost:${port}/health`)).ok;
} catch {
return false;
}
}
private async startHermesGateway(port: number): Promise<void> {
const child = spawn('hermes', ['gateway', 'start'], {
detached: true,
env: { ...process.env },
stdio: 'ignore',
});
child.unref();
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
await new Promise<void>((r) => setTimeout(r, 500));
if (await this.isHermesRunning(port)) return;
}
throw new Error(`Hermes gateway did not start within 10s on port ${port}`);
}
}
+181 -12
View File
@@ -20,6 +20,7 @@ import type {
GitWorkingTreePatch,
GitWorkingTreePatches,
GitWorkingTreeStatus,
SubmoduleWorkingTreePatches,
} from '@lobechat/electron-client-ipc';
import { detectRepoType, resolveGitDir } from '@/utils/git';
@@ -739,9 +740,73 @@ export default class GitController extends ControllerModule {
*
* Per-file patches are capped at 256 KB; oversized or binary entries get an
* empty `patch` string and a flag the renderer can use for a placeholder.
*
* Dirty submodules are detected via `git submodule status` and surfaced as
* grouped `submodules[]` entries their internal patches live under each
* group, not in the parent's flat `patches` list. Nested submodules are not
* traversed (phase 1).
*/
@IpcMethod()
async getGitWorkingTreePatches(dirPath: string): Promise<GitWorkingTreePatches> {
return this.collectWorkingTreePatches(dirPath, true);
}
/**
* List paths of initialized submodules registered in `dirPath`. Uninitialized
* entries (`-` prefix in `git submodule status`) are skipped there's no
* working tree to inspect for those. Failures (no submodules, shell errors)
* return an empty set so callers gracefully fall back to the flat layout.
*
* Only direct submodules are listed; nested submodules would need
* `--recursive` plus a tree-aware renderer we don't have in phase 1.
*/
private async listSubmodulePaths(dirPath: string): Promise<Set<string>> {
const execFileAsync = promisify(execFile);
try {
const { stdout } = await execFileAsync('git', ['submodule', 'status'], {
cwd: dirPath,
timeout: 5000,
});
const paths = new Set<string>();
for (const line of stdout.split('\n')) {
if (line.length < 2) continue;
// Status char: ' ' (clean), '+' (modified content), '-' (uninit), 'U' (conflict).
if (line[0] === '-') continue;
// Format: "<status><sha> <path>[ (<describe>)]". Parse via string ops
// rather than a single regex — combining `\s+` separators with a
// greedy/lazy path capture trips eslint's ReDoS rule.
const rest = line.slice(1);
const firstSpace = rest.indexOf(' ');
if (firstSpace < 0) continue;
const sha = rest.slice(0, firstSpace);
if (!/^[\da-f]{7,40}$/.test(sha)) continue;
let path = rest.slice(firstSpace + 1);
// Drop the trailing ` (<describe>)` suffix when present.
if (path.endsWith(')')) {
const describeStart = path.lastIndexOf(' (');
if (describeStart > 0) path = path.slice(0, describeStart);
}
if (path) paths.add(path);
}
return paths;
} catch (error: any) {
logger.debug('[listSubmodulePaths] failed', {
cwd: dirPath,
stderr: error?.stderr?.toString?.() ?? error?.stderr,
});
return new Set();
}
}
/**
* Shared implementation for working-tree patch collection. The IPC entry
* passes `recurseSubmodules: true`; recursive calls into each submodule pass
* `false` to avoid traversing nested submodules (phase 1).
*/
private async collectWorkingTreePatches(
dirPath: string,
recurseSubmodules: boolean,
): Promise<GitWorkingTreePatches> {
const MAX_PATCH_BYTES = 256 * 1024;
const execFileAsync = promisify(execFile);
@@ -751,10 +816,19 @@ export default class GitController extends ControllerModule {
status: GitFileDiffStatus;
}
// Step 0 — when recursion is enabled, learn which paths in the parent's
// status are submodule roots. Their internal diffs are collected separately
// (see Step 4) so we filter them out of the parent's flat patch list.
const submodulePaths = recurseSubmodules
? await this.listSubmodulePaths(dirPath)
: new Set<string>();
// Step 1 — classify every dirty path. Mirrors getGitWorkingTreeFiles but
// also distinguishes untracked (`??`) from staged-add (`A`) so we can pick
// the right path (git diff vs raw read) per entry.
// the right path (git diff vs raw read) per entry. Submodule entries are
// siphoned into `submoduleDirtyEntries` for separate recursion in Step 4.
const entries: Entry[] = [];
const submoduleDirtyEntries: Entry[] = [];
try {
const { stdout } = await execFileAsync('git', ['status', '--porcelain', '-z'], {
cwd: dirPath,
@@ -772,20 +846,27 @@ export default class GitController extends ControllerModule {
// R/C entries carry an extra source-path token we must consume.
if (x === 'R' || x === 'C') i++;
if (!filePath) continue;
let parsed: Entry | null = null;
if (x === '?' && y === '?') {
entries.push({ filePath, isUntracked: true, status: 'added' });
parsed = { filePath, isUntracked: true, status: 'added' };
} else if (x === '!' && y === '!') {
// ignored
} else if (x === 'D' || y === 'D') {
entries.push({ filePath, isUntracked: false, status: 'deleted' });
parsed = { filePath, isUntracked: false, status: 'deleted' };
} else if (x === 'A' || y === 'A') {
entries.push({ filePath, isUntracked: false, status: 'added' });
parsed = { filePath, isUntracked: false, status: 'added' };
} else {
entries.push({ filePath, isUntracked: false, status: 'modified' });
parsed = { filePath, isUntracked: false, status: 'modified' };
}
if (!parsed) continue;
if (submodulePaths.has(filePath)) {
submoduleDirtyEntries.push(parsed);
} else {
entries.push(parsed);
}
}
} catch (error: any) {
logger.warn('[getGitWorkingTreePatches] status failed', {
logger.warn('[collectWorkingTreePatches] status failed', {
cwd: dirPath,
stderr: error?.stderr?.toString?.() ?? error?.stderr,
});
@@ -818,7 +899,7 @@ export default class GitController extends ControllerModule {
30_000,
);
} catch (error: any) {
logger.warn('[getGitWorkingTreePatches] bulk diff failed; per-file fallback', {
logger.warn('[collectWorkingTreePatches] bulk diff failed; per-file fallback', {
cwd: dirPath,
stderr: error?.stderr?.toString?.() ?? error?.stderr,
});
@@ -855,7 +936,33 @@ export default class GitController extends ControllerModule {
const allPatches: GitWorkingTreePatch[] = [...trackedPatches.values(), ...untrackedPatches];
allPatches.sort((a, b) => order[a.status] - order[b.status]);
return { patches: allPatches };
// Step 4 — for each dirty submodule, recurse for its own patches + branch.
// We only descend one level (`recurseSubmodules: false` on the inner call)
// because phase 1's UI groups direct children; nested submodules would
// need a tree view we don't have yet. Empty groups (pointer-only bumps)
// are kept so the user still sees the submodule surfaced in the panel.
let submodules: SubmoduleWorkingTreePatches[] | undefined;
if (submoduleDirtyEntries.length > 0) {
submodules = await Promise.all(
submoduleDirtyEntries.map(async (entry) => {
const absolutePath = path.resolve(dirPath, entry.filePath);
const [sub, branchInfo] = await Promise.all([
this.collectWorkingTreePatches(absolutePath, false),
this.getGitBranch(absolutePath),
]);
return {
absolutePath,
branch: branchInfo.branch,
detached: branchInfo.detached,
name: path.basename(entry.filePath),
patches: sub.patches,
relativePath: entry.filePath,
};
}),
);
}
return { patches: allPatches, submodules };
}
/**
@@ -876,7 +983,23 @@ export default class GitController extends ControllerModule {
*/
@IpcMethod()
async getGitBranchDiff(payload: GetGitBranchDiffPayload): Promise<GitBranchDiffPatches> {
const { path: dirPath, baseRef: baseRefOverride } = payload;
return this.collectBranchDiff(payload.path, payload.baseRef, true);
}
/**
* Shared implementation for branch-diff collection. The IPC entry passes
* `recurseSubmodules: true`; recursive calls into each submodule pass
* `false` to avoid traversing nested submodules (phase 1). Each submodule's
* base ref is resolved independently we don't try to derive it from the
* parent's base because (a) the parent's submodule pointer may not exist
* as a branch ref inside the submodule and (b) "this submodule's branch
* vs its own remote default" is what users typically want.
*/
private async collectBranchDiff(
dirPath: string,
baseRefOverride: string | undefined,
recurseSubmodules: boolean,
): Promise<GitBranchDiffPatches> {
const MAX_PATCH_BYTES = 256 * 1024;
const execFileAsync = promisify(execFile);
@@ -930,7 +1053,7 @@ export default class GitController extends ControllerModule {
30_000,
);
} catch (error: any) {
logger.warn('[getGitBranchDiff] diff failed', {
logger.warn('[collectBranchDiff] diff failed', {
baseRef,
cwd: dirPath,
stderr: error?.stderr?.toString?.() ?? error?.stderr,
@@ -938,9 +1061,20 @@ export default class GitController extends ControllerModule {
if (typeof error?.partialStdout === 'string') bulkDiff = error.partialStdout;
}
// Step 4 — split + classify per-file from the diff preamble alone.
// Step 4 — split per-file. When submodule recursion is enabled, peel out
// any pointer-bump entries (block path matches a registered submodule)
// into `pointerBumpPaths`; we'll surface those groups unconditionally in
// Step 5 even if the submodule's own branch is clean.
const submodulePaths = recurseSubmodules
? await this.listSubmodulePaths(dirPath)
: new Set<string>();
const patches: GitWorkingTreePatch[] = [];
const pointerBumpPaths = new Set<string>();
for (const block of splitBulkDiff(bulkDiff)) {
if (submodulePaths.has(block.path)) {
pointerBumpPaths.add(block.path);
continue;
}
const status = detectDiffBlockStatus(block.patch);
patches.push(buildTrackedPatch({ filePath: block.path, status }, block, MAX_PATCH_BYTES));
}
@@ -948,7 +1082,42 @@ export default class GitController extends ControllerModule {
const order: Record<GitFileDiffStatus, number> = { added: 0, modified: 1, deleted: 2 };
patches.sort((a, b) => order[a.status] - order[b.status]);
return { baseRef, headRef, patches };
// Step 5 — recurse for EVERY registered submodule (not just those with
// pointer-bumps) so we also surface submodules whose own branch diverges
// from its own origin/HEAD even when the parent's pointer is unchanged.
// Single-level only (`recurseSubmodules: false` on the inner call). A
// group is kept when EITHER its pointer changed in the parent OR its own
// branch diff has at least one patch; submodules that are clean on both
// axes are dropped to keep the panel quiet. Submodule count is expected
// to be small (single digits in practice), so per-submodule fetch + diff
// in parallel is acceptable.
let submodules: SubmoduleWorkingTreePatches[] | undefined;
if (submodulePaths.size > 0) {
const candidates = await Promise.all(
Array.from(submodulePaths).map(async (relativePath) => {
const absolutePath = path.resolve(dirPath, relativePath);
const [sub, branchInfo] = await Promise.all([
this.collectBranchDiff(absolutePath, undefined, false),
this.getGitBranch(absolutePath),
]);
return {
group: {
absolutePath,
branch: branchInfo.branch,
detached: branchInfo.detached,
name: path.basename(relativePath),
patches: sub.patches,
relativePath,
},
keep: pointerBumpPaths.has(relativePath) || sub.patches.length > 0,
};
}),
);
const filtered = candidates.filter((c) => c.keep).map((c) => c.group);
if (filtered.length > 0) submodules = filtered;
}
return { baseRef, headRef, patches, submodules };
}
/**
@@ -605,7 +605,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
}
}
// ─── AskUserQuestion MCP server (LOBE-8725) ───
// ─── AskUserQuestion MCP server () ───
/**
* Lazy single-instance MCP server for CC's AskUserQuestion replacement.
@@ -651,7 +651,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
// `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
// (no ToolSearch hop). See spike notes — falls back to the
// 2-hop ToolSearch path if a future CC drops the flag, no breakage.
const config = {
mcpServers: {
@@ -43,17 +43,18 @@ import {
import {
editLocalFile,
expandTilde,
type FileResult,
listLocalFiles,
moveLocalFiles,
readLocalFile,
renameLocalFile,
type SearchOptions,
writeLocalFile,
} from '@lobechat/local-file-shell';
import { dialog, shell } from 'electron';
import { execa } from 'execa';
import { unzipSync } from 'fflate';
import { type FileResult, type SearchOptions } from '@/modules/fileSearch';
import ContentSearchService from '@/services/contentSearchSrv';
import FileSearchService from '@/services/fileSearchSrv';
import { createLogger } from '@/utils/logger';
@@ -2,7 +2,6 @@ import querystring from 'node:querystring';
import { URL } from 'node:url';
import type { DataSyncConfig } from '@lobechat/electron-client-ipc';
import retry from 'async-retry';
import { safeStorage, session as electronSession } from 'electron';
import { OFFICIAL_CLOUD_SERVER } from '@/const/env';
@@ -378,10 +377,8 @@ export default class RemoteServerConfigCtr extends ControllerModule {
}
/**
* Refresh access token with retry mechanism
* Use stored refresh token to obtain a new access token
* Handles concurrent requests by returning the existing refresh promise if one is in progress.
* Retries up to 3 times with exponential backoff for transient errors.
* Refresh the access token using the stored refresh token (single attempt).
* Concurrent callers share the in-progress refresh promise.
*/
async refreshAccessToken(): Promise<{ error?: string; success: boolean }> {
// If a refresh is already in progress, return the existing promise
@@ -390,60 +387,18 @@ export default class RemoteServerConfigCtr extends ControllerModule {
return this.refreshPromise;
}
// Start a new refresh operation with retry
logger.info('Initiating new token refresh operation with retry.');
this.refreshPromise = this.performTokenRefreshWithRetry();
logger.info('Initiating new token refresh operation.');
// Return the promise so callers can wait
return this.refreshPromise;
}
/**
* Performs token refresh with retry mechanism
* Uses exponential backoff: 1s, 2s, 4s
*/
private async performTokenRefreshWithRetry(): Promise<{ error?: string; success: boolean }> {
try {
return await retry(
async (bail, attemptNumber) => {
logger.debug(`Token refresh attempt ${attemptNumber}/3`);
const result = await this.performTokenRefresh();
if (result.success) {
return result;
}
// Check if error is non-retryable
if (this.isNonRetryableError(result.error)) {
logger.warn(`Non-retryable error encountered: ${result.error}`);
// Use bail to stop retrying immediately
bail(new Error(result.error));
return result; // This won't be reached, but TypeScript needs it
}
// Throw error to trigger retry for transient errors
throw new Error(result.error);
},
{
factor: 2, // Exponential backoff factor
maxTimeout: 4000, // Max wait time between retries: 4s
minTimeout: 1000, // Min wait time between retries: 1s
onRetry: (err: Error, attempt: number) => {
logger.info(`Token refresh retry ${attempt}/3: ${err.message}`);
},
retries: 3, // Total retry attempts
},
);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error('Token refresh failed after all retries:', errorMessage);
return { error: errorMessage, success: false };
} finally {
// Ensure the promise reference is cleared once the operation completes
// No retry: with refresh token rotation the server consumes the old token as soon
// as the request lands. Resending it (e.g. after a lost response) triggers reuse
// detection — invalid_grant + revocation of the whole grant — which logs the user
// out. Transient failures are recovered by the next refresh cycle instead.
this.refreshPromise = this.performTokenRefresh().finally(() => {
logger.debug('Clearing the refresh promise reference.');
this.refreshPromise = null;
}
});
return this.refreshPromise;
}
/**
@@ -721,10 +721,7 @@ describe('AuthCtr', () => {
});
describe('Proactive Token Refresh', () => {
const FIVE_MINUTES = 5 * 60 * 1000; // Debounce interval
beforeEach(() => {
// Reset mocks for proactive refresh tests
vi.mocked(mockRemoteServerConfigCtr.getRemoteServerConfig).mockResolvedValue({
active: true,
remoteServerUrl: 'https://lobehub-cloud.com',
@@ -733,22 +730,16 @@ describe('AuthCtr', () => {
vi.mocked(mockRemoteServerConfigCtr.isRemoteServerConfigured).mockResolvedValue(true);
vi.mocked(mockRemoteServerConfigCtr.getAccessToken).mockResolvedValue('mock-access-token');
vi.mocked(mockRemoteServerConfigCtr.getTokenExpiresAt).mockReturnValue(
Date.now() + 3600000, // Token valid for 1 hour
Date.now() + 7 * 24 * 60 * 60 * 1000, // Token valid for 7 days
);
// Reset getLastTokenRefreshAt to a recent value by default
// Individual tests will override this as needed
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(Date.now());
vi.mocked(mockRemoteServerConfigCtr.isTokenExpiringSoon).mockReturnValue(false);
vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({ success: true });
vi.mocked(mockRemoteServerConfigCtr.isNonRetryableError).mockReturnValue(false);
});
describe('onAppActivate', () => {
it('should refresh token when last refresh was more than 5 minutes ago', async () => {
// Last refresh was 10 minutes ago (exceeds 5-minute debounce)
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
Date.now() - 10 * 60 * 1000,
);
vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({
success: true,
});
it('should refresh token when it is expiring soon', async () => {
vi.mocked(mockRemoteServerConfigCtr.isTokenExpiringSoon).mockReturnValue(true);
await authCtr.onAppActivate();
@@ -756,33 +747,27 @@ describe('AuthCtr', () => {
expect(mockWindow.webContents.send).toHaveBeenCalledWith('tokenRefreshed');
});
it('should NOT refresh token when last refresh was within 5 minutes', async () => {
// Last refresh was 2 minutes ago (within 5-minute debounce)
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
Date.now() - 2 * 60 * 1000,
);
it('should NOT refresh token when it is not expiring soon', async () => {
vi.mocked(mockRemoteServerConfigCtr.isTokenExpiringSoon).mockReturnValue(false);
await authCtr.onAppActivate();
expect(mockRemoteServerConfigCtr.refreshAccessToken).not.toHaveBeenCalled();
});
it('should refresh token when lastRefreshAt is undefined (never refreshed)', async () => {
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(undefined);
vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({
success: true,
});
it('should check expiry with a small buffer, not the 24h default', async () => {
vi.mocked(mockRemoteServerConfigCtr.isTokenExpiringSoon).mockReturnValue(false);
await authCtr.onAppActivate();
expect(mockRemoteServerConfigCtr.refreshAccessToken).toHaveBeenCalled();
const [buffer] = vi.mocked(mockRemoteServerConfigCtr.isTokenExpiringSoon).mock.calls[0];
expect(buffer).toBeGreaterThan(0);
expect(buffer).toBeLessThanOrEqual(60 * 60 * 1000);
});
it('should skip refresh when remote server is not active', async () => {
vi.mocked(mockRemoteServerConfigCtr.isRemoteServerConfigured).mockResolvedValue(false);
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
Date.now() - 10 * 60 * 1000,
);
vi.mocked(mockRemoteServerConfigCtr.isTokenExpiringSoon).mockReturnValue(true);
await authCtr.onAppActivate();
@@ -791,19 +776,15 @@ describe('AuthCtr', () => {
it('should skip refresh when no access token exists', async () => {
vi.mocked(mockRemoteServerConfigCtr.getAccessToken).mockResolvedValue(null);
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
Date.now() - 10 * 60 * 1000,
);
vi.mocked(mockRemoteServerConfigCtr.isTokenExpiringSoon).mockReturnValue(true);
await authCtr.onAppActivate();
expect(mockRemoteServerConfigCtr.refreshAccessToken).not.toHaveBeenCalled();
});
it('should handle refresh failure with non-retryable error', async () => {
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
Date.now() - 10 * 60 * 1000,
);
it('should clear tokens and require re-auth on non-retryable error', async () => {
vi.mocked(mockRemoteServerConfigCtr.isTokenExpiringSoon).mockReturnValue(true);
vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({
error: 'invalid_grant',
success: false,
@@ -819,10 +800,8 @@ describe('AuthCtr', () => {
expect(mockWindow.webContents.send).toHaveBeenCalledWith('authorizationRequired');
});
it('should handle refresh failure with transient error (start auto-refresh)', async () => {
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
Date.now() - 10 * 60 * 1000,
);
it('should preserve tokens on transient error', async () => {
vi.mocked(mockRemoteServerConfigCtr.isTokenExpiringSoon).mockReturnValue(true);
vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({
error: 'network_error',
success: false,
@@ -831,90 +810,49 @@ describe('AuthCtr', () => {
await authCtr.onAppActivate();
// Should not clear tokens for transient errors
expect(mockRemoteServerConfigCtr.clearTokens).not.toHaveBeenCalled();
});
});
describe('afterAppReady (initializeAutoRefresh)', () => {
it('should proactively refresh token on startup when debounce interval exceeded', async () => {
// Last refresh was 10 minutes ago (exceeds 5-minute debounce)
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
Date.now() - 10 * 60 * 1000,
);
vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({
success: true,
});
it('should proactively refresh on startup when token is expiring soon', async () => {
vi.mocked(mockRemoteServerConfigCtr.isTokenExpiringSoon).mockReturnValue(true);
authCtr.afterAppReady();
// Wait for async initialization
await new Promise((resolve) => setTimeout(resolve, 100));
expect(mockRemoteServerConfigCtr.refreshAccessToken).toHaveBeenCalled();
});
it('should NOT refresh on startup when token was recently refreshed (within debounce)', async () => {
// Last refresh was 2 minutes ago (within 5-minute debounce)
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
Date.now() - 2 * 60 * 1000,
);
it('should NOT refresh on startup when token is not expiring soon', async () => {
vi.mocked(mockRemoteServerConfigCtr.isTokenExpiringSoon).mockReturnValue(false);
authCtr.afterAppReady();
// Wait for async initialization
await new Promise((resolve) => setTimeout(resolve, 100));
expect(mockRemoteServerConfigCtr.refreshAccessToken).not.toHaveBeenCalled();
});
it('should refresh on startup when token is expired regardless of last refresh time', async () => {
// Token expired 1 hour ago
vi.mocked(mockRemoteServerConfigCtr.getTokenExpiresAt).mockReturnValue(
Date.now() - 60 * 60 * 1000,
);
// Last refresh was 2 minutes ago (within debounce, but token is expired)
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
Date.now() - 2 * 60 * 1000,
);
vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({
success: true,
});
it('should check expiry with a small buffer, not the 24h default', async () => {
vi.mocked(mockRemoteServerConfigCtr.isTokenExpiringSoon).mockReturnValue(false);
authCtr.afterAppReady();
// Wait for async initialization
await new Promise((resolve) => setTimeout(resolve, 100));
expect(mockRemoteServerConfigCtr.refreshAccessToken).toHaveBeenCalled();
const [buffer] = vi.mocked(mockRemoteServerConfigCtr.isTokenExpiringSoon).mock.calls[0];
expect(buffer).toBeGreaterThan(0);
expect(buffer).toBeLessThanOrEqual(60 * 60 * 1000);
});
});
describe('refresh debounce boundary tests', () => {
it('should NOT refresh at exactly 5 minutes minus 1 second', async () => {
// Last refresh was 4 minutes 59 seconds ago
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
Date.now() - (FIVE_MINUTES - 1000),
);
it('should skip initialization when no access token exists', async () => {
vi.mocked(mockRemoteServerConfigCtr.getAccessToken).mockResolvedValue(null);
vi.mocked(mockRemoteServerConfigCtr.isTokenExpiringSoon).mockReturnValue(true);
await authCtr.onAppActivate();
authCtr.afterAppReady();
await new Promise((resolve) => setTimeout(resolve, 100));
expect(mockRemoteServerConfigCtr.refreshAccessToken).not.toHaveBeenCalled();
});
it('should refresh at exactly 5 minutes', async () => {
// Last refresh was exactly 5 minutes ago
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
Date.now() - FIVE_MINUTES,
);
vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({
success: true,
});
await authCtr.onAppActivate();
expect(mockRemoteServerConfigCtr.refreshAccessToken).toHaveBeenCalled();
});
});
});
});
@@ -1,9 +1,12 @@
import type { execSync as ExecSyncType } from 'node:child_process';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { App } from '@/core/App';
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
import GatewayConnectionCtr from '../GatewayConnectionCtr';
import HeterogeneousAgentCtr from '../HeterogeneousAgentCtr';
import LocalFileCtr from '../LocalFileCtr';
import RemoteServerConfigCtr from '../RemoteServerConfigCtr';
import ShellCommandCtr from '../ShellCommandCtr';
@@ -31,6 +34,7 @@ const { ipcMainHandleMock, MockGatewayClient } = vi.hoisted(() => {
});
sendToolCallResponse = vi.fn();
sendAgentRunAck = vi.fn();
constructor(options: any) {
super();
@@ -71,6 +75,22 @@ const { ipcMainHandleMock, MockGatewayClient } = vi.hoisted(() => {
this.emit('error', new Error(message));
}
simulateAgentRunRequest(
agentType: string,
operationId = 'op-1',
prompt = 'hello',
jwt = 'mock-jwt',
) {
this.emit('agent_run_request', {
agentType,
jwt,
operationId,
prompt,
topicId: 'topic-1',
type: 'agent_run_request',
});
}
simulateReconnecting(delay: number) {
this.connectionStatus = 'reconnecting';
this.emit('status_changed', 'reconnecting');
@@ -89,6 +109,10 @@ vi.mock('electron', () => ({
getPath: vi.fn((name: string) => `/mock/${name}`),
},
ipcMain: { handle: ipcMainHandleMock },
powerSaveBlocker: {
start: vi.fn(() => 1),
stop: vi.fn(),
},
}));
vi.mock('@/utils/logger', () => ({
@@ -119,6 +143,15 @@ vi.mock('node:crypto', () => ({
randomUUID: vi.fn(() => 'mock-device-uuid'),
}));
const execSyncMock = vi.hoisted(() => vi.fn());
const execFileSyncMock = vi.hoisted(() => vi.fn());
const spawnMock = vi.hoisted(() => vi.fn());
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<{ execSync: typeof ExecSyncType }>();
return { ...actual, execFileSync: execFileSyncMock, execSync: execSyncMock, spawn: spawnMock };
});
vi.mock('node:os', () => ({
default: { hostname: vi.fn(() => 'mock-hostname') },
}));
@@ -127,6 +160,13 @@ vi.mock('@lobechat/device-gateway-client', () => ({
GatewayClient: MockGatewayClient,
}));
vi.mock('execa', () => ({
execa: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }),
}));
vi.mock('fast-glob', () => ({ default: vi.fn().mockResolvedValue([]) }));
vi.mock('fflate', () => ({ unzipSync: vi.fn() }));
// ─── Mock Controllers ───
const mockLocalFileCtr = {
@@ -158,6 +198,11 @@ const mockShellCommandCtr = {
handleRunCommand: vi.fn().mockResolvedValue({ success: true, stdout: '' }),
} as unknown as ShellCommandCtr;
const mockHeterogeneousAgentCtr = {
sendPrompt: vi.fn().mockResolvedValue(undefined),
startSession: vi.fn().mockResolvedValue({ sessionId: 'mock-session-id' }),
} as unknown as HeterogeneousAgentCtr;
const mockRemoteServerConfigCtr = {
getAccessToken: vi.fn().mockResolvedValue('mock-access-token'),
isRemoteServerConfigured: vi.fn().mockResolvedValue(true),
@@ -174,6 +219,7 @@ const mockApp = {
if (Cls === RemoteServerConfigCtr) return mockRemoteServerConfigCtr;
if (Cls === LocalFileCtr) return mockLocalFileCtr;
if (Cls === ShellCommandCtr) return mockShellCommandCtr;
if (Cls === HeterogeneousAgentCtr) return mockHeterogeneousAgentCtr;
return null;
}),
getService: vi.fn((Cls) => {
@@ -573,6 +619,327 @@ describe('GatewayConnectionCtr', () => {
});
});
// ─── Agent Run Routing ───
describe('agent run routing', () => {
async function connectAndOpen() {
ctr.afterAppReady();
await vi.advanceTimersByTimeAsync(0);
const client = MockGatewayClient.lastInstance!;
client.simulateConnected();
return client;
}
beforeEach(() => {
vi.mocked(mockHeterogeneousAgentCtr.startSession).mockClear();
vi.mocked(mockHeterogeneousAgentCtr.sendPrompt).mockClear();
});
it.each([
['openclaw', 'openclaw'],
['hermes', 'hermes'],
['codex', 'codex'],
['claude-code', 'claude'],
] as const)('uses command "%s" for agentType "%s"', async (agentType, expectedCommand) => {
const client = await connectAndOpen();
client.simulateAgentRunRequest(agentType);
await vi.advanceTimersByTimeAsync(0);
expect(mockHeterogeneousAgentCtr.startSession).toHaveBeenCalledWith(
expect.objectContaining({ agentType, command: expectedCommand }),
);
});
it('sends accepted ack and fires sendPrompt', async () => {
const client = await connectAndOpen();
client.simulateAgentRunRequest('openclaw', 'op-xyz');
await vi.advanceTimersByTimeAsync(0);
expect(client.sendAgentRunAck).toHaveBeenCalledWith({
operationId: 'op-xyz',
status: 'accepted',
});
expect(mockHeterogeneousAgentCtr.sendPrompt).toHaveBeenCalledWith(
expect.objectContaining({ operationId: 'op-xyz', sessionId: 'mock-session-id' }),
);
});
it('sends rejected ack when startSession throws', async () => {
vi.mocked(mockHeterogeneousAgentCtr.startSession).mockRejectedValueOnce(
new Error('binary not found'),
);
const client = await connectAndOpen();
client.simulateAgentRunRequest('openclaw', 'op-fail');
await vi.advanceTimersByTimeAsync(0);
expect(client.sendAgentRunAck).toHaveBeenCalledWith({
operationId: 'op-fail',
reason: 'binary not found',
status: 'rejected',
});
});
});
// ─── runHeteroTask ───
describe('runHeteroTask', () => {
/** Creates a minimal mock child process returned by spawn(). */
function makeMockChild(pid = 9999) {
const listeners: Record<string, Array<(...a: any[]) => void>> = {};
return {
on: vi.fn((event: string, cb: (...a: any[]) => void) => {
listeners[event] = listeners[event] ?? [];
listeners[event].push(cb);
}),
pid,
unref: vi.fn(),
_emit: (event: string, ...args: any[]) => listeners[event]?.forEach((cb) => cb(...args)),
};
}
async function connectAndOpen() {
ctr.afterAppReady();
await vi.advanceTimersByTimeAsync(0);
const client = MockGatewayClient.lastInstance!;
client.simulateConnected();
return client;
}
beforeEach(() => {
execFileSyncMock.mockReturnValue('/usr/local/bin/lh\n');
spawnMock.mockReset();
});
it('always injects buildNotifyProtocol into the prompt', async () => {
const child = makeMockChild();
spawnMock.mockReturnValue(child);
const client = await connectAndOpen();
client.simulateToolCallRequest(
'runHeteroTask',
{
agentType: 'openclaw',
operationId: 'op-1',
prompt: 'hello',
taskId: 'task-1',
topicId: 'topic-1',
},
'req-run',
);
await vi.advanceTimersByTimeAsync(0);
expect(spawnMock).toHaveBeenCalledTimes(1);
const [, spawnArgs] = spawnMock.mock.calls[0] as [string, string[]];
const messageArg = spawnArgs[spawnArgs.indexOf('--message') + 1];
expect(messageArg).toContain('hello');
expect(messageArg).toContain('lh notify');
});
it('kills an existing concurrent openclaw process for the same topicId before spawning', async () => {
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
// First task
const child1 = makeMockChild(1111);
spawnMock.mockReturnValueOnce(child1);
const client = await connectAndOpen();
client.simulateToolCallRequest(
'runHeteroTask',
{
agentType: 'openclaw',
operationId: 'op-1',
prompt: 'msg1',
taskId: 'task-1',
topicId: 'topic-same',
},
'req-1',
);
await vi.advanceTimersByTimeAsync(0);
// Second task for same topicId — should kill task-1's pid first
const child2 = makeMockChild(2222);
spawnMock.mockReturnValueOnce(child2);
client.simulateToolCallRequest(
'runHeteroTask',
{
agentType: 'openclaw',
operationId: 'op-2',
prompt: 'msg2',
taskId: 'task-2',
topicId: 'topic-same',
},
'req-2',
);
await vi.advanceTimersByTimeAsync(0);
expect(killSpy).toHaveBeenCalledWith(1111, 'SIGTERM');
expect(spawnMock).toHaveBeenCalledTimes(2);
killSpy.mockRestore();
});
it('does not kill processes for a different topicId', async () => {
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
const child1 = makeMockChild(3333);
spawnMock.mockReturnValueOnce(child1);
const client = await connectAndOpen();
client.simulateToolCallRequest(
'runHeteroTask',
{
agentType: 'openclaw',
operationId: 'op-1',
prompt: 'a',
taskId: 'task-a',
topicId: 'topic-A',
},
'req-a',
);
await vi.advanceTimersByTimeAsync(0);
const child2 = makeMockChild(4444);
spawnMock.mockReturnValueOnce(child2);
client.simulateToolCallRequest(
'runHeteroTask',
{
agentType: 'openclaw',
operationId: 'op-2',
prompt: 'b',
taskId: 'task-b',
topicId: 'topic-B',
},
'req-b',
);
await vi.advanceTimersByTimeAsync(0);
expect(killSpy).not.toHaveBeenCalled();
killSpy.mockRestore();
});
});
// ─── Platform Capability Probing ───
describe('platform capability probing', () => {
async function connectAndOpen() {
ctr.afterAppReady();
await vi.advanceTimersByTimeAsync(0);
const client = MockGatewayClient.lastInstance!;
client.simulateConnected();
return client;
}
beforeEach(() => {
execSyncMock.mockReset();
});
it('returns available:true with version when binary is installed', async () => {
execSyncMock.mockImplementation((cmd: string) => {
if (cmd.startsWith('which ') || cmd.startsWith('where '))
return '/usr/local/bin/openclaw\n';
if (cmd.includes('--version')) return 'openclaw 1.2.3\n';
return '';
});
const client = await connectAndOpen();
client.simulateToolCallRequest(
'checkPlatformCapability',
{ platform: 'openclaw' },
'req-cap',
);
await vi.advanceTimersByTimeAsync(0);
expect(client.sendToolCallResponse).toHaveBeenCalledWith({
requestId: 'req-cap',
result: {
content: JSON.stringify({ available: true, version: 'openclaw 1.2.3' }),
success: true,
},
});
});
it('returns available:true without version when --version command fails', async () => {
execSyncMock.mockImplementation((cmd: string) => {
if (cmd.startsWith('which ') || cmd.startsWith('where '))
return '/usr/local/bin/openclaw\n';
throw new Error('version command failed');
});
const client = await connectAndOpen();
client.simulateToolCallRequest(
'checkPlatformCapability',
{ platform: 'openclaw' },
'req-cap-nover',
);
await vi.advanceTimersByTimeAsync(0);
expect(client.sendToolCallResponse).toHaveBeenCalledWith({
requestId: 'req-cap-nover',
result: {
content: JSON.stringify({ available: true }),
success: true,
},
});
});
it('returns available:false when binary is not installed', async () => {
execSyncMock.mockImplementation(() => {
throw new Error('command not found');
});
const client = await connectAndOpen();
client.simulateToolCallRequest(
'checkPlatformCapability',
{ platform: 'openclaw' },
'req-missing',
);
await vi.advanceTimersByTimeAsync(0);
expect(client.sendToolCallResponse).toHaveBeenCalledWith({
requestId: 'req-missing',
result: {
content: JSON.stringify({
available: false,
reason: 'openclaw is not installed on this device',
}),
success: true,
},
});
});
it('returns available:false for unknown platform', async () => {
const client = await connectAndOpen();
client.simulateToolCallRequest(
'checkPlatformCapability',
{ platform: 'unknownBot' },
'req-unknown-plat',
);
await vi.advanceTimersByTimeAsync(0);
expect(client.sendToolCallResponse).toHaveBeenCalledWith({
requestId: 'req-unknown-plat',
result: {
content: JSON.stringify({ available: false, reason: 'Unknown platform: unknownBot' }),
success: true,
},
});
});
it('getAgentProfile returns empty object', async () => {
const client = await connectAndOpen();
client.simulateToolCallRequest('getAgentProfile', { platform: 'openclaw' }, 'req-profile');
await vi.advanceTimersByTimeAsync(0);
expect(client.sendToolCallResponse).toHaveBeenCalledWith({
requestId: 'req-profile',
result: {
content: JSON.stringify({}),
success: true,
},
});
});
});
// ─── IPC Methods ───
describe('getConnectionStatus', () => {
@@ -717,7 +717,7 @@ describe('HeterogeneousAgentCtr', () => {
* `stdout.on('end')` handler can schedule `pipeline.flush()` onto the
* broadcast queue), then drain the queue, then broadcast complete.
*/
describe('exit-before-end ordering (LOBE-8516 phase 0 race)', () => {
describe('exit-before-end ordering (phase 0 race)', () => {
let broadcasts: Array<{ channel: string; data: any }>;
beforeEach(() => {
@@ -808,7 +808,7 @@ describe('HeterogeneousAgentCtr', () => {
});
});
describe('app-quit cleanup of AskUserQuestion temp configs (LOBE-8725)', () => {
describe('app-quit cleanup of AskUserQuestion temp configs ()', () => {
// 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
@@ -535,6 +535,7 @@ describe('RemoteServerConfigCtr', () => {
expect(result.success).toBe(false);
expect(result.error).toContain('Token refresh failed');
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('should handle missing tokens in response', async () => {
@@ -618,7 +619,7 @@ describe('RemoteServerConfigCtr', () => {
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('should handle network errors with retry', async () => {
it('should not retry after a network error', async () => {
const { safeStorage } = await import('electron');
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
vi.mocked(safeStorage.decryptString).mockImplementation((buffer: Buffer) =>
@@ -644,9 +645,8 @@ describe('RemoteServerConfigCtr', () => {
expect(result.success).toBe(false);
expect(result.error).toContain('Network error');
// With retry mechanism, fetch should be called 4 times (1 initial + 3 retries)
expect(mockFetch).toHaveBeenCalledTimes(4);
}, 15000);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
});
describe('afterAppReady', () => {
@@ -256,6 +256,26 @@ export class BrowserManager {
});
}
/**
* Consume a route captured before an update restart. The captured route is
* cleared before any navigation decision so a subsequent normal launch never
* restores a stale route.
*/
private consumePendingRestoreRoute(): string {
const pendingRestoreRoute = this.app.storeManager.get('pendingRestoreRoute', '');
if (pendingRestoreRoute) this.app.storeManager.set('pendingRestoreRoute', '');
return pendingRestoreRoute;
}
private resolveMainWindowInitialPath(
isOnboardingCompleted: boolean,
pendingRestoreRoute: string,
): string {
if (!isOnboardingCompleted) return '/desktop-onboarding';
if (pendingRestoreRoute) return pendingRestoreRoute;
return '/';
}
/**
* Initialize all browsers when app starts up
*/
@@ -271,7 +291,11 @@ export class BrowserManager {
// Dynamically determine initial path for main window
if (browser.identifier === BrowsersIdentifiers.app) {
const initialPath = isOnboardingCompleted ? '/' : '/desktop-onboarding';
const pendingRestoreRoute = this.consumePendingRestoreRoute();
const initialPath = this.resolveMainWindowInitialPath(
isOnboardingCompleted,
pendingRestoreRoute,
);
browser = {
...browser,
keepAlive: isLinux ? false : browser.keepAlive,
@@ -110,6 +110,13 @@ describe('BrowserManager', () => {
getController: vi.fn().mockReturnValue({
isRemoteServerConfigured: vi.fn().mockResolvedValue(true),
}),
storeManager: {
get: vi.fn((key: string) => {
if (key === 'pendingRestoreRoute') return '';
return '';
}),
set: vi.fn(),
},
} as unknown as AppCore;
manager = new BrowserManager(mockApp);
@@ -266,6 +273,43 @@ describe('BrowserManager', () => {
expect(manager.browsers.has('app')).toBe(true);
expect(manager.browsers.has('settings')).toBe(false);
});
it('restores a captured route as the main window initial path', async () => {
(mockApp.storeManager.get as any).mockImplementation((key: string) => {
if (key === 'pendingRestoreRoute') return '/agent/abc';
return '';
});
await manager.initializeBrowsers();
expect(manager.browsers.get('app')?.options.path).toBe('/agent/abc');
});
it('clears the captured route after consuming it', async () => {
(mockApp.storeManager.get as any).mockImplementation((key: string) => {
if (key === 'pendingRestoreRoute') return '/agent/abc';
return '';
});
await manager.initializeBrowsers();
expect(mockApp.storeManager.set).toHaveBeenCalledWith('pendingRestoreRoute', '');
});
it('ignores the captured route when onboarding is not completed', async () => {
(mockApp.storeManager.get as any).mockImplementation((key: string) => {
if (key === 'pendingRestoreRoute') return '/agent/abc';
return '';
});
(mockApp.getController as any).mockReturnValue({
isRemoteServerConfigured: vi.fn().mockResolvedValue(false),
});
await manager.initializeBrowsers();
expect(manager.browsers.get('app')?.options.path).toBe('/desktop-onboarding');
expect(mockApp.storeManager.set).toHaveBeenCalledWith('pendingRestoreRoute', '');
});
});
describe('broadcastToAllWindows', () => {
@@ -12,6 +12,7 @@ import { autoUpdater } from 'electron-updater';
import { isDev, isWindows } from '@/const/env';
import { getDesktopEnv } from '@/env';
import { UPDATE_CHANNEL, UPDATE_SERVER_URL, updaterConfig } from '@/modules/updater/configs';
import { extractRestoreRoute } from '@/modules/updater/utils';
import { createLogger } from '@/utils/logger';
import type { App as AppCore } from '../App';
@@ -239,12 +240,29 @@ export class UpdaterManager {
}
};
private captureRestoreRoute = () => {
try {
const url = this.mainWindow.webContents?.getURL();
if (!url) return;
const route = extractRestoreRoute(url);
if (!route) return;
this.app.storeManager.set('pendingRestoreRoute', route);
logger.info(`Captured route for restore after update restart: ${route}`);
} catch (error) {
logger.warn('Failed to capture route for restore after update restart:', error);
}
};
/**
* Install update immediately
*/
public installNow = () => {
logger.info('Installing update now...');
this.captureRestoreRoute();
this.app.isQuiting = true;
logger.info('Closing all windows before update installation...');
@@ -361,6 +361,51 @@ describe('UpdaterManager', () => {
});
});
describe('captureRestoreRoute', () => {
const callCapture = () => (updaterManager as any).captureRestoreRoute();
it('stores the derived route from the main window URL', () => {
(mockApp.browserManager.getMainWindow as any).mockReturnValue({
webContents: { getURL: () => 'app://renderer/agent/abc' },
});
callCapture();
expect(mockApp.storeManager.set).toHaveBeenCalledWith('pendingRestoreRoute', '/agent/abc');
});
it('stores nothing when the URL is not a restorable route', () => {
(mockApp.browserManager.getMainWindow as any).mockReturnValue({
webContents: { getURL: () => 'app://renderer/' },
});
callCapture();
expect(mockApp.storeManager.set).not.toHaveBeenCalled();
});
it('stores nothing when there is no webContents', () => {
(mockApp.browserManager.getMainWindow as any).mockReturnValue({ webContents: null });
callCapture();
expect(mockApp.storeManager.set).not.toHaveBeenCalled();
});
it('does not throw when reading the URL fails', () => {
(mockApp.browserManager.getMainWindow as any).mockReturnValue({
webContents: {
getURL: () => {
throw new Error('boom');
},
},
});
expect(() => callCapture()).not.toThrow();
expect(mockApp.storeManager.set).not.toHaveBeenCalled();
});
});
describe('installLater', () => {
it('should set autoInstallOnAppQuit to true', () => {
updaterManager.installLater();
@@ -95,6 +95,11 @@ const createMockApp = () => {
}),
},
browserManager: {
getMainWindow: vi.fn(() => ({
broadcast: vi.fn(),
loadUrl: vi.fn(),
show: vi.fn(),
})),
showMainWindow: vi.fn(),
retrieveByIdentifier: vi.fn(() => ({
show: vi.fn(),
@@ -223,6 +228,9 @@ describe('LinuxMenu', () => {
describe('menu item click handlers', () => {
it('should handle preferences click', () => {
const mainWindow = { broadcast: vi.fn(), loadUrl: vi.fn(), show: vi.fn() };
(mockApp.browserManager.getMainWindow as any).mockReturnValue(mainWindow);
linuxMenu.buildAndSetAppMenu();
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
@@ -231,7 +239,9 @@ describe('LinuxMenu', () => {
expect(preferencesItem).toBeDefined();
preferencesItem.click();
expect(mockApp.browserManager.retrieveByIdentifier).toHaveBeenCalledWith('settings');
expect(mockApp.browserManager.getMainWindow).toHaveBeenCalled();
expect(mainWindow.show).toHaveBeenCalled();
expect(mainWindow.broadcast).toHaveBeenCalledWith('navigate', { path: '/settings' });
});
it('should handle check for updates click', () => {
+10 -2
View File
@@ -103,7 +103,11 @@ export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform {
},
{ type: 'separator' },
{
click: () => this.app.browserManager.retrieveByIdentifier('settings').show(),
click: async () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.show();
mainWindow.broadcast('navigate', { path: '/settings' });
},
label: t('file.preferences'),
},
{
@@ -465,7 +469,11 @@ export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform {
},
{ type: 'separator' },
{
click: () => this.app.browserManager.retrieveByIdentifier('settings').show(),
click: async () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.show();
mainWindow.broadcast('navigate', { path: '/settings' });
},
label: t('tray.settings'),
},
{ type: 'separator' },
@@ -205,6 +205,9 @@ describe('WindowsMenu', () => {
describe('menu item click handlers', () => {
it('should handle preferences click', () => {
const mainWindow = { broadcast: vi.fn(), loadUrl: vi.fn(), show: vi.fn() };
(mockApp.browserManager.getMainWindow as any).mockReturnValue(mainWindow);
windowsMenu.buildAndSetAppMenu();
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
@@ -213,7 +216,9 @@ describe('WindowsMenu', () => {
expect(preferencesItem).toBeDefined();
preferencesItem.click();
expect(mockApp.browserManager.retrieveByIdentifier).toHaveBeenCalledWith('settings');
expect(mockApp.browserManager.getMainWindow).toHaveBeenCalled();
expect(mainWindow.show).toHaveBeenCalled();
expect(mainWindow.broadcast).toHaveBeenCalledWith('navigate', { path: '/settings' });
});
it('should handle check for updates click', () => {
+10 -2
View File
@@ -102,7 +102,11 @@ export class WindowsMenu extends BaseMenuPlatform implements IMenuPlatform {
},
{ type: 'separator' },
{
click: () => this.app.browserManager.retrieveByIdentifier('settings').show(),
click: async () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.show();
mainWindow.broadcast('navigate', { path: '/settings' });
},
label: t('file.preferences'),
},
this.getUpdateMenuItem(t),
@@ -472,7 +476,11 @@ export class WindowsMenu extends BaseMenuPlatform implements IMenuPlatform {
},
{ type: 'separator' },
{
click: () => this.app.browserManager.retrieveByIdentifier('settings').show(),
click: async () => {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.show();
mainWindow.broadcast('navigate', { path: '/settings' });
},
label: t('tray.settings'),
},
{ type: 'separator' },
@@ -1,52 +0,0 @@
import type { ToolDetectorManager } from '@/core/infrastructure/ToolDetectorManager';
import { createLogger } from '@/utils/logger';
import type { FileResult, SearchOptions } from '../types';
import type { UnixSearchTool } from './unix';
import { UnixFileSearch } from './unix';
const logger = createLogger('module:FileSearch:linux');
/**
* Linux file search implementation
* Uses fd > find > fast-glob fallback strategy
*/
export class LinuxSearchServiceImpl extends UnixFileSearch {
constructor(toolDetectorManager?: ToolDetectorManager) {
super(toolDetectorManager);
}
/**
* Perform file search
* @param options Search options
* @returns Promise of search result list
*/
async search(options: SearchOptions): Promise<FileResult[]> {
// Determine the best available tool on first search
if (this.currentTool === null) {
this.currentTool = await this.determineBestUnixTool();
logger.info(`Using file search tool: ${this.currentTool}`);
}
return this.searchWithUnixTool(this.currentTool as UnixSearchTool, options);
}
/**
* Check search service status
* @returns Promise indicating if service is available (always true for Linux)
*/
async checkSearchServiceStatus(): Promise<boolean> {
// At minimum, fast-glob is always available
return true;
}
/**
* Update search index
* Linux doesn't have a system-wide search index like Spotlight
* @returns Promise indicating operation result (always false for Linux)
*/
async updateSearchIndex(): Promise<boolean> {
logger.warn('updateSearchIndex is not supported on Linux (no system-wide index)');
return false;
}
}
@@ -1,31 +0,0 @@
import { platform } from 'node:os';
import type { ToolDetectorManager } from '@/core/infrastructure/ToolDetectorManager';
import { LinuxSearchServiceImpl } from './impl/linux';
import { MacOSSearchServiceImpl } from './impl/macOS';
import { WindowsSearchServiceImpl } from './impl/windows';
export { BaseFileSearch } from './base';
export type { FileResult, SearchOptions } from './types';
export const createFileSearchModule = (toolDetectorManager?: ToolDetectorManager) => {
const currentPlatform = platform();
switch (currentPlatform) {
case 'darwin': {
return new MacOSSearchServiceImpl(toolDetectorManager);
}
case 'win32': {
return new WindowsSearchServiceImpl(toolDetectorManager);
}
case 'linux': {
return new LinuxSearchServiceImpl(toolDetectorManager);
}
default: {
// Fallback to Linux implementation (uses fast-glob, no external dependencies)
console.warn(`Unsupported platform: ${currentPlatform}, using Linux fallback`);
return new LinuxSearchServiceImpl(toolDetectorManager);
}
}
};
@@ -1,53 +0,0 @@
export interface FileResult {
contentType?: string;
createdTime: Date;
// Search engine used to find this file (e.g., 'mdfind', 'fd', 'find', 'fast-glob')
engine?: string;
isDirectory: boolean;
lastAccessTime: Date;
// Spotlight specific metadata
metadata?: {
[key: string]: any;
};
modifiedTime: Date;
name: string;
path: string;
size: number;
type: string;
}
export interface SearchOptions {
// Directory options
// Content options
contentContains?: string;
// Created after specific date
createdAfter?: Date;
// Created before specific date
createdBefore?: Date;
// Whether to return detailed results
detailed?: boolean;
// Limit search to specific directories
exclude?: string[]; // Files containing specific content
// File type options
fileTypes?: string[];
// Basic options
keywords: string;
limit?: number;
// Created before specific date
// Advanced options
liveUpdate?: boolean;
// File type filters, like "public.image", "public.movie"
// Time options
modifiedAfter?: Date;
// Modified after specific date
modifiedBefore?: Date;
// Path options
onlyIn?: string; // Whether to return detailed metadata
sortBy?: 'name' | 'date' | 'size'; // Result sorting
sortDirection?: 'asc' | 'desc'; // Sort direction
}
@@ -28,7 +28,7 @@ export const claudeCodeDriver: HeterogeneousAgentDriver = {
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
// see ) when present. Path-based config is required — CC
// does not accept inline JSON for `--mcp-config`.
...(mcpConfigPath ? ['--mcp-config', mcpConfigPath] : []),
...(resumeSessionId ? ['--resume', resumeSessionId] : []),
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { extractRestoreRoute } from '../utils';
describe('extractRestoreRoute', () => {
it('extracts the route from a production renderer URL', () => {
expect(extractRestoreRoute('app://renderer/agent/abc')).toBe('/agent/abc');
});
it('extracts the route from a dev renderer URL', () => {
expect(extractRestoreRoute('http://localhost:5173/settings/provider')).toBe(
'/settings/provider',
);
});
it('strips the lng query param but keeps the rest', () => {
expect(extractRestoreRoute('app://renderer/agent?lng=zh-CN&x=1')).toBe('/agent?x=1');
});
it('strips a lone lng query param', () => {
expect(extractRestoreRoute('app://renderer/chat?lng=en-US')).toBe('/chat');
});
it('returns null for the root route', () => {
expect(extractRestoreRoute('app://renderer/')).toBeNull();
});
it('returns null for file: protocol (splash/error pages)', () => {
expect(extractRestoreRoute('file:///Users/x/resources/splash.html')).toBeNull();
});
it('returns null for static asset paths', () => {
expect(extractRestoreRoute('app://renderer/assets/index.js')).toBeNull();
});
it('returns null for root static files', () => {
expect(extractRestoreRoute('app://renderer/favicon.ico')).toBeNull();
});
it('preserves dotted SPA route slugs', () => {
expect(extractRestoreRoute('app://renderer/community/skill/github.owner.repo')).toBe(
'/community/skill/github.owner.repo',
);
});
it('returns null for an unparseable URL', () => {
expect(extractRestoreRoute('not a url')).toBeNull();
});
});
@@ -1,5 +1,12 @@
import semver from 'semver';
const STATIC_ASSET_PATH_PREFIXES = ['/assets/', '/_next/', '/static/'];
const ROOT_STATIC_FILE_RE = /^\/[^/]+\.[^/]+$/;
const isStaticAssetPath = (pathname: string) =>
ROOT_STATIC_FILE_RE.test(pathname) ||
STATIC_ASSET_PATH_PREFIXES.some((prefix) => pathname.startsWith(prefix));
/**
* Determine if application update is needed rather than just renderer update
* @param currentVersion Current version
@@ -31,3 +38,29 @@ export const shouldUpdateApp = (currentVersion: string, nextVersion: string): bo
return true;
}
};
/**
* Extract a restorable SPA route (`pathname + search`) from a renderer window URL.
* Returns `null` when the URL is not a restorable route splash/error pages
* (`file:` protocol), known static asset paths, or the root route (identical
* to the default, nothing worth restoring).
*/
export const extractRestoreRoute = (rawUrl: string): string | null => {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
return null;
}
if (url.protocol === 'file:') return null;
if (isStaticAssetPath(url.pathname)) return null;
// `lng` is re-appended by Browser.buildUrlWithLocale on the next load
url.searchParams.delete('lng');
const route = `${url.pathname}${url.search}`;
if (route === '/' || route === '') return null;
return route;
};
@@ -1,12 +1,12 @@
import type { FileResult, SearchOptions } from '@lobechat/local-file-shell';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { App } from '@/core/App';
import type { FileResult, SearchOptions } from '@/modules/fileSearch';
import FileSearchService from '../fileSearchSrv';
// Mock the fileSearch module
vi.mock('@/modules/fileSearch', () => {
vi.mock('@lobechat/local-file-shell', () => {
const MockFileSearchImpl = vi.fn().mockImplementation(() => ({
search: vi.fn(),
checkSearchServiceStatus: vi.fn(),
@@ -1,31 +1,31 @@
import type { GrepContentParams, GrepContentResult } from '@lobechat/electron-client-ipc';
import type { BaseContentSearch} from '@/modules/contentSearch';
import { createContentSearchImpl } from '@/modules/contentSearch';
import {
type BaseContentSearch,
createContentSearchImpl,
type GrepContentParams,
type GrepContentResult,
} from '@lobechat/local-file-shell';
import { ServiceModule } from './index';
/**
* Content Search Service
* Provides content search functionality using platform-specific implementations
* sunk into the shared `@lobechat/local-file-shell` package.
*/
export default class ContentSearchService extends ServiceModule {
private impl: BaseContentSearch = createContentSearchImpl();
/**
* Perform content search (grep)
*/
async grep(params: GrepContentParams): Promise<GrepContentResult> {
// Ensure toolDetectorManager is set
// Lazily wire the desktop ToolDetectorManager so we don't hit the
// class-field init-before-super-constructor gotcha. The manager already
// satisfies the minimal `ToolDetector` contract (only `getBestTool` is
// consumed by the search impls).
if (this.app?.toolDetectorManager) {
this.impl.setToolDetectorManager(this.app.toolDetectorManager);
this.impl.setToolDetector(this.app.toolDetectorManager);
}
return this.impl.grep(params);
}
/**
* Check if a specific tool is available
*/
async checkToolAvailable(tool: string): Promise<boolean> {
return this.impl.checkToolAvailable(tool);
}
+15 -25
View File
@@ -1,54 +1,44 @@
import type { GlobFilesParams, GlobFilesResult } from '@lobechat/electron-client-ipc';
import type {
BaseFileSearch,
FileResult,
SearchOptions} from '@/modules/fileSearch';
import {
createFileSearchModule
} from '@/modules/fileSearch';
type BaseFileSearch,
createFileSearchModule,
type FileResult,
type GlobFilesParams,
type GlobFilesResult,
type SearchOptions,
} from '@lobechat/local-file-shell';
import { ServiceModule } from './index';
/**
* File Search Service
* Main service class that uses platform-specific implementations internally
* Main service class that delegates to platform-specific implementations from
* `@lobechat/local-file-shell`.
*/
export default class FileSearchService extends ServiceModule {
private impl: BaseFileSearch = createFileSearchModule();
/**
* Perform file search
*/
async search(
query: string,
options: Omit<SearchOptions, 'keywords'> = {},
): Promise<FileResult[]> {
if (this.app?.toolDetectorManager) {
this.impl.setToolDetector(this.app.toolDetectorManager);
}
return this.impl.search({ ...options, keywords: query });
}
/**
* Check search service status
*/
async checkSearchServiceStatus(): Promise<boolean> {
return this.impl.checkSearchServiceStatus();
}
/**
* Update search index
* @param path Optional specified path
* @returns Promise indicating operation success
*/
async updateSearchIndex(path?: string): Promise<boolean> {
return this.impl.updateSearchIndex(path);
}
/**
* Perform glob pattern matching
* @param params Glob parameters
* @returns Promise of glob result
*/
async glob(params: GlobFilesParams): Promise<GlobFilesResult> {
if (this.app?.toolDetectorManager) {
this.impl.setToolDetector(this.app.toolDetectorManager);
}
return this.impl.glob(params);
}
}
@@ -8,7 +8,7 @@ import type {
} from '@lobechat/device-gateway-client';
import { GatewayClient } from '@lobechat/device-gateway-client';
import type { GatewayConnectionStatus } from '@lobechat/electron-client-ipc';
import { app } from 'electron';
import { app, powerSaveBlocker } from 'electron';
import { createLogger } from '@/utils/logger';
@@ -36,6 +36,7 @@ export default class GatewayConnectionService extends ServiceModule {
private client: GatewayClient | null = null;
private status: GatewayConnectionStatus = 'disconnected';
private deviceId: string | null = null;
private powerSaveBlockerId: number | null = null;
private tokenProvider: (() => Promise<string | null>) | null = null;
private tokenRefresher: (() => Promise<{ error?: string; success: boolean }>) | null = null;
@@ -318,6 +319,26 @@ export default class GatewayConnectionService extends ServiceModule {
}
};
// ─── Power Save Blocker ───
/**
* Start power save blocker to prevent macOS App Nap from suspending the process
* while the gateway connection is active. Uses 'prevent-app-suspension' so the
* display can still sleep only the app process is kept alive.
*/
private startPowerSaveBlocker() {
if (this.powerSaveBlockerId !== null) return;
this.powerSaveBlockerId = powerSaveBlocker.start('prevent-app-suspension');
logger.info(`Power save blocker started (id=${this.powerSaveBlockerId})`);
}
private stopPowerSaveBlocker() {
if (this.powerSaveBlockerId === null) return;
powerSaveBlocker.stop(this.powerSaveBlockerId);
logger.info(`Power save blocker stopped (id=${this.powerSaveBlockerId})`);
this.powerSaveBlockerId = null;
}
// ─── Status Broadcasting ───
private setStatus(status: GatewayConnectionStatus) {
@@ -325,6 +346,15 @@ export default class GatewayConnectionService extends ServiceModule {
logger.info(`Connection status: ${this.status}${status}`);
this.status = status;
// Keep the app process alive while gateway is connected so macOS App Nap
// does not suspend it during display sleep, which would drop the WebSocket.
if (status === 'connected') {
this.startPowerSaveBlocker();
} else {
this.stopPowerSaveBlocker();
}
this.app.browserManager.broadcastToAllWindows('gatewayConnectionStatusChanged', { status });
}
+1
View File
@@ -21,6 +21,7 @@ export interface ElectronMainStore {
locale: string;
localFileWorkspaceRoots: string[];
networkProxy: NetworkProxySettings;
pendingRestoreRoute: string;
shortcuts: Record<string, string>;
storagePath: string;
themeMode: 'dark' | 'light' | 'system';
+7
View File
@@ -1,3 +1,4 @@
import { setLoggerFactory } from '@lobechat/local-file-shell';
import debug from 'debug';
import electronLog from 'electron-log';
@@ -46,3 +47,9 @@ export const createLogger = (namespace: string) => {
},
};
};
// Route @lobechat/local-file-shell logs through desktop's electron-log +
// debug pipeline so search failures from the sunk contentSearch / fileSearch
// modules land in the same production log file users attach for support
// (regression introduced when the modules moved out of the desktop tree).
setLoggerFactory(createLogger);
@@ -0,0 +1,39 @@
---
title: Introducing CAO — Your Chief Agent Operator
description: >-
Meet CAO: agents that review their own work, recruit teammates when they need help, and only stop to ask you when it really matters.
tags:
- CAO
- Agent Teams
- Tasks
- Models
---
# Introducing CAO — Your Chief Agent Operator
## Meet CAO
CAO (Chief Agent Operator) turns short back-and-forth chats into agents that just keep going. Instead of waiting for you to say "ok, next step", your agent now checks its own work, decides what to do next, and gets on with it. When something genuinely needs your call, it pauses, tells you what it tried, and asks a clear question — no more guessing in the dark.
CAO can also bring in help. If a task gets big, your agent can put together a small team of sub-agents and hand pieces off to them. You can peek into any of their conversations to see what they're up to, while your main agent stays in charge of the overall plan.
[Learn more about CAO →](https://x.com/lobehub/status/2056371816265097337?s=20)
## What else is new
- Recurring tasks are more reliable, with a cap on how many times a task can run and protection against schedules that fire too often
- A cleaner Agent Documents view — easier to find what you put there, and no more clutter from old web crawls
- Project skills now show up in the working sidebar, with a built-in markdown preview
- You can pick a different model for each kind of follow-up suggestion
- New model: Baidu Ernie 5.1
- DeepSeek pricing is back to official rates
## Improvements and fixes
- Desktop no longer signs you out from background token refreshes, and remembers which page you were on after an app update.
- Chat input actions show as icon + label, and only the latest reply animates as it streams.
- Cleaner tab bar styling, and task schedule labels no longer wrap awkwardly.
- Local-file search handles hidden files starting with `.`, and grep parameters now flow through correctly.
- Documents fail clearly when they aren't supported, instead of producing confusing errors.
- Gemini tool calls handle thinking signatures and enums correctly, and per-tool timeouts now stick.
- Task pages keep the right agent context; memory updates stay out of your main thread.
@@ -0,0 +1,38 @@
---
title: 隆重推出 CAO —— 你的首席智能体运营官
description: 认识 CAO:会自己复盘、能临时组队,只在真正需要时才停下来问你的智能体。
tags:
- CAO
- 智能体团队
- 任务
- 模型
---
# 隆重推出 CAO —— 你的首席智能体运营官
## 认识 CAO
CAO(首席智能体运营官)让原本一问一答的对话,变成一个能自己往前推进的智能体。不必再每一步都告诉它「好,下一步」—— 它会自己复盘已完成的部分,决定接下来要做什么,然后继续推进。当遇到真正需要你来拍板的事,它会停下来,告诉你已经尝试了什么,并提出一个清晰的问题,而不是凭感觉硬猜。
CAO 也会找帮手。任务变大时,它可以临时组建一支子智能体小队,把不同部分交给队友处理。你随时可以看任意一个子智能体的对话进展,而主智能体始终掌控整体节奏。
[了解更多 CAO →](https://x.com/lobehub/status/2056371816265097337?s=20)
## 还有这些新东西
- 周期性任务更稳了:可以限制总执行次数,也会阻止设置得过于频繁的计划
- 智能体文档视图更清爽 —— 更容易找到自己放进去的东西,也不再混着旧的网页抓取记录
- 项目技能现在会出现在工作侧栏,并自带 Markdown 预览
- 不同类型的后续建议可以分别选择不同的模型
- 新增模型:百度文心一言 5.1
- DeepSeek 价格已恢复官方定价
## 体验优化与修复
- 桌面端不再因为后台刷新 token 而把你踢出登录,应用更新后也能回到你之前所在的页面。
- 聊天输入框的动作按钮调整为「图标 + 标签」样式,只有最新一条回复会有打字动画。
- 标签栏样式更清爽,任务计划标签不再换行错位。
- 本地文件搜索可以正确处理以 `.` 开头的隐藏文件,搜索参数也能完整传递。
- 不支持的文档现在会明确报错,而不是抛出令人困惑的提示。
- Gemini 工具调用对思考签名和枚举类型的处理已修复,每个工具的超时设置也确实生效。
- 任务页面会保留正确的智能体上下文,记忆更新也不会再混入主对话。
+46 -156
View File
@@ -2,359 +2,249 @@
"$schema": "https://github.com/lobehub/lobe-chat/blob/main/docs/changelog/schema.json",
"cloud": [],
"community": [
{
"image": "https://hub-apac-1.lobeobjects.space/billboard/covers/1778838542538-MDEMAEav.png",
"id": "2026-05-19-chief-agent-operator",
"date": "2026-05-19",
"versionRange": ["2.1.58", "2.2.0"]
},
{
"image": "/blog/assets4aa1732a45832afc780600e6e329860c.webp",
"id": "2026-05-11-agent-tasks-ga",
"date": "2026-05-11",
"versionRange": [
"2.1.57"
]
"versionRange": ["2.1.57"]
},
{
"image": "/blog/assetsb2bf4ddf0a45ff887a993c18cb7ab983.webp",
"id": "2026-05-04-task-scheduler",
"date": "2026-05-04",
"versionRange": [
"2.1.54",
"2.1.56"
]
"versionRange": ["2.1.54", "2.1.56"]
},
{
"image": "/blog/assetsfa267a02f20bc5ba6f1273bcf27b7c9f.webp",
"id": "2026-04-27-heterogeneous-agent",
"date": "2026-04-27",
"versionRange": [
"2.1.53"
]
"versionRange": ["2.1.53"]
},
{
"image": "/blog/assetsdfda32866c4bc59af0526e52f31d1da2.webp",
"id": "2026-04-20-daily-brief",
"date": "2026-04-20",
"versionRange": [
"2.1.50",
"2.1.52"
]
"versionRange": ["2.1.50", "2.1.52"]
},
{
"image": "/blog/assets300abe7e259d293da6c5ed4f642a1be6.webp",
"id": "2026-04-13-gateway-sidebar",
"date": "2026-04-13",
"versionRange": [
"2.1.48",
"2.1.49"
]
"versionRange": ["2.1.48", "2.1.49"]
},
{
"image": "/blog/assets7ea204859aeb5aa9be5810a20ba1669a.webp",
"id": "2026-04-06-auto-completion",
"date": "2026-04-06",
"versionRange": [
"2.1.47"
]
"versionRange": ["2.1.47"]
},
{
"id": "2026-03-30-agent-tasks",
"date": "2026-03-30",
"versionRange": [
"2.1.45",
"2.1.46"
]
"versionRange": ["2.1.45", "2.1.46"]
},
{
"image": "/blog/assets53e6ec9cf72554dbc1f8224fc0550a03.webp",
"id": "2026-03-23-media-memory",
"date": "2026-03-23",
"versionRange": [
"2.1.44"
]
"versionRange": ["2.1.44"]
},
{
"image": "https://hub-apac-1.lobeobjects.space/blog/assets/4a68a7644501cb513d08670b102a446e.webp",
"id": "2026-03-16-search",
"date": "2026-03-16",
"versionRange": [
"2.1.38",
"2.1.43"
]
"versionRange": ["2.1.38", "2.1.43"]
},
{
"id": "2026-02-08-runtime-auth",
"date": "2026-02-08",
"versionRange": [
"2.1.6",
"2.1.26"
]
"versionRange": ["2.1.6", "2.1.26"]
},
{
"image": "/blog/assetsa8e504275f2cd891fabecca985998de0.webp",
"id": "2026-01-27-v2",
"date": "2026-01-27",
"versionRange": [
"2.0.1",
"2.1.5"
]
"versionRange": ["2.0.1", "2.1.5"]
},
{
"image": "/blog/assets7f3b38c1d76cceb91edb29d6b1eb60db.webp",
"id": "2025-12-20-mcp",
"date": "2025-12-20",
"versionRange": [
"1.142.8",
"1.143.0"
]
"versionRange": ["1.142.8", "1.143.0"]
},
{
"image": "/blog/assets3a7f0b29839603336e39e923b423409b.webp",
"id": "2025-11-08-comfy-ui",
"date": "2025-11-08",
"versionRange": [
"1.133.5",
"1.142.8"
]
"versionRange": ["1.133.5", "1.142.8"]
},
{
"image": "/blog/assets35e6aa692b0c16009c61964279514166.webp",
"id": "2025-10-08-python",
"date": "2025-10-08",
"versionRange": [
"1.120.7",
"1.133.5"
]
"versionRange": ["1.120.7", "1.133.5"]
},
{
"image": "/blog/assetsce5d6dc93676f974be2e162e8ace03f0.webp",
"id": "2025-09-08-gemini",
"date": "2025-09-08",
"versionRange": [
"1.109.1",
"1.120.7"
]
"versionRange": ["1.109.1", "1.120.7"]
},
{
"image": "/blog/assetsdf48eed9de76b7e37c269b294285f09d.webp",
"id": "2025-08-08-image-generation",
"date": "2025-08-08",
"versionRange": [
"1.97.10",
"1.109.1"
]
"versionRange": ["1.97.10", "1.109.1"]
},
{
"image": "/blog/assets902eb746fe2042fc2ea831c71002be72.webp",
"id": "2025-07-08-mcp-market",
"date": "2025-07-08",
"versionRange": [
"1.93.3",
"1.97.10"
]
"versionRange": ["1.93.3", "1.97.10"]
},
{
"image": "/blog/assets5cc27b8cae995074da20d4ffe06a1460.webp",
"id": "2025-06-08-claude-4",
"date": "2025-06-08",
"versionRange": [
"1.84.27",
"1.93.3"
]
"versionRange": ["1.84.27", "1.93.3"]
},
{
"image": "/blog/assets2a36d86a4eed6e7938dd6e9c684701ed.webp",
"id": "2025-05-08-desktop-app",
"date": "2025-05-08",
"versionRange": [
"1.77.17",
"1.84.27"
]
"versionRange": ["1.77.17", "1.84.27"]
},
{
"image": "/blog/assetsc0efdb82443556ae3acefe00099b3f23.webp",
"id": "2025-04-06-exports",
"date": "2025-04-06",
"versionRange": [
"1.67.2",
"1.77.17"
]
"versionRange": ["1.67.2", "1.77.17"]
},
{
"image": "/blog/assetse743f0a47127390dde766a0a790476db.webp",
"id": "2025-03-02-new-models",
"date": "2025-03-02",
"versionRange": [
"1.49.13",
"1.67.2"
]
"versionRange": ["1.49.13", "1.67.2"]
},
{
"image": "/blog/assets18168d5fe64ea34905a7e52fd82d0e9d.webp",
"id": "2025-02-02-deepseek-r1",
"date": "2025-02-02",
"versionRange": [
"1.47.8",
"1.49.12"
]
"versionRange": ["1.47.8", "1.49.12"]
},
{
"image": "/blog/assetsf9ed064fe764cbeff2f46910e7099a91.webp",
"id": "2025-01-22-new-ai-provider",
"date": "2025-01-22",
"versionRange": [
"1.43.1",
"1.47.7"
]
"versionRange": ["1.43.1", "1.47.7"]
},
{
"image": "/blog/assets2d409f43b58953ad5396c6beab8a0719.webp",
"id": "2025-01-03-user-profile",
"date": "2025-01-03",
"versionRange": [
"1.34.1",
"1.43.0"
]
"versionRange": ["1.34.1", "1.43.0"]
},
{
"image": "/blog/assets/d9cbfcbef130183bc490d515d8a38aa4.webp",
"id": "2024-11-27-forkable-chat",
"date": "2024-11-27",
"versionRange": [
"1.33.1",
"1.34.0"
]
"versionRange": ["1.33.1", "1.34.0"]
},
{
"image": "/blog/assets/2d678631c55369ba7d753c3ffcb73782.webp",
"id": "2024-11-25-november-providers",
"date": "2024-11-25",
"versionRange": [
"1.30.1",
"1.33.0"
]
"versionRange": ["1.30.1", "1.33.0"]
},
{
"image": "/blog/assets/f10a4b98782e36797c38071eed785c6f.webp",
"id": "2024-11-06-share-text-json",
"date": "2024-11-06",
"versionRange": [
"1.26.1",
"1.28.0"
]
"versionRange": ["1.26.1", "1.28.0"]
},
{
"image": "/blog/assets/944c671604833cd2457445b211ebba33.webp",
"id": "2024-10-27-pin-assistant",
"date": "2024-10-27",
"versionRange": [
"1.19.1",
"1.26.0"
]
"versionRange": ["1.19.1", "1.26.0"]
},
{
"image": "/blog/assets/f6d047a345e47a52592cff916c9a64ce.webp",
"id": "2024-09-20-artifacts",
"date": "2024-09-20",
"versionRange": [
"1.17.1",
"1.19.0"
]
"versionRange": ["1.17.1", "1.19.0"]
},
{
"image": "/blog/assets/d7e57f8e69f97b76b3c2414f3441b6e4.webp",
"id": "2024-09-13-openai-o1-models",
"date": "2024-09-13",
"versionRange": [
"1.12.1",
"1.17.0"
]
"versionRange": ["1.12.1", "1.17.0"]
},
{
"image": "/blog/assets/d6129350de510a62fe87b2d2f0fb9477.webp",
"id": "2024-08-21-file-upload-and-knowledge-base",
"date": "2024-08-21",
"versionRange": [
"1.8.1",
"1.12.0"
]
"versionRange": ["1.8.1", "1.12.0"]
},
{
"image": "/blog/assets/37d85fdfccff9ed56e9c6827faee01c7.webp",
"id": "2024-08-02-lobe-chat-database-docker",
"date": "2024-08-02",
"versionRange": [
"1.6.1",
"1.8.0"
]
"versionRange": ["1.6.1", "1.8.0"]
},
{
"image": "/blog/assets/39d7890f8cbe21e77db8d3c94f7f22e4.webp",
"id": "2024-07-19-gpt-4o-mini",
"date": "2024-07-19",
"versionRange": [
"1.0.1",
"1.6.0"
]
"versionRange": ["1.0.1", "1.6.0"]
},
{
"image": "/blog/assets/eb477e62217f4d1b644eff975c7ac168.webp",
"id": "2024-06-19-lobe-chat-v1",
"date": "2024-06-19",
"versionRange": [
"0.147.0",
"1.0.0"
]
"versionRange": ["0.147.0", "1.0.0"]
},
{
"image": "/blog/assets/8a8d361b4c0cce6da350cc0de65c0ad6.webp",
"id": "2024-02-14-ollama",
"date": "2024-02-14",
"versionRange": [
"0.125.1",
"0.127.0"
]
"versionRange": ["0.125.1", "0.127.0"]
},
{
"image": "/blog/assets/9498087e85f27e692716a63cb3b58d79.webp",
"id": "2024-02-08-sso-oauth",
"date": "2024-02-08",
"versionRange": [
"0.118.1",
"0.125.0"
]
"versionRange": ["0.118.1", "0.125.0"]
},
{
"image": "/blog/assets/603fefbb944bc6761ebdab5956fc0084.webp",
"id": "2023-12-22-dalle-3",
"date": "2023-12-22",
"versionRange": [
"0.102.1",
"0.118.0"
]
"versionRange": ["0.102.1", "0.118.0"]
},
{
"image": "/blog/assets/8d4c2cc0ce8654fa8ac06cc036a7f941.webp",
"id": "2023-11-19-tts-stt",
"date": "2023-11-19",
"versionRange": [
"0.101.1",
"0.102.0"
]
"versionRange": ["0.101.1", "0.102.0"]
},
{
"image": "/blog/assets/d47654360d626f80144cdedb979a3526.webp",
"id": "2023-11-14-gpt4-vision",
"date": "2023-11-14",
"versionRange": [
"0.90.0",
"0.101.0"
]
"versionRange": ["0.90.0", "0.101.0"]
},
{
"image": "/blog/assets/50b38eac1769ae6f13aef72f3d725eec.webp",
"id": "2023-09-09-plugin-system",
"date": "2023-09-09",
"versionRange": [
"0.67.0",
"0.72.0"
]
"versionRange": ["0.67.0", "0.72.0"]
}
]
}
@@ -41,7 +41,7 @@ We utilize stylelint to standardize the style of our CSS code. In the configurat
### Style Checking
You don't need to manually run these checks. The project is configured with husky to automatically run lint-staged when you commit code, which will check if your committed files comply with the above standards.
You don't need to manually run these checks. The project is configured with Git hooks to automatically run lint-staged when you commit code, which will check if your committed files comply with the above standards.
## Contribution Process
@@ -39,7 +39,7 @@ Prettier 负责代码格式化,以保证代码的一致性。您可以在 `.pr
### 风格检查
你不需要手动运行这些检查,项目配置了 husky 会在您提交代码时自动运行 lint-staged 检查你提交的文件是否符合以上规范。
你不需要手动运行这些检查,项目配置了 Git hooks 会在您提交代码时自动运行 lint-staged 检查你提交的文件是否符合以上规范。
## 贡献流程
+85 -20
View File
@@ -188,6 +188,7 @@ table agent_eval_benchmarks {
table agent_eval_datasets {
id text [pk, not null]
benchmark_id text [not null]
source_experiment_id text
identifier text [not null]
user_id text
name text [not null]
@@ -202,10 +203,39 @@ table agent_eval_datasets {
indexes {
(identifier, user_id) [name: 'agent_eval_datasets_identifier_user_id_unique', unique]
benchmark_id [name: 'agent_eval_datasets_benchmark_id_idx']
source_experiment_id [name: 'agent_eval_datasets_source_experiment_id_idx']
user_id [name: 'agent_eval_datasets_user_id_idx']
}
}
table agent_eval_experiment_benchmarks {
experiment_id text [not null]
benchmark_id text [not null]
user_id text [not null]
created_at "timestamp with time zone" [not null, default: `now()`]
indexes {
(experiment_id, benchmark_id) [pk]
benchmark_id [name: 'agent_eval_experiment_benchmarks_benchmark_id_idx']
user_id [name: 'agent_eval_experiment_benchmarks_user_id_idx']
}
}
table agent_eval_experiments {
id text [pk, not null]
user_id text [not null]
name text [not null]
description text
metadata jsonb
accessed_at "timestamp with time zone" [not null, default: `now()`]
created_at "timestamp with time zone" [not null, default: `now()`]
updated_at "timestamp with time zone" [not null, default: `now()`]
indexes {
user_id [name: 'agent_eval_experiments_user_id_idx']
}
}
table agent_eval_run_topics {
user_id text [not null]
run_id text [not null]
@@ -228,6 +258,8 @@ table agent_eval_run_topics {
table agent_eval_runs {
id text [pk, not null]
dataset_id text [not null]
experiment_id text
parent_run_id text
target_agent_id text
user_id text [not null]
name text
@@ -240,7 +272,10 @@ table agent_eval_runs {
updated_at "timestamp with time zone" [not null, default: `now()`]
indexes {
dataset_id [name: 'agent_eval_runs_dataset_id_idx']
experiment_id [name: 'agent_eval_runs_experiment_id_idx']
parent_run_id [name: 'agent_eval_runs_parent_run_id_idx']
user_id [name: 'agent_eval_runs_user_id_idx']
status [name: 'agent_eval_runs_status_idx']
target_agent_id [name: 'agent_eval_runs_target_agent_id_idx']
@@ -747,6 +782,56 @@ table generations {
}
}
table llm_generation_tracing {
id uuid [pk, not null, default: `gen_random_uuid()`]
scenario text [not null]
prompt_version text [not null]
prompt_hash text [not null]
schema_name text
user_id text [not null]
agent_id text
topic_id text
trigger text
parent_tracing_id uuid
provider text
model text
success boolean [not null]
error_code text
error_detail text
validation_failed boolean [not null, default: false]
input_hash text
input_hint text
latency_ms integer
input_tokens integer
output_tokens integer
cost_usd "numeric(12, 8)"
storage_key text
feedback_signal text
feedback_score "numeric(3, 2)"
feedback_source text
feedback_data jsonb
feedback_updated_at "timestamp with time zone"
trace_id text
span_id text
metadata jsonb [default: `{}`]
created_at "timestamp with time zone" [not null, default: `now()`]
indexes {
scenario [name: 'llm_generation_tracing_scenario_idx']
prompt_version [name: 'llm_generation_tracing_prompt_version_idx']
user_id [name: 'llm_generation_tracing_user_id_idx']
agent_id [name: 'llm_generation_tracing_agent_id_idx']
topic_id [name: 'llm_generation_tracing_topic_id_idx']
provider [name: 'llm_generation_tracing_provider_idx']
model [name: 'llm_generation_tracing_model_idx']
success [name: 'llm_generation_tracing_success_idx']
error_code [name: 'llm_generation_tracing_error_code_idx']
validation_failed [name: 'llm_generation_tracing_validation_failed_idx']
feedback_signal [name: 'llm_generation_tracing_feedback_signal_idx']
created_at [name: 'llm_generation_tracing_created_at_idx']
}
}
table message_chunks {
message_id text
chunk_id uuid
@@ -1212,22 +1297,6 @@ table oidc_sessions {
}
}
table page_shares {
id text [pk, not null]
document_id text [not null]
user_id text [not null]
visibility text [not null, default: 'private']
page_view_count integer [not null, default: 0]
accessed_at "timestamp with time zone" [not null, default: `now()`]
created_at "timestamp with time zone" [not null, default: `now()`]
updated_at "timestamp with time zone" [not null, default: `now()`]
indexes {
document_id [name: 'page_shares_document_id_unique', unique]
user_id [name: 'page_shares_user_id_idx']
}
}
table chunks {
id uuid [pk, not null, default: `gen_random_uuid()`]
text text
@@ -2183,10 +2252,6 @@ ref: threads.source_message_id - messages.id
ref: messages.message_group_id > message_groups.id
ref: page_shares.document_id > documents.id
ref: page_shares.user_id - users.id
ref: sessions.group_id - session_groups.id
ref: topic_documents.document_id > documents.id
@@ -51,6 +51,23 @@ Supported crawler types are listed below:
---
## `JINA_USE_CN_DOMAINS`
Switch Jina Reader/Search to the mainland China fallback domains.
```env
JINA_USE_CN_DOMAINS=true
```
When enabled, LobeHub replaces the Jina endpoints as follows:
- `https://r.jina.ai` → `https://r.jinaai.cn`
- `https://s.jina.ai` → `https://s.jinaai.cn`
> 📌 Useful when `jina.ai` is unreachable from mainland China. Authentication and API behavior stay the same.
---
## `CRAWL_CONCURRENCY`
Controls crawler concurrency per crawl task. The default is `3`. On low-resource servers, use `1` to reduce CPU spikes.
@@ -46,6 +46,23 @@ CRAWLER_IMPLS="naive,search1api"
---
## `JINA_USE_CN_DOMAINS`
将 Jina Reader / Search 切换到中国大陆可用的备用域名。
```env
JINA_USE_CN_DOMAINS=true
```
启用后,LobeHub 会将 Jina 端点替换为:
- `https://r.jina.ai` → `https://r.jinaai.cn`
- `https://s.jina.ai` → `https://s.jinaai.cn`
> 📌 适用于中国大陆网络无法访问 `jina.ai` 的场景。认证方式与 API 行为保持不变。
---
## `CRAWL_CONCURRENCY`
控制单次网页抓取任务的并发数量,默认值为 `3`。在低配置服务器上建议设置为 `1` 以降低 CPU 峰值。
@@ -52,18 +52,18 @@ When using the `turn` mode, the API Keys will be retrieved in a polling manner a
The `DEFAULT_AGENT_CONFIG` is used to configure the default settings for the LobeHub default agent. It supports various data types and structures, including key-value pairs, nested fields, array values, and more. The table below provides detailed information on the configuration options, examples, and corresponding explanations for the `DEFAULT_AGENT_CONFIG` environment variable:
| Configuration Type | Example | Explanation |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| Basic Key-Value Pair | `model=gpt-4` | Set the model to `gpt-4`. |
| Nested Field | `tts.sttLocale=en-US` | Set the language locale for the text-to-speech service to `en-US`. |
| Array | `plugins=search-engine,lobe-image-designer` | Enable the `search-engine` and `lobe-image-designer` plugins. |
| Chinese Comma | `plugins=search-enginelobe-image-designer` | Same as above, demonstrating support for Chinese comma separation. |
| Multiple Configurations | `model=glm-4;provider=zhipu` | Set the model to `glm-4` and the model provider to `zhipu`. |
| Numeric Value | `params.max_tokens=300`, `chatConfig.historyCount=5` | Set the maximum tokens to `300`, Set the number of historical messages to 5. |
| Boolean Value | `chatConfig.enableAutoCreateTopic=true`, `chatConfig.enableCompressThreshold=true`, `chatConfig.enableHistoryCount=true` | Enable automatic topic creation, History length compression threshold, number of historical records. |
| Special Characters | `inputTemplate="Hello; I am a bot;"` | Set the input template to `Hello; I am a bot;`. |
| Error Handling | `model=gpt-4;maxToken` | Ignore invalid entry `maxToken` and only parse `model=gpt-4`. |
| Value Override | `model=gpt-4;model=gpt-4-1106-preview` | If a key is repeated, use the value that appears last; in this case, the value of `model` is `gpt-4-1106-preview`. |
| Configuration Type | Example | Explanation |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| Basic Key-Value Pair | `model=gpt-4` | Set the model to `gpt-4`. |
| Nested Field | `tts.sttLocale=en-US` | Set the language locale for the text-to-speech service to `en-US`. |
| Array | `plugins=search-engine,lobe-image-designer` | Enable the `search-engine` and `lobe-image-designer` plugins. |
| Chinese Comma | `plugins=search-enginelobe-image-designer` | Same as above, demonstrating support for Chinese comma separation. |
| Multiple Configurations | `model=glm-4;provider=zhipu` | Set the model to `glm-4` and the model provider to `zhipu`. |
| Numeric Value | `params.max_tokens=300`, `chatConfig.historyCount=5` | Set the maximum tokens to `300`, Set the number of historical messages to 5. |
| Boolean Value | `chatConfig.enableHistoryCount=true`, `chatConfig.enableCompressThreshold=true`, `chatConfig.enableStreaming=true` | Enable history length limit, history length compression threshold, and streaming output. |
| Special Characters | `inputTemplate="Hello; I am a bot;"` | Set the input template to `Hello; I am a bot;`. |
| Error Handling | `model=gpt-4;maxToken` | Ignore invalid entry `maxToken` and only parse `model=gpt-4`. |
| Value Override | `model=gpt-4;model=gpt-4-1106-preview` | If a key is repeated, use the value that appears last; in this case, the value of `model` is `gpt-4-1106-preview`. |
Further reading:
@@ -49,18 +49,18 @@ LobeHub 在部署时提供了一些额外的配置项,你可以使用环境变
`DEFAULT_AGENT_CONFIG` 用于配置 LobeHub 默认助理的默认配置。它支持多种数据类型和结构,包括键值对、嵌套字段、数组值等。下表详细说明了 `DEFAULT_AGENT_CONFIG` 环境变量的配置项、示例以及相应解释:
| 配置项类型 | 示例 | 解释 |
| ----- | ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- |
| 基本键值对 | `model=gpt-4` | 设置模型为 `gpt-4`。 |
| 嵌套字段 | `tts.sttLocale=en-US` | 设置文本到语音服务的语言区域为 `en-US`。 |
| 数组 | `plugins=search-engine,lobe-image-designer` | 启用 `search-engine` 和 `lobe-image-designer` 插件。 |
| 中文逗号 | `plugins=search-enginelobe-image-designer` | 同上,演示支持中文逗号分隔。 |
| 多个配置项 | `model=glm-4;provider=zhipu` | 设置模型为 `glm-4` 且模型服务商为 `zhipu`。 |
| 数字值 | `params.max_tokens=300`, `chatConfig.historyCount=5` | 设置最大令牌数为 `300`,设置历史消息条数为 5。 |
| 布尔值 | `chatConfig.enableAutoCreateTopic=true`,`chatConfig.enableCompressThreshold=true`, `chatConfig.enableHistoryCount=true` | 启用自动创建主题,历史长度压缩阈值,历史记录条数。 |
| 特殊字符 | `inputTemplate="Hello; I am a bot;"` | 设置输入模板为 `Hello; I am a bot;`。 |
| 错误处理 | `model=gpt-4;maxToken` | 忽略无效条目 `maxToken`,仅解析出 `model=gpt-4`。 |
| 值覆盖 | `model=gpt-4;model=gpt-4-1106-preview` | 如果键重复,使用最后一次出现的值,此处 `model` 的值为 `gpt-4-1106-preview`。 |
| 配置项类型 | 示例 | 解释 |
| ----- | ----------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- |
| 基本键值对 | `model=gpt-4` | 设置模型为 `gpt-4`。 |
| 嵌套字段 | `tts.sttLocale=en-US` | 设置文本到语音服务的语言区域为 `en-US`。 |
| 数组 | `plugins=search-engine,lobe-image-designer` | 启用 `search-engine` 和 `lobe-image-designer` 插件。 |
| 中文逗号 | `plugins=search-enginelobe-image-designer` | 同上,演示支持中文逗号分隔。 |
| 多个配置项 | `model=glm-4;provider=zhipu` | 设置模型为 `glm-4` 且模型服务商为 `zhipu`。 |
| 数字值 | `params.max_tokens=300`, `chatConfig.historyCount=5` | 设置最大令牌数为 `300`,设置历史消息条数为 5。 |
| 布尔值 | `chatConfig.enableHistoryCount=true`,`chatConfig.enableCompressThreshold=true`, `chatConfig.enableStreaming=true` | 启用历史消息数量限制,历史长度压缩阈值,流式输出。 |
| 特殊字符 | `inputTemplate="Hello; I am a bot;"` | 设置输入模板为 `Hello; I am a bot;`。 |
| 错误处理 | `model=gpt-4;maxToken` | 忽略无效条目 `maxToken`,仅解析出 `model=gpt-4`。 |
| 值覆盖 | `model=gpt-4;model=gpt-4-1106-preview` | 如果键重复,使用最后一次出现的值,此处 `model` 的值为 `gpt-4-1106-preview`。 |
相关阅读:
@@ -167,6 +167,13 @@ If you need to use Azure OpenAI to provide model services, you can refer to the
- Default: `https://api.anthropic.com`
- Example: `https://my-anthropic-proxy.com`
### `ANTHROPIC_CLIENT_TIMEOUT`
- Type: Optional
- Description: Anthropic SDK client request timeout in milliseconds. This timeout is also used by Anthropic-compatible providers that rely on the Anthropic SDK.
- Default: `295000`
- Example: `780000`
## AWS Bedrock
### `ENABLED_AWS_BEDROCK`
@@ -165,6 +165,13 @@ LobeHub 在部署时提供了丰富的模型服务商相关的环境变量,你
- 默认值:`https://api.anthropic.com`
- 示例:`https://my-anthropic-proxy.com`
### `ANTHROPIC_CLIENT_TIMEOUT`
- 类型:可选
- 描述:Anthropic SDK 客户端请求超时时间,单位为毫秒。依赖 Anthropic SDK 的 Anthropic 兼容服务商也会使用此超时时间。
- 默认值:`295000`
- 示例:`780000`
## AWS Bedrock
### `ENABLED_AWS_BEDROCK`
+4 -1
View File
@@ -117,11 +117,13 @@ By connecting a Discord channel to your LobeHub agent, users can interact with t
![](https://hub-apac-1.lobeobjects.space/docs/d83686461d14690e87512491c1349b6b.webp)
Copy the generated URL, open it in your browser, select the server you want to add the bot to, and click **Authorize**.
> **Private channels need explicit access.** Server-level authorization only grants the bot category/server defaults. If a channel is marked **Private Channel**, Discord ignores the bot's server-wide role and the bot won't receive any events there — including @mentions — until you open the channel's **Settings → Permissions** and add the bot (or a role it has) under **Members and roles**. You can also click **Sync Now** to make the channel inherit its category's permissions.
</Steps>
## Step 4: Test the Connection
Back in LobeHub's channel settings for Discord, click **Test Connection** to verify everything is configured correctly. Then send a message to your bot in Discord to confirm it responds.
Back in LobeHub's channel settings for Discord, click **Test Connection** to verify everything is configured correctly. Then send a message to your bot on Discord to confirm it responds.
## Step 5: Set Your Platform Identity (Recommended)
@@ -191,5 +193,6 @@ See the [Channels overview](/docs/usage/channels/overview#direct-message-policy)
## Troubleshooting
- **Bot not responding in server:** Confirm the bot has been invited to the server with the correct permissions, and Message Content Intent is enabled. If **Group Policy** is `Disabled` or `Allowlist`, double-check the channel is in **Allowed Channel IDs**. If **Allowed User IDs** is set, the sender's user ID must be in it.
- **@mention doesn't reach the bot in one specific channel:** The channel is likely a **Private Channel**. Discord drops every event for users/bots not explicitly listed in that channel's permissions, no matter what server-wide role they have. Open the channel's **Settings → Permissions**, click **Add members or roles**, and add the bot (or a role it has). Alternatively click **Sync Now** to inherit the parent category's permissions. A quick visual check: if the bot's name renders without an underline/link styling when you type `@<botname>`, Discord thinks it has no access to that channel.
- **Bot not responding to DMs:** Open **Advanced Settings** and confirm **DM Policy** is not set to `Disabled`. If **Allowed User IDs** is set, make sure the sender's Discord user ID is in it.
- **Test Connection failed:** Double-check the Application ID, Bot Token, and Public Key are correct.
+3
View File
@@ -116,6 +116,8 @@ tags:
![](https://hub-apac-1.lobeobjects.space/docs/2f6350e6bfe770e0e64b18762af3b63e.webp)
复制生成的链接,在浏览器中打开,选择您希望添加机器人的服务器,然后点击 **授权**。
> **私有频道需要单独授权。** 服务器层级的授权只会给机器人分类 / 服务器的默认权限。如果某个频道开启了 **Private Channel(私有频道)**Discord 会忽略机器人在服务器层级的角色,机器人在该频道收不到任何事件(包括 @提及),直到你在频道的 **设置 → Permissions(权限)** 中点击 **Add members or roles**,把机器人本身或它持有的角色加进来。也可以直接点 **Sync Now** 让频道继承分类的权限。
</Steps>
## 第四步:测试连接
@@ -190,5 +192,6 @@ LobeHub 通过三层叠加配置控制入站消息,全部位于 **高级设置
## 故障排除
- **机器人未在服务器中响应:** 确认机器人已被邀请到服务器并拥有正确的权限,同时启用了消息内容意图。如果 **群组策略** 是 `Disabled` 或 `Allowlist`,确认目标频道在 **允许的频道 ID** 列表里。如果 **允许的用户 ID** 已填,发送者的用户 ID 必须在列表里。
- **某个频道里 @不到机器人:** 通常是因为该频道是 **Private Channel(私有频道)**。Discord 会丢弃所有未被显式加入频道权限列表的用户 / 机器人的事件,无论他们在服务器层级是什么角色。打开该频道的 **设置 → Permissions(权限)**,点击 **Add members or roles**,把机器人或它持有的角色加进来;或者点 **Sync Now** 让频道继承父分类的权限。一个快速肉眼判断:输入 `@<机器人名>` 时如果出现的机器人名字没有下划线 / 链接样式,说明 Discord 认为它无权访问该频道。
- **机器人不回私信:** 打开 **高级设置**,确认 **私信策略** 不是 `Disabled`。如果 **允许的用户 ID** 已填,确认发起方的 Discord 用户 ID 在列表里。
- **测试连接失败:** 仔细检查应用程序 ID、机器人令牌和公钥是否正确。
+3 -3
View File
@@ -3,7 +3,7 @@ title: Connect LobeHub to Feishu (飞书)
description: >-
Learn how to create a Feishu custom app and connect it to your LobeHub agent
as a message channel, enabling your AI assistant to interact with team members
in Feishu chats.
on Feishu.
tags:
- Feishu
- 飞书
@@ -14,7 +14,7 @@ tags:
# Connect LobeHub to Feishu (飞书)
By connecting a Feishu channel to your LobeHub agent, team members can interact with the AI assistant directly in Feishu private chats and group conversations.
By connecting a Feishu channel to your LobeHub agent, team members can interact with the AI assistant directly on Feishu through private chats and group conversations.
> If you are using the international version (Lark), please refer to the [Lark setup guide](/docs/usage/channels/lark).
@@ -172,7 +172,7 @@ By connecting a Feishu channel to your LobeHub agent, team members can interact
## Step 6: Test the Connection
Back in LobeHub's channel settings, click **Test Connection** to verify the credentials. Then find your bot in Feishu by searching its name and send it a message to confirm it responds.
Back in LobeHub's channel settings, click **Test Connection** to verify the credentials. Then find your bot on Feishu by searching its name and send it a message to confirm it responds.
## Step 7: Set Your Platform Identity (Recommended)
+2 -2
View File
@@ -13,7 +13,7 @@ tags:
# Connect LobeHub to Lark
By connecting a Lark channel to your LobeHub agent, team members can interact with the AI assistant directly in Lark private chats and group conversations.
By connecting a Lark channel to your LobeHub agent, team members can interact with the AI assistant directly on Lark through private chats and group conversations.
> If you are using the Chinese version (飞书), please refer to the [Feishu setup guide](/docs/usage/channels/feishu).
@@ -163,7 +163,7 @@ By connecting a Lark channel to your LobeHub agent, team members can interact wi
## Step 6: Test the Connection
Back in LobeHub's channel settings, click **Test Connection** to verify the credentials. Then find your bot in Lark by searching its name and send it a message to confirm it responds.
Back in LobeHub's channel settings, click **Test Connection** to verify the credentials. Then find your bot on Lark by searching its name and send it a message to confirm it responds.
## Step 7: Set Your Platform Identity (Recommended)
+4 -4
View File
@@ -2,7 +2,7 @@
title: Connect LobeHub to LINE
description: >-
Learn how to connect a LINE Messaging API bot to your LobeHub agent,
enabling your AI assistant to chat with users in LINE direct messages and
enabling your AI assistant to chat with users on LINE through direct messages and
group conversations.
tags:
- LINE
@@ -145,11 +145,11 @@ By default the LINE Official Account Manager auto-replies to user messages and s
<Steps>
### Add the bot as a friend
Open the **Messaging API** tab in the LINE Developers Console and scan the bot's **QR code** with your phone, or search for the **Bot basic ID** (e.g. `@abc1234x`) in LINE.
Open the **Messaging API** tab in the LINE Developers Console and scan the bot's **QR code** with your phone, or search for the **Bot basic ID** (e.g. `@abc1234x`) on LINE.
### Send a real message
Send any message to the bot in LINE. Within a few seconds your LobeHub agent should reply.
Send any message to the bot on LINE. Within a few seconds your LobeHub agent should reply.
### Run Test Connection (optional)
@@ -190,7 +190,7 @@ LINE's Messaging API has a few specifics that LobeHub maps as follows:
- **"Verify" fails in the LINE Developers Console.** The Channel Secret in LobeHub must match the value shown on the LINE Developers Console **Basic settings** tab exactly. Re-paste it, save, and try again.
- **`Authentication failed.` on Save / Test Connection.** Your Channel Access Token is invalid or expired. Re-issue the long-lived token in the Messaging API tab and paste the new value into LobeHub.
- **`Channel access token belongs to bot Uxxx, not Uyyy`.** The Destination User ID does not match the token. Easiest fix: clear the field and click **Fetch from LINE** to re-pull the correct `userId`. The `Uxxx` shown in the error is also the userId the token actually belongs to — you can paste it in directly. (Manual check: `curl -H "Authorization: Bearer <token>" https://api.line.me/v2/bot/info`.)
- **Webhook delivery is rejected with `401 Invalid signature`.** The Channel Secret in LobeHub doesn't match the one in LINE. Update LobeHub with the correct Channel Secret.
- **Webhook delivery is rejected with `401 Invalid signature`.** The Channel Secret in LobeHub doesn't match the one shown in the LINE Developers Console. Update LobeHub with the correct Channel Secret.
- **Bot doesn't respond.** Check that:
1. **Use webhook** is toggled **ON** in the Messaging API tab.
2. **Auto-response messages** and **Greeting message** are disabled in the LINE Official Account Manager.
+1 -1
View File
@@ -2,7 +2,7 @@
title: Connect LobeHub to QQ
description: >-
Learn how to create a QQ bot and connect it to your LobeHub agent as a message
channel, enabling your AI assistant to chat with users in QQ group chats and
channel, enabling your AI assistant to chat with users on QQ through group chats and
direct messages.
tags:
- QQ
+3 -3
View File
@@ -2,8 +2,8 @@
title: Connect LobeHub to Slack
description: >-
Learn how to create a Slack app and connect it to your LobeHub agent as a
message channel, enabling your AI assistant to interact with users in Slack
channels and direct messages.
message channel, enabling your AI assistant to interact with users on Slack
through channels and direct messages.
tags:
- Slack
- Message Channels
@@ -165,7 +165,7 @@ LobeHub supports two connection modes for Slack:
Click **Test Connection** in LobeHub, then go to Slack, invite the bot to a channel, and mention it with `@LobeHub Assistant` to confirm it responds.
> **Slash Commands:** If you used the manifest template above, the `/new` and `/stop` commands are automatically configured. Type `/new` in Slack to reset the conversation, or `/stop` to stop the current execution. You can also use these commands via `@bot /new`.
> **Slash Commands:** If you used the manifest template above, the `/new` and `/stop` commands are automatically configured. Type `/new` on Slack to reset the conversation, or `/stop` to stop the current execution. You can also use these commands via `@bot /new`.
---
+1 -13
View File
@@ -2,7 +2,7 @@
title: Connect LobeHub to Telegram
description: >-
Learn how to create a Telegram bot and connect it to your LobeHub agent as a
message channel, enabling your AI assistant to chat with users in Telegram
message channel, enabling your AI assistant to chat with users on Telegram
private and group conversations.
tags:
- Telegram
@@ -27,8 +27,6 @@ By connecting a Telegram channel to your LobeHub agent, users can interact with
Open Telegram and search for **@BotFather** — the official Telegram bot for managing bots. Start a conversation and send the `/newbot` command.
![](/blog/assets1be39423d2bca3a6ee3f247e02a638be.webp)
### Set Bot Name and Username
BotFather will ask you to:
@@ -36,14 +34,10 @@ By connecting a Telegram channel to your LobeHub agent, users can interact with
1. Choose a **display name** for your bot (e.g., "LobeHub Assistant")
2. Choose a **username** — it must end with `bot` (e.g., `lobehub_assistant_bot`)
![](/blog/assetsa95ea7fad4727559d3f8d84a96947d5e.webp)
### Copy the Bot Token
After creating the bot, BotFather will send you an **API token** (format: `123456789:ABCdefGhIjKlmNoPQRsTuVwXyZ`). Copy and save this token.
![](/blog/assets95dc1ff1901807b3f860b70294667682.webp)
> **Important:** Your bot token is a secret credential. Never share it publicly.
</Steps>
@@ -60,8 +54,6 @@ By connecting a Telegram channel to your LobeHub agent, users can interact with
The **Bot User ID** will be automatically derived from your token — no need to enter it manually.
![](/blog/assets939b659e955daf90e2e9e7caba8aa9bd.webp)
### Optional: Set a Webhook Secret
You can optionally enter a **Webhook Secret Token** for additional security. This is used to verify that incoming webhook requests originate from Telegram.
@@ -77,8 +69,6 @@ By connecting a Telegram channel to your LobeHub agent, users can interact with
Click **Test Connection** in LobeHub's channel settings to verify the integration. Then open Telegram, find your bot by searching its username, and send a message. The bot should respond through your LobeHub agent.
![](/blog/assets5dd8b54083201bff2494404b66e37df0.webp)
## Set Your Platform Identity (Recommended)
One optional field under **Advanced Settings** carries a lot of weight in day-to-day use — fill it in once and most surprises go away.
@@ -103,8 +93,6 @@ To use the bot in Telegram groups:
2. By default, the bot responds when mentioned with `@your_bot_username`
3. Send a message mentioning the bot to start interacting
![](/blog/assetsfa30300bd730d56097bfbce49c5f3d06.webp)
<Callout type={'warning'}>
**About Group Privacy Mode:** Telegram bots have privacy mode enabled by default, which means they only receive messages that @mention the bot, reply to the bot, or contain /commands. If you change the privacy mode setting after creating the bot, you **must remove and re-add the bot to the group** for the new setting to take effect in that group.
</Callout>
-12
View File
@@ -26,8 +26,6 @@ tags:
打开 Telegram 并搜索 **@BotFather** —— 这是用于管理机器人的官方 Telegram 机器人。开始对话并发送 `/newbot` 命令。
![](/blog/assets1be39423d2bca3a6ee3f247e02a638be.webp)
### 设置机器人名称和用户名
BotFather 会要求您:
@@ -35,14 +33,10 @@ tags:
1. 为您的机器人选择一个 **显示名称**(例如,“LobeHub 助手”)
2. 选择一个 **用户名** —— 必须以 `bot` 结尾(例如,`lobehub_assistant_bot`
![](/blog/assetsa95ea7fad4727559d3f8d84a96947d5e.webp)
### 复制机器人令牌
创建机器人后,BotFather 会发送给您一个 **API 令牌**(格式:`123456789:ABCdefGhIjKlmNoPQRsTuVwXyZ`)。复制并保存此令牌。
![](/blog/assets95dc1ff1901807b3f860b70294667682.webp)
> **重要提示:** 您的机器人令牌是一个机密凭证,请勿公开分享。
</Steps>
@@ -59,8 +53,6 @@ tags:
**机器人用户 ID** 将根据您的令牌自动生成,无需手动输入。
![](/blog/assets939b659e955daf90e2e9e7caba8aa9bd.webp)
### 可选:设置 Webhook 密钥
您可以选择输入一个 **Webhook 密钥令牌** 以增加安全性。此密钥用于验证来自 Telegram 的入站 Webhook 请求。
@@ -76,8 +68,6 @@ tags:
在 LobeHub 的渠道设置中点击 **测试连接** 以验证集成。然后打开 Telegram,搜索您的机器人用户名并发送消息。机器人应通过您的 LobeHub 代理进行响应。
![](/blog/assets5dd8b54083201bff2494404b66e37df0.webp)
## 填写你的平台身份(推荐)
**高级设置**里有一个可选字段影响日常使用体验,建议一开始就填好。
@@ -102,8 +92,6 @@ tags:
2. 默认情况下,机器人在被 `@your_bot_username` 提及时会响应
3. 发送一条提及机器人的消息以开始互动
![](/blog/assetsfa30300bd730d56097bfbce49c5f3d06.webp)
<Callout type={'warning'}>
**关于隐私模式(Group Privacy):** Telegram 机器人默认启用隐私模式,仅接收群组中 @提及、回复机器人的消息以及 / 命令。如果您在创建机器人后更改了隐私模式设置,**必须将机器人从群组中移除后重新加入**,新的设置才会对该群组生效。
</Callout>
+2 -2
View File
@@ -2,7 +2,7 @@
title: Connect LobeHub to WeChat
description: >-
Learn how to connect a WeChat bot to your LobeHub agent via the iLink Bot API,
enabling your AI assistant to chat with users in WeChat private and group
enabling your AI assistant to chat with users on WeChat through private and group
conversations.
tags:
- WeChat
@@ -37,7 +37,7 @@ In LobeHub, navigate to your agent's settings, then select the **Channels** tab.
### Confirm Login
After scanning, a confirmation prompt will appear in WeChat. Tap **Confirm** to authorize the connection.
After scanning, a confirmation prompt will appear on WeChat. Tap **Confirm** to authorize the connection.
![](/blog/assets8a08815733e06500b6552019d6dfbe7b.webp)
+9 -9
View File
@@ -2,8 +2,8 @@
title: Use LobeHub on Discord
description: >-
Add the official LobeHub bot to a Discord server, then link your Discord
account to LobeHub. Pick a default agent and chat with your AI assistants in
Discord DMs — no bot setup required.
account to LobeHub. Pick a default agent and chat with your AI assistants on
Discord through DMs — no bot setup required.
tags:
- Messenger
- Discord
@@ -33,7 +33,7 @@ Discord works in two phases: a **server admin adds** the official LobeHub bot to
{/* TODO: screenshot — Discord detail page with empty Connections list and the Connect button */}
### Authorise in Discord
### Authorise on Discord
You'll be redirected to Discord's bot-add screen. Pick the server you want to add the bot to and click **Authorise**.
@@ -45,15 +45,15 @@ Discord works in two phases: a **server admin adds** the official LobeHub bot to
{/* TODO: screenshot — Discord detail page showing one connected server row */}
> **Server already connected by someone else?** LobeHub shows a "Server already connected" notice. You don't need to add the bot again — just DM the LobeHub bot in Discord to link your personal account.
> **Server already connected by someone else?** LobeHub shows a "Server already connected" notice. You don't need to add the bot again — just DM the LobeHub bot on Discord to link your personal account.
</Steps>
## Phase B — Link your personal account (each member)
<Steps>
### Open the LobeHub bot in Discord
### Open the LobeHub bot on Discord
Open the LobeHub bot in Discord — the **Open in Discord** button on the Discord detail page (or the pending-link row) takes you straight there.
Open the LobeHub bot on Discord — the **Open in Discord** button on the Discord detail page (or the pending-link row) takes you straight there.
{/* TODO: screenshot — Discord detail page with the pending user row + Open in Discord button */}
@@ -65,7 +65,7 @@ Discord works in two phases: a **server admin adds** the official LobeHub bot to
### Confirm the link in your browser
Tap the link, sign in to LobeHub if asked, and choose a **default agent**. Every message you DM the LobeHub bot in Discord (across all servers) will route to this agent.
Tap the link, sign in to LobeHub if asked, and choose a **default agent**. Every message you DM the LobeHub bot on Discord (across all servers) will route to this agent.
{/* TODO: screenshot — confirm-link page in LobeHub with the agent picker */}
@@ -78,7 +78,7 @@ Discord works in two phases: a **server admin adds** the official LobeHub bot to
Two equivalent ways:
- **In Discord** — DM the bot `/agents` and pick a different agent.
- **On Discord** — DM the bot `/agents` and pick a different agent.
- **In LobeHub** — open **Settings → Messenger → Discord** and use the agent picker on your link row.
The change takes effect on the next message you send.
@@ -96,7 +96,7 @@ You can re-add the bot to a server (or re-link personally) at any time by repeat
## Troubleshooting
- **"Server already connected"** — Another LobeHub user already added the bot to this server. DM the LobeHub bot in Discord to link your personal account; you do not need to add the bot again.
- **"Server already connected"** — Another LobeHub user already added the bot to this server. DM the LobeHub bot on Discord to link your personal account; you do not need to add the bot again.
- **Discord install failed (`<reason>`)** — Common reasons: authorisation cancelled, install session expired (re-open the modal and try again), Discord returned incomplete data (retry; if persistent, contact support).
- **Bot is in the server but doesn't reply** — Check that you have a personal link under **Settings → Messenger → Discord**. The bot only answers users with a confirmed personal link.
- **Removed the audit row but the bot is still in my server** — That's expected. Disconnecting in LobeHub only removes the audit entry; a Discord server admin must kick the bot from Discord itself.
+5 -5
View File
@@ -31,7 +31,7 @@ Slack works in two phases: a **workspace admin installs** the official LobeHub S
{/* TODO: screenshot — Slack detail page with empty Connections list and the Connect button */}
### Authorise in Slack
### Authorise on Slack
You'll be redirected to Slack's authorisation screen. Pick the workspace you want to install into and click **Allow**.
@@ -43,15 +43,15 @@ Slack works in two phases: a **workspace admin installs** the official LobeHub S
{/* TODO: screenshot — Slack detail page showing one connected workspace row */}
> **Workspace already connected by someone else?** LobeHub blocks the install and shows a "Workspace already connected" notice. You don't need to install again — just DM **@LobeHub** in Slack to link your personal account. If you want to take over ownership, ask the original installer to disconnect the workspace first.
> **Workspace already connected by someone else?** LobeHub blocks the install and shows a "Workspace already connected" notice. You don't need to install again — just DM **@LobeHub** on Slack to link your personal account. If you want to take over ownership, ask the original installer to disconnect the workspace first.
</Steps>
## Phase B — Link your personal account (each member)
<Steps>
### Open the LobeHub bot in Slack
### Open the LobeHub bot on Slack
In Slack, open the **Apps** sidebar and find **LobeHub**, or search for `@LobeHub`. Open a DM with the bot.
On Slack, open the **Apps** sidebar and find **LobeHub**, or search for `@LobeHub`. Open a DM with the bot.
{/* TODO: screenshot — Slack apps sidebar with LobeHub highlighted */}
@@ -76,7 +76,7 @@ Slack works in two phases: a **workspace admin installs** the official LobeHub S
Two equivalent ways:
- **In Slack** — DM the bot `/agents` and pick a different agent.
- **On Slack** — DM the bot `/agents` and pick a different agent.
- **In LobeHub** — open **Settings → Messenger → Slack** and use the agent picker on your link row.
The change takes effect on the next message you send.
+3 -3
View File
@@ -32,7 +32,7 @@ On the Telegram detail page, click **Connect** in the top-right corner. A modal
{/* TODO: screenshot — Telegram detail page with empty Connections list and the Connect button */}
## Step 3: Open the bot in Telegram
## Step 3: Open the bot on Telegram
Either tap **Open in Telegram** in the modal, or scan the QR code with your phone. Telegram opens the official LobeHub bot.
@@ -58,7 +58,7 @@ After confirming, the Telegram detail page shows your link as a "user" row with
Two equivalent ways:
- **In Telegram** — send `/agents` to the bot and pick a different agent.
- **On Telegram** — send `/agents` to the bot and pick a different agent.
- **In LobeHub** — open **Settings → Messenger → Telegram** and use the agent picker on your link row.
The change takes effect on the next message you send.
@@ -67,7 +67,7 @@ The change takes effect on the next message you send.
In **Settings → Messenger → Telegram**, click **Disconnect** on the link row. The bot will stop accepting your messages until you re-link by sending `/start` again.
> Disconnecting from LobeHub does not remove the bot from your Telegram chat list — you can manually delete the chat in Telegram if you no longer want to see it.
> Disconnecting from LobeHub does not remove the bot from your Telegram chat list — you can manually delete the chat on Telegram if you no longer want to see it.
## Troubleshooting
+19 -1
View File
@@ -196,6 +196,9 @@
"groupWizard.searchTemplates": "البحث في القوالب...",
"groupWizard.title": "إنشاء مجموعة",
"groupWizard.useTemplate": "استخدام قالب",
"heteroAgent.cloudNotConfigured.action": "تكوين",
"heteroAgent.cloudNotConfigured.desc": "قم بتكوين رمز Claude Code في ملف تعريف الوكيل الخاص بك لبدء إرسال الرسائل.",
"heteroAgent.cloudNotConfigured.title": "مطلوب بيانات اعتماد السحابة",
"heteroAgent.cloudRepo.multiSelected": "{{count}} مستودعات محددة",
"heteroAgent.cloudRepo.noRepos": "لم يتم تكوين أي مستودعات. أضفها في إعدادات الوكيل.",
"heteroAgent.cloudRepo.notSet": "لم يتم تحديد أي مستودع",
@@ -217,6 +220,13 @@
"inbox.title": "Lobe AI",
"input.addAi": "إضافة رسالة من الذكاء الاصطناعي",
"input.addUser": "إضافة رسالة من المستخدم",
"input.costEstimate.creditsPerMillionTokens": "{{credits}} ائتمان/مليون رموز",
"input.costEstimate.hint": "التكلفة المقدرة: ~{{credits}} ائتمان",
"input.costEstimate.inputLabel": "الإدخال",
"input.costEstimate.outputLabel": "الإخراج",
"input.costEstimate.settingsLink": "ضبط حد التحذير",
"input.costEstimate.tokenCount": "~{{tokens}} رموز",
"input.costEstimate.tooltip": "تم التقدير بناءً على السياق الحالي، الأدوات، وتسعير النموذج. قد تختلف التكلفة الفعلية.",
"input.disclaimer": "قد يخطئ الوكلاء. استخدم حكمك الخاص للمعلومات الحساسة.",
"input.errorMsg": "فشل الإرسال: {{errorMsg}}. أعد المحاولة أو أرسل لاحقًا.",
"input.more": "المزيد",
@@ -467,6 +477,10 @@
"sharePage.error.unauthorized.subtitle": "يرجى تسجيل الدخول لعرض هذا الموضوع المشترك.",
"sharePage.error.unauthorized.title": "مطلوب تسجيل الدخول",
"sharePageDisclaimer": "تمت مشاركة هذا المحتوى من قبل مستخدم ولا يعبر عن آراء LobeHub. لا تتحمل LobeHub أي مسؤولية عن أي نتائج ناتجة عن هذا المحتوى المشترك.",
"signalCallbacks.collapse": "إخفاء التفاصيل",
"signalCallbacks.empty": "لا توجد رسائل استرجاع",
"signalCallbacks.expand": "عرض التفاصيل",
"signalCallbacks.title": "{{tool}} · {{count}} تحديثات الاسترجاع",
"stt.action": "إدخال صوتي",
"stt.loading": "جارٍ التعرف...",
"stt.prettifying": "جارٍ التجميل...",
@@ -689,7 +703,6 @@
"thread.closeSubagentThread": "طي محادثة الوكيل الفرعي",
"thread.divider": "موضوع فرعي",
"thread.openSubagentThread": "عرض محادثة الوكيل الفرعي كاملة",
"thread.subagentBadge": "وكيل فرعي",
"thread.subagentReadOnlyHint": "المحادثات مع الوكيل الفرعي للقراءة فقط — يتم التنفيذ بواسطة الوكيل الرئيسي.",
"thread.threadMessageCount": "{{messageCount}} رسالة",
"thread.title": "موضوع فرعي",
@@ -773,6 +786,7 @@
"upload.preview.prepareTasks": "جارٍ تجهيز الأجزاء...",
"upload.preview.status.pending": "جارٍ التحضير للرفع...",
"upload.preview.status.processing": "جارٍ معالجة الملف...",
"upload.validation.unsupportedFileType": "نوع الملف غير مدعوم: {{files}}. الصور المدعومة: JPG، PNG، GIF، WebP. المستندات المدعومة تشمل PDF، Word، Excel، PowerPoint، Markdown، النص، CSV، JSON، وملفات التعليمات البرمجية.",
"upload.validation.videoSizeExceeded": "يجب ألا يتجاوز حجم ملف الفيديو 20 ميغابايت. الحجم الحالي هو {{actualSize}}.",
"viewMode.fullWidth": "العرض الكامل",
"viewMode.normal": "قياسي",
@@ -888,6 +902,8 @@
"workingPanel.localFile.closeOther": "إغلاق الآخرين",
"workingPanel.localFile.closeRight": "إغلاق إلى اليمين",
"workingPanel.localFile.error": "تعذر تحميل هذا الملف",
"workingPanel.localFile.preview.raw": "خام",
"workingPanel.localFile.preview.render": "معاينة",
"workingPanel.localFile.truncated": "تم تقليص معاينة الملف إلى {{limit}} حرفًا",
"workingPanel.progress": "Progress",
"workingPanel.progress.allCompleted": "All tasks completed",
@@ -953,6 +969,8 @@
"workingPanel.review.viewMode.unified": "التبديل إلى العرض الموحد",
"workingPanel.review.wordWrap.disable": "تعطيل التفاف النص",
"workingPanel.review.wordWrap.enable": "تمكين التفاف النص",
"workingPanel.skills.empty": "لم يتم العثور على مهارات في هذا المشروع",
"workingPanel.skills.title": "المهارات",
"workingPanel.space": "مسافة",
"workingPanel.title": "Working Panel",
"you": "أنت",
+10
View File
@@ -121,6 +121,16 @@
"ModelSwitchPanel.detail.context": "طول السياق",
"ModelSwitchPanel.detail.pricing": "الأسعار",
"ModelSwitchPanel.detail.pricing.cachedInput": "المدخلات المخزنة ${{amount}}/مليون",
"ModelSwitchPanel.detail.pricing.credits.cachedInput": "مدخل مخزن {{amount}} أرصدة/مليون رموز",
"ModelSwitchPanel.detail.pricing.credits.image": "أرصدة/صورة",
"ModelSwitchPanel.detail.pricing.credits.input": "مدخل {{amount}} أرصدة/مليون رموز",
"ModelSwitchPanel.detail.pricing.credits.megapixel": "أرصدة/ميجا بكسل",
"ModelSwitchPanel.detail.pricing.credits.millionCharacters": "أرصدة/مليون أحرف",
"ModelSwitchPanel.detail.pricing.credits.millionTokens": "أرصدة/مليون رموز",
"ModelSwitchPanel.detail.pricing.credits.output": "مخرج {{amount}} أرصدة/مليون رموز",
"ModelSwitchPanel.detail.pricing.credits.perImage": "~ {{amount}} أرصدة / صورة",
"ModelSwitchPanel.detail.pricing.credits.perVideo": "~ {{amount}} أرصدة / فيديو",
"ModelSwitchPanel.detail.pricing.credits.second": "أرصدة/ثانية",
"ModelSwitchPanel.detail.pricing.group.audio": "الصوت",
"ModelSwitchPanel.detail.pricing.group.image": "الصورة",
"ModelSwitchPanel.detail.pricing.group.text": "النص",
+2
View File
@@ -1,8 +1,10 @@
{
"actionTag.category.command": "أمر",
"actionTag.category.projectSkill": "مهارة المشروع",
"actionTag.category.skill": "مهارة",
"actionTag.category.tool": "أداة",
"actionTag.tooltip.command": "يشغّل أمر الشرطة المائلة على جانب العميل قبل الإرسال.",
"actionTag.tooltip.projectSkill": "يتم إرسالها كاستدعاء شرطة مائلة بحيث يقوم CLI الخاص بالوكيل بتشغيل مهارة المشروع المطابقة.",
"actionTag.tooltip.skill": "يحمّل حزمة مهارات قابلة لإعادة الاستخدام لهذا الطلب.",
"actionTag.tooltip.tool": "يشير إلى أداة اختارها المستخدم صراحةً لهذا الطلب.",
"actions.expand.off": "طي",
+13
View File
@@ -11,6 +11,19 @@
"brief.action.ignore": "تجاهل",
"brief.action.retry": "إعادة المحاولة",
"brief.addFeedback": "مشاركة الملاحظات",
"brief.agentSignal.selfReview.applied.heading": "محدث",
"brief.agentSignal.selfReview.applied.summary": "تم تطبيق تحديث حلم واحد.",
"brief.agentSignal.selfReview.applied.summary_plural": "تم تطبيق {{count}} تحديثات حلم.",
"brief.agentSignal.selfReview.applied.title": "تم تحديث موارد الحلم",
"brief.agentSignal.selfReview.error.heading": "مشكلة",
"brief.agentSignal.selfReview.error.summary": "تعذر إكمال بعض الأعمال أثناء هذا الحلم.",
"brief.agentSignal.selfReview.error.title": "واجه الحلم مشكلة",
"brief.agentSignal.selfReview.ideas.summary": "تم حفظ ملاحظات الحلم للمراجعة المستقبلية.",
"brief.agentSignal.selfReview.ideas.title": "ملاحظات الحلم",
"brief.agentSignal.selfReview.proposal.heading": "اقتراح",
"brief.agentSignal.selfReview.proposal.summary": "هناك اقتراح حلم واحد يحتاج إلى مراجعتك.",
"brief.agentSignal.selfReview.proposal.summary_plural": "هناك {{count}} اقتراحات حلم تحتاج إلى مراجعتك.",
"brief.agentSignal.selfReview.proposal.title": "اقتراح الحلم يحتاج إلى مراجعة",
"brief.collapse": "عرض أقل",
"brief.commentPlaceholder": "شارك ملاحظاتك...",
"brief.commentSubmit": "إرسال الملاحظات",
-2
View File
@@ -1,8 +1,6 @@
{
"addUserMessage.desc": "أضف الإدخال الحالي كرسالة مستخدم دون بدء التوليد",
"addUserMessage.title": "إضافة رسالة مستخدم",
"clearCurrentMessages.desc": "مسح الرسائل والملفات المرفوعة من المحادثة الحالية",
"clearCurrentMessages.title": "مسح رسائل المحادثة",
"commandPalette.desc": "افتح لوحة الأوامر العامة للوصول السريع إلى الميزات",
"commandPalette.title": "لوحة الأوامر",
"deleteAndRegenerateMessage.desc": "حذف الرسالة الأخيرة وإعادة توليدها",
+8 -7
View File
@@ -314,7 +314,7 @@
"claude-3-haiku-20240307.description": "Claude 3 Haiku هو أسرع وأصغر نموذج من Anthropic، مصمم لتقديم استجابات شبه فورية بأداء سريع ودقيق.",
"claude-3-opus-20240229.description": "Claude 3 Opus هو أقوى نموذج من Anthropic للمهام المعقدة، يتميز بالأداء العالي، الذكاء، الطلاقة، والفهم.",
"claude-3-sonnet-20240229.description": "Claude 3 Sonnet يوازن بين الذكاء والسرعة لتلبية احتياجات المؤسسات، ويوفر فائدة عالية بتكلفة أقل ونشر موثوق على نطاق واسع.",
"claude-haiku-4-5-20251001.description": "Claude Haiku 4.5 هو النموذج الأسرع والأذكى من Anthropic، يتميز بسرعة البرق وقدرات استدلال موسعة.",
"claude-haiku-4-5-20251001.description": "Claude Haiku 4.5 هو النموذج الأكثر سرعة وذكاءً من Anthropic، يتميز بسرعة البرق وقدرات استدلال موسعة.",
"claude-haiku-4-5.description": "Claude Haiku 4.5 من Anthropic — نموذج Haiku من الجيل التالي مع تحسينات في التفكير والرؤية.",
"claude-haiku-4.5.description": "Claude Haiku 4.5 هو نموذج Haiku الأسرع والأذكى من Anthropic، يتميز بسرعة البرق وقدرات استدلال موسعة.",
"claude-opus-4-1-20250805-thinking.description": "Claude Opus 4.1 Thinking هو إصدار متقدم يمكنه عرض عملية تفكيره.",
@@ -329,7 +329,7 @@
"claude-opus-4.6-fast.description": "Claude Opus 4.6 هو النموذج الأكثر ذكاءً من Anthropic لبناء الوكلاء والبرمجة.",
"claude-opus-4.6.description": "Claude Opus 4.6 هو النموذج الأكثر ذكاءً من Anthropic لبناء الوكلاء والبرمجة.",
"claude-sonnet-4-20250514-thinking.description": "Claude Sonnet 4 Thinking يمكنه تقديم استجابات شبه فورية أو تفكير متسلسل مرئي.",
"claude-sonnet-4-20250514.description": "Claude Sonnet 4 يمكنه تقديم استجابات شبه فورية أو تفكير ممتد خطوة بخطوة مع عرض العملية.",
"claude-sonnet-4-20250514.description": "Claude Sonnet 4 يمكنه تقديم استجابات شبه فورية أو تفكير ممتد خطوة بخطوة مع عملية مرئية.",
"claude-sonnet-4-5-20250929.description": "Claude Sonnet 4.5 هو النموذج الأكثر ذكاءً من Anthropic حتى الآن.",
"claude-sonnet-4-5.description": "Claude Sonnet 4.5 من Anthropic — نموذج Sonnet محسّن مع أداء برمجي معزز.",
"claude-sonnet-4-6.description": "Claude Sonnet 4.6 من Anthropic — أحدث نموذج Sonnet مع برمجة واستخدام أدوات متفوقة.",
@@ -403,7 +403,7 @@
"deepseek-ai/deepseek-llm-67b-chat.description": "DeepSeek LLM Chat (67B) هو نموذج مبتكر يوفر فهمًا عميقًا للغة وتفاعلًا ذكيًا.",
"deepseek-ai/deepseek-v3.1-terminus.description": "DeepSeek V3.1 هو نموذج تفكير من الجيل التالي يتمتع بقدرات أقوى في التفكير المعقد وسلسلة التفكير لمهام التحليل العميق.",
"deepseek-ai/deepseek-v3.2.description": "DeepSeek V3.2 هو نموذج استدلال من الجيل التالي يتميز بقدرات استدلال معقدة وسلسلة التفكير.",
"deepseek-chat.description": "نموذج مفتوح المصدر جديد يجمع بين القدرات العامة والبرمجية. يحافظ على حوار النموذج العام وقوة البرمجة للنموذج البرمجي، مع تحسين توافق التفضيلات. كما يحسن DeepSeek-V2.5 الكتابة واتباع التعليمات.",
"deepseek-chat.description": "نموذج مفتوح المصدر جديد يجمع بين القدرات العامة وقدرات البرمجة. يحافظ على الحوار العام لنموذج الدردشة وقوة البرمجة لنموذج المبرمج، مع تحسين توافق التفضيلات. كما يحسن DeepSeek-V2.5 الكتابة واتباع التعليمات.",
"deepseek-coder-33B-instruct.description": "DeepSeek Coder 33B هو نموذج لغة برمجية تم تدريبه على 2 تريليون رمز (87٪ كود، 13٪ نص صيني/إنجليزي). يقدم نافذة سياق 16K ومهام الإكمال في المنتصف، ويوفر إكمال كود على مستوى المشاريع وملء مقاطع الكود.",
"deepseek-coder-v2.description": "DeepSeek Coder V2 هو نموذج كود MoE مفتوح المصدر يتميز بأداء قوي في مهام البرمجة، ويضاهي GPT-4 Turbo.",
"deepseek-coder-v2:236b.description": "DeepSeek Coder V2 هو نموذج كود MoE مفتوح المصدر يتميز بأداء قوي في مهام البرمجة، ويضاهي GPT-4 Turbo.",
@@ -425,7 +425,7 @@
"deepseek-r1-fast-online.description": "الإصدار الكامل السريع من DeepSeek R1 مع بحث ويب في الوقت الحقيقي، يجمع بين قدرات بحجم 671B واستجابة أسرع.",
"deepseek-r1-online.description": "الإصدار الكامل من DeepSeek R1 مع 671 مليار معلمة وبحث ويب في الوقت الحقيقي، يوفر فهمًا وتوليدًا أقوى.",
"deepseek-r1.description": "يستخدم DeepSeek-R1 بيانات البداية الباردة قبل التعلم المعزز ويؤدي أداءً مماثلًا لـ OpenAI-o1 في الرياضيات، والبرمجة، والتفكير.",
"deepseek-reasoner.description": "نموذج DeepSeek مخصص لمهام الاستدلال المنطقي المعقدة.",
"deepseek-reasoner.description": "نموذج استدلال DeepSeek يركز على مهام الاستدلال المنطقي المعقدة.",
"deepseek-v2.description": "DeepSeek V2 هو نموذج MoE فعال لمعالجة منخفضة التكلفة.",
"deepseek-v2:236b.description": "DeepSeek V2 236B هو نموذج DeepSeek الموجه للبرمجة مع قدرات قوية في توليد الكود.",
"deepseek-v3-0324.description": "DeepSeek-V3-0324 هو نموذج MoE يحتوي على 671 مليار معلمة يتميز بقوة في البرمجة، والقدرات التقنية، وفهم السياق، والتعامل مع النصوص الطويلة.",
@@ -503,6 +503,7 @@
"ernie-5.0-thinking-latest.description": "Wenxin 5.0 Thinking هو نموذج رائد متعدد الوسائط أصلي يدعم النصوص، الصور، الصوت، والفيديو بشكل موحد. يوفر ترقيات شاملة للقدرات في الأسئلة المعقدة، الإبداع، وسيناريوهات الوكلاء.",
"ernie-5.0-thinking-preview.description": "معاينة Wenxin 5.0 Thinking هو نموذج رائد متعدد الوسائط أصلي يدعم النصوص، الصور، الصوت، والفيديو بشكل موحد. يوفر ترقيات شاملة للقدرات في الأسئلة المعقدة، الإبداع، وسيناريوهات الوكلاء.",
"ernie-5.0.description": "ERNIE 5.0، النموذج الجديد في سلسلة ERNIE، هو نموذج كبير متعدد الوسائط أصلي. يعتمد نهج نمذجة متعدد الوسائط موحد، حيث يقوم بنمذجة النصوص، الصور، الصوت، والفيديو بشكل مشترك لتقديم قدرات متعددة الوسائط شاملة. تم تحسين قدراته الأساسية بشكل كبير، محققًا أداءً قويًا في تقييمات المعايير. يتفوق بشكل خاص في الفهم متعدد الوسائط، اتباع التعليمات، الكتابة الإبداعية، الدقة الواقعية، تخطيط الوكلاء، واستخدام الأدوات.",
"ernie-5.1.description": "ERNIE 5.1 هو أحدث نموذج في سلسلة ERNIE، يتميز بترقيات شاملة لقدراته الأساسية. يظهر تحسينات كبيرة في مجالات مثل الوكلاء، معالجة المعرفة، الاستدلال، والبحث العميق. يعتمد هذا الإصدار على بنية تعلم معزز غير متزامن بالكامل ومفككة، مصممة خصيصًا لمعالجة التحديات الرئيسية في تطور النماذج الكبيرة نحو اتخاذ القرارات الذاتية للوكلاء، بما في ذلك التناقضات العددية بين التدريب والاستدلال، انخفاض استخدام موارد الحوسبة غير المتجانسة، والقضايا العالمية الناتجة عن تأثيرات الذيل الطويل. بالإضافة إلى ذلك، يتم استخدام تقنيات تدريب ما بعد واسعة النطاق للوكلاء لتعزيز قدرات النموذج وأداء التعميم. من خلال إطار عمل تعاوني من ثلاث مراحل يشمل البيئة، الخبراء، وعمليات الدمج، لا يضمن النهج كفاءة التدريب فحسب، بل يحسن بشكل كبير من استقرار النموذج وأدائه في المهام المعقدة.",
"ernie-char-fiction-8k-preview.description": "معاينة ERNIE Character Fiction 8K هو نموذج لإنشاء الشخصيات والحبكات القصصية، مخصص لتقييم الميزات والاختبار.",
"ernie-char-fiction-8k.description": "ERNIE Character Fiction 8K هو نموذج شخصيات للروايات وإنشاء الحبكات، مناسب لتوليد القصص الطويلة.",
"ernie-image-turbo.description": "ERNIE-Image هو نموذج نص إلى صورة بـ 8 مليارات معلمة تم تطويره بواسطة Baidu. يحتل المرتبة الأولى في العديد من المعايير، محققًا المركز الأول في SuperCLUE في الصين ويتصدر المسار مفتوح المصدر.",
@@ -514,7 +515,7 @@
"ernie-x1-turbo-32k.description": "ERNIE X1 Turbo 32K هو نموذج تفكير سريع بسياق 32K للاستدلال المعقد والدردشة متعددة الأدوار.",
"ernie-x1.1-preview.description": "معاينة ERNIE X1.1 هو نموذج تفكير مخصص للتقييم والاختبار.",
"ernie-x1.1.description": "ERNIE X1.1 هو نموذج تفكير تجريبي للتقييم والاختبار.",
"fal-ai/bytedance/seedream/v4.description": "Seedream 4.0 هو نموذج توليد الصور من ByteDance Seed، يدعم إدخال النصوص والصور مع توليد صور عالية الجودة وقابلة للتحكم بشكل كبير. يقوم بتوليد الصور من التعليمات النصية.",
"fal-ai/bytedance/seedream/v4.description": "Seedream 4.0 هو نموذج توليد الصور من ByteDance Seed، يدعم المدخلات النصية والصورية مع توليد صور عالي الجودة وقابل للتحكم بدرجة كبيرة. يقوم بتوليد الصور من التعليمات النصية.",
"fal-ai/flux-kontext/dev.description": "نموذج FLUX.1 يركز على تحرير الصور، ويدعم إدخال النصوص والصور.",
"fal-ai/flux-pro/kontext.description": "FLUX.1 Kontext [pro] يقبل النصوص وصور مرجعية كمدخلات، مما يتيح تعديلات محلية مستهدفة وتحولات معقدة في المشهد العام.",
"fal-ai/flux/krea.description": "Flux Krea [dev] هو نموذج لتوليد الصور يتميز بميول جمالية نحو صور أكثر واقعية وطبيعية.",
@@ -523,7 +524,7 @@
"fal-ai/imagen4/preview.description": "نموذج عالي الجودة لتوليد الصور من Google.",
"fal-ai/nano-banana.description": "Nano Banana هو أحدث وأسرع وأكثر نماذج Google كفاءةً لتوليد وتحرير الصور من خلال المحادثة.",
"fal-ai/qwen-image-edit.description": "نموذج تحرير الصور الاحترافي من فريق Qwen يدعم التعديلات الدلالية والمظهرية، ويحرر النصوص الصينية والإنجليزية بدقة، ويمكّن من تعديلات عالية الجودة مثل نقل الأنماط وتدوير الكائنات.",
"fal-ai/qwen-image.description": "نموذج توليد الصور القوي من فريق Qwen يتميز بعرض نصوص صينية مذهلة وأنماط بصرية متنوعة.",
"fal-ai/qwen-image.description": "نموذج توليد الصور القوي من فريق Qwen يتميز بعرض نصوص صينية مبهرة وأنماط بصرية متنوعة.",
"flux-1-schnell.description": "نموذج تحويل النص إلى صورة يحتوي على 12 مليار معلمة من Black Forest Labs يستخدم تقنيات تقطير الانتشار العدائي الكامن لتوليد صور عالية الجودة في 1-4 خطوات. ينافس البدائل المغلقة ومتاح بموجب ترخيص Apache-2.0 للاستخدام الشخصي والبحثي والتجاري.",
"flux-dev.description": "نموذج مفتوح المصدر مخصص لتوليد الصور لأغراض البحث والابتكار غير التجاري، مع تحسينات فعالة.",
"flux-kontext-max.description": "توليد وتحرير صور سياقية متقدمة، تجمع بين النصوص والصور لتحقيق نتائج دقيقة ومتسقة.",
@@ -565,7 +566,7 @@
"gemini-3-pro-image-preview:image.description": "Gemini 3 Pro Image (Nano Banana Pro) هو نموذج توليد الصور من Google ويدعم أيضًا الدردشة متعددة الوسائط.",
"gemini-3-pro-preview.description": "Gemini 3 Pro هو أقوى نموذج من Google للوكيل الذكي والبرمجة الإبداعية، يقدم تفاعلاً أعمق وصورًا أغنى مع استدلال متقدم.",
"gemini-3.1-flash-image-preview.description": "Gemini 3.1 Flash Image (Nano Banana 2) يقدم جودة صور احترافية بسرعة فائقة مع دعم الدردشة متعددة الوسائط.",
"gemini-3.1-flash-image-preview:image.description": "Gemini 3.1 Flash Image (Nano Banana 2) هو أسرع نموذج توليد صور أصلي من Google مع دعم التفكير، وتوليد الصور الحواري وتحريرها.",
"gemini-3.1-flash-image-preview:image.description": "Gemini 3.1 Flash Image (Nano Banana 2) هو أسرع نموذج توليد صور أصلي من Google مع دعم التفكير، وتوليد الصور الحواري، وتحرير الصور.",
"gemini-3.1-flash-lite-preview.description": "Gemini 3.1 Flash-Lite Preview هو النموذج الأكثر كفاءة من حيث التكلفة من Google، مُحسّن للمهام الوكيلة ذات الحجم الكبير، الترجمة، ومعالجة البيانات.",
"gemini-3.1-flash-lite.description": "Gemini 3.1 Flash-Lite هو النموذج متعدد الوسائط الأكثر كفاءة من Google، مُحسّن للمهام الوكيلية ذات الحجم الكبير، الترجمة، ومعالجة البيانات.",
"gemini-3.1-pro-preview.description": "Gemini 3.1 Pro Preview يحسن من Gemini 3 Pro مع قدرات استدلال محسّنة ويضيف دعم مستوى التفكير المتوسط.",
+14
View File
@@ -27,6 +27,13 @@
"agent.layout.skipConfirm.ok": "تخطي الآن",
"agent.layout.skipConfirm.title": "تخطي الإعداد الآن؟",
"agent.layout.switchMessage": "لست في مزاج لذلك اليوم؟ يمكنك التبديل إلى <modeLink><modeText>{{mode}}</modeText></modeLink> أو <skipLink><skipText>{{skip}}</skipText></skipLink>.",
"agent.layout.switchMessageClassic": "لست في المزاج اليوم؟ يمكنك التبديل إلى <modeLink><modeText>{{mode}}</modeText></modeLink>.",
"agent.messenger.cta.discord": "أضف إلى Discord",
"agent.messenger.cta.slack": "أضف إلى Slack",
"agent.messenger.cta.telegram": "افتح في Telegram",
"agent.messenger.subtitle": "تحدث مع وكيلك على Telegram أو Slack أو Discord",
"agent.messenger.telegramQrCaption": "امسح باستخدام كاميرا هاتفك",
"agent.messenger.title": "ابق معي أينما كنت تراسل",
"agent.modeSwitch.agent": "تفاعلي",
"agent.modeSwitch.classic": "كلاسيكي",
"agent.modeSwitch.collapse": "طي",
@@ -68,6 +75,13 @@
"agent.wrapUp.confirm.content": "سأحفظ ما ناقشناه حتى الآن. يمكنك دائمًا العودة ومتابعة الحديث لاحقًا.",
"agent.wrapUp.confirm.ok": "إنهاء الآن",
"agent.wrapUp.confirm.title": "إنهاء الإعداد الآن؟",
"agentPicker.allCategories": "الكل",
"agentPicker.continue": "متابعة",
"agentPicker.skip": "تخطي الآن",
"agentPicker.subtitle": "أضف بعضًا إلى مكتبتك الآن — اكتشف المزيد في أي وقت لاحقًا.",
"agentPicker.title": "لنضف بعض الوكلاء إلى مكتبتك",
"agentPicker.title2": "اختر ما يناسب طريقة عملك",
"agentPicker.title3": "يمكنك دائمًا إضافة المزيد لاحقًا — ابدأ ببعض الخيارات",
"back": "رجوع",
"finish": "ابدأ الآن",
"interests.area.business": "الأعمال والاستراتيجية",
+18 -2
View File
@@ -39,8 +39,8 @@
"builtins.lobe-agent-documents.inspector.docCount": "{{count}} مستندات",
"builtins.lobe-agent-documents.inspector.opsCount": "{{count}} عمليات",
"builtins.lobe-agent-documents.inspector.opsResult": "{{success}}/{{total}} عمليات",
"builtins.lobe-agent-documents.inspector.target.agent": "في الوكيل",
"builtins.lobe-agent-documents.inspector.target.currentTopic": "في الموضوع",
"builtins.lobe-agent-documents.inspector.scope.agent": "في الوكيل",
"builtins.lobe-agent-documents.inspector.scope.currentTopic": "في الموضوع",
"builtins.lobe-agent-documents.title": "مستندات الوكيل",
"builtins.lobe-agent-management.apiName.callAgent": "وكيل الاتصال",
"builtins.lobe-agent-management.apiName.createAgent": "إنشاء وكيل",
@@ -90,6 +90,14 @@
"builtins.lobe-agent.title": "وكيل لوب",
"builtins.lobe-claude-code.agent.instruction": "تعليمات",
"builtins.lobe-claude-code.agent.result": "النتيجة",
"builtins.lobe-claude-code.task.createLabel": "إنشاء المهمة: ",
"builtins.lobe-claude-code.task.getLabel": "تفحص المهمة #{{taskId}}",
"builtins.lobe-claude-code.task.listLabel": "عرض المهام",
"builtins.lobe-claude-code.task.updateCompleted": "مكتملة",
"builtins.lobe-claude-code.task.updateDeleted": "محذوفة",
"builtins.lobe-claude-code.task.updateInProgress": "بدأت",
"builtins.lobe-claude-code.task.updateLabel": "تحديث المهمة #{{taskId}}",
"builtins.lobe-claude-code.task.updatePending": "إعادة تعيين",
"builtins.lobe-claude-code.todoWrite.allDone": "جميع المهام مكتملة",
"builtins.lobe-claude-code.todoWrite.currentStep": "الخطوة الحالية",
"builtins.lobe-claude-code.todoWrite.todos": "المهام",
@@ -237,6 +245,14 @@
"builtins.lobe-page-agent.apiName.updateNode": "تحديث العقدة",
"builtins.lobe-page-agent.apiName.wrapNodes": "تغليف العقد",
"builtins.lobe-page-agent.title": "الصفحة",
"builtins.lobe-self-feedback-intent.apiName.declareSelfFeedbackIntent": "تسجيل فكرة تحسين",
"builtins.lobe-self-feedback-intent.inspector.gap.proposal": "اقتراح تحسين",
"builtins.lobe-self-feedback-intent.inspector.memory.write": "تسجيل التفضيل",
"builtins.lobe-self-feedback-intent.inspector.rejected": "لم يتم التسجيل",
"builtins.lobe-self-feedback-intent.inspector.skill.consolidate": "تنظيم الأساليب",
"builtins.lobe-self-feedback-intent.inspector.skill.create": "تم العثور على أسلوب جديد",
"builtins.lobe-self-feedback-intent.inspector.skill.refine": "تحسين الأسلوب",
"builtins.lobe-self-feedback-intent.title": "أفكار للتحسين",
"builtins.lobe-skill-store.apiName.importFromMarket": "استيراد من السوق",
"builtins.lobe-skill-store.apiName.importSkill": "استيراد المهارة",
"builtins.lobe-skill-store.apiName.searchSkill": "البحث عن المهارات",

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