Compare commits

...

230 Commits

Author SHA1 Message Date
ONLY-yours 8ef7e7e84e 🐛 fix: restore cold replica state in HeterogeneousPersistenceHandler
Vercel serverless functions are stateless per-request, so `operationStates`
is empty on every `heteroIngest` call. loadOrCreateState always cold-creates.

#14539 fixed `toolMsgIdByCallId` restoration but left `accumulatedContent`,
`toolState.payloads`, and `toolState.persistedIds` empty on cold load,
causing two bugs:

- Content truncation: cold instance starts with `accumulatedContent=''`,
  accumulates only the current batch's text, then writes that shorter string
  on the next step boundary or terminal — overwriting the longer content the
  previous write had already stored in DB.

- Tool duplication / tools[] overwrite: `persistedIds={}` on cold load
  means every `tools_calling` event re-creates already-persisted tool
  messages, and `payloads=[]` means phase 1/3 writes only the current
  batch's tools, wiping previous tools from `assistant.tools[]`.

Fix: in `loadOrCreateState`, fetch the current assistant message and restore
`accumulatedContent`, `accumulatedReasoning`, `toolState.payloads`, and
`toolState.persistedIds` from it. Cold load is now equivalent to warm load.

Also adds two regression tests covering the cold-replica scenarios.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:25:47 +08:00
ONLY-yours aaa8de0254 🐛 fix: extend reconnect guard to cover all in-flight connection statuses
The previous guard only skipped reconnect for 'connecting'/'connected'
but the connection can already be in 'authenticating' or 'reconnecting'
by the time useGatewayReconnect fires, leaving the race window open.

Flip the condition: skip for any status that is not 'disconnected'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:10:35 +08:00
ONLY-yours 644d1b6788 🐛 fix: always pass --cwd /workspace for cloud CC to ensure session resume
CC stores session files at ~/.claude/projects/<encoded-cwd>/.
Without an explicit --cwd the actual working directory can differ
between sandbox invocations, so --resume <heteroSessionId> fails
to locate the previous session files even though the container is
persistent and the ID is correctly stored in topic.metadata.

Default cwd to /workspace for cloud runs (desktop keeps its own
explicit path), guaranteeing a stable session-file location across
page reloads within the same sandbox lifecycle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 22:37:58 +08:00
ONLY-yours c4f7995863 🐛 fix: skip reconnect when gateway action already established a connection
Race condition on new-topic first message:
1. switchTopic loads runningOperation → useGatewayReconnect fires
2. executeGatewayAgent calls connectToGateway (status: connecting)
3. reconnectToGatewayOperation overwrites with resumeOnConnect:true
4. Gateway sees resume on a brand-new session → no events → stuck

Second message works because the client store's runningOperation is
stale (from the first op), so SWR deduplications and no reconnect fires.

Fix: bail out of reconnectToGatewayOperation if gatewayConnections
already shows connecting/connected for that operationId.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 22:29:36 +08:00
Innei 746bf4f316 💄 style(intervention): polish confirmation bar layout (#14587) 2026-05-09 22:21:39 +08:00
AmAzing- 58dd297141 chore: Refine homepage banner copy for channels and skills (#14588) 2026-05-09 22:09:18 +08:00
AmAzing- a4e5a20b4d 🛠️ fix: unify SKILL.md frontmatter parsing and edit validation in agent documents (#14566) 2026-05-09 22:04:05 +08:00
LiJian 95f41f8cec feat: add signOperationJwt with 4h expiry for hetero-agent operations (#14586)
*  feat: add signOperationJwt with 4h expiry for hetero-agent operations

- Add `signOperationJwt(userId)` to internalJwt.ts with 4h expiry and
  `purpose: 'hetero-operation'`, so Claude Code / Codex tasks running
  beyond 5 minutes no longer hit 401 on heteroIngest / heteroFinish
- Update `execAgent` hetero path to use `signOperationJwt` instead of
  `signUserJWT`; gatewayToken continues to use 5m `signUserJWT`
- Add unit tests in `__tests__/internalJwt.test.ts` with correct mocks
  for `jose` (SignJWT class + importJWK) and `authEnv`, covering all
  three signing functions and the expiry difference assertion

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

* 🔒 security: restrict hetero-operation JWT scope to heteroIngest/heteroFinish

A leaked 4-hour sandbox LOBEHUB_JWT must not be replayable against any
other authenticated lambda route.

- Forward `purpose` claim from JWT payload through validateOIDCJWT →
  tokenData → oidcAuth context so middlewares can inspect it
- oidcAuth: reject tokens with purpose 'hetero-operation' — they cannot
  reach any normal authedProcedure route
- New heteroOperationAuth middleware: exclusively accepts
  purpose 'hetero-operation' tokens, rejects all others
- Export heteroAuthedProcedure (baseProcedure + heteroOperationAuth +
  userAuth) from trpc/lambda/index.ts
- heteroIngest / heteroFinish now use heteroAgentProcedure built on
  heteroAuthedProcedure + serverDatabase + HeterogeneousAgentService
- Tests: heteroOperationAuth (4), oidcAuth (4), update heteroIngest
  test caller to supply purpose:'hetero-operation' context (23 total)

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 21:57:50 +08:00
lobehubbot f7fbc1c833 Merge remote-tracking branch 'origin/main' into canary 2026-05-09 13:33:21 +00:00
Innei 0f5fb54cb6 🚀 release: 20260509 (#14563)
# 🚀 LobeHub Release (20260509)

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

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

---

##  Highlights

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

---

## 🏗️ Core Agent & Architecture

### Agent Task System

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

### Agent Signal pipeline

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

### Heterogeneous agent runtime

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

### Knowledge & inline docs

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

### Architecture

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

---

## 📱 Platforms & Integrations

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

---

## 🤖 Models & Providers

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

---

## 🖥️ User Experience

### Onboarding

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

### Home & navigation

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

### Conversation & chat

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

### Skills, agents, devtools

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

### Desktop & CLI

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

---

## 🔧 Tooling

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

---

## 🔒 Security & Reliability

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

---

## 👥 Contributors

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

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

Plus @lobehubbot for i18n and translation maintenance.

---

**Full Changelog**:
https://github.com/lobehub/lobe-chat/compare/v2.1.56...release/weekly-20260509
2026-05-09 21:30:37 +08:00
Innei feaaaba2a9 💄 style(settings): remove image avatar from lab input markdown rendering item (#14582) 2026-05-09 21:15:02 +08:00
YuTengjing 21f6f94bed 🐛 fix: polish task agent manager (#14569) 2026-05-09 20:58:29 +08:00
AmAzing- b180c03e04 feat: migrate Notion to LobeHub Market (#14578)
Migrate Notion to LobeHub Market
2026-05-09 20:55:26 +08:00
Arvin Xu 0d39dff2d5 🐛 fix(agent-runtime): recover malformed tool_call names instead of finishing silently (#14577)
* 🐛 fix(agent-runtime): recover malformed tool_call names instead of finishing silently

When an LLM emits tool_call names without the `____` separator (e.g. `activateTools`
instead of `lobe-activator____activateTools`), the resolver dropped them silently and
the harness finished with "completed without tool calls" — empty assistant bubble,
no error in dashboards.

Three layers of defense:

- Resolver fallback: when the bare name uniquely matches an API across known
  manifests, recover the identifier; ambiguous matches still drop to avoid
  false binding.
- StreamingHandler logs unresolved tool_call names so the silent-drop path is
  observable in debug output.
- GeneralChatAgent surfaces the unresolvable count and names in reasonDetail
  so dashboards can distinguish this from a genuine no-tool completion.

Fixes LOBE-8696

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

* 🐛 fix(agent-runtime): restrict bare-name fallback to tools offered this turn

Address review feedback on the LOBE-8696 resolver fallback. The
manifests map passed to ToolNameResolver.resolve is broader than the
tools actually sent to the LLM (the client builds it from every
installed plugin and every builtin; the server can preserve manifests
even after a step deactivates a tool). Without a turn-scope
restriction:

- A model returning a malformed bare name could resolve to a tool that
  was not enabled for this turn.
- A disabled duplicate API name could shadow the enabled call and make
  it look ambiguous, dropping a valid call.

Pipe an `offeredToolNames` list (the names actually sent in this LLM
payload) into resolve(): when set, the missing-prefix fallback only
considers manifests whose generated tool name appears in the list.

- ToolNameResolver.resolve gains an optional `offeredToolNames` param.
- internal_transformToolCalls forwards the list through.
- createAgentExecutors builds resolvedAgentConfig before the
  StreamingHandler so the closure can bind the offered names — same
  list that gets sent to the model.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:47:21 +08:00
LiJian 6fb24adbd2 feat: Cloud Claude Code V3 — repo picker, GitHub token, sandbox context (#14568)
*  feat: Cloud Claude Code V3 — repo picker, GitHub token, sandbox context

- Add CloudRepoSwitcher component (web-only multi-select repo picker)
  - Pre-topic selections buffered in module singleton (pendingTopicRepos)
  - Consumed by gateway.ts at topic creation time via appContext.initialTopicMetadata
  - Eliminates race condition where updateTopicMetadata dropped silently
- Extend ChatTopicMetadata with repos[] field for multi-repo binding
- Add initialTopicMetadata to ExecAgentAppContext so repos are written to
  topic metadata at creation time (server-side, zero race condition)
- Extend ExecAgentSchema Zod schema with initialTopicMetadata
- Inject GITHUB_TOKEN env var into sandbox so CC can use git/gh CLI
- Build cloudHeteroContext with GitHub auth section when token is available
- Add workingDirectory selector for web (repos[0] fallback)
- Add refreshTopic call in gateway path after new topic creation
- Add CloudHeterogeneousConfig profile editor for GITHUB_REPOS / GITHUB_CRED_KEY
- Extend sandboxRunner with repo clone setup script and systemContext support

* 🐛 fix: add open-source stub for pendingTopicRepos to fix Vite build

* ♻️ refactor: move pendingTopicRepos real impl into submodule, remove cloud override

* 🐛 fix: consume pendingTopicRepos only after topic creation succeeds

* 🐛 fix: add missing getPendingTopicRepos import in gateway

* 🔒 fix: address security and dead-code issues from PR review

- sandboxRunner: sanitize repo dir name to prevent shell injection
- sandboxRunner: use git insteadOf (-c flag) so token is never stored in .git/config
- cloudHeteroContext: fix return type from string|undefined to string (dead branch)
- CloudRepoSwitcher: remove unreachable empty-list branch in popover content

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

* 💬 i18n: add claude setup-token hint to token description

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

* 🐛 fix: remove incorrect web hetero→gateway forced routing in agentDispatcher

On web, heterogeneousProvider is ignored — routing falls through to isGatewayMode.
Cloud CC only runs when gateway mode is enabled; gateway.ts handles sandbox
spawning when it detects a hetero provider.

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

* 🐛 fix: restore web hetero→gateway routing; update stale test

On web, a configured heterogeneousProvider always routes to gateway —
the cloud sandbox is the only execution environment regardless of
isGatewayMode. The test assumed the pre-cloud-CC world where web
ignored hetero providers entirely.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 20:39:14 +08:00
Innei a09991af8c 📝 docs(version-release): enforce git-derived PR refs and metrics (#14575)
* 📝 docs(version-release): enforce git-derived PR refs and metrics

Add the skill's first-class hard rules for computing release-note inputs
from git instead of memory: latest-tag base via `git describe`, PR refs
from commit subjects, metric counts from `wc -l`, handle resolution via
`gh pr view`, and a pre-publish `comm -23` diff that must be empty.
Also adds @cy948 to the team roster and notes Tsuki / René Wang's
commit-author aliases so contributor classification stops drifting.

* ♻️ refactor(version-release): split skill into router + per-flow references

SKILL.md was 426 lines covering three distinct flows. Split it so each
flow lives next to its own checklist:

- reference/minor-release.md — minor workflow (lifted from SKILL.md)
- reference/patch-release-scenarios.md — patch flows (existing)
- reference/release-notes-style.md — long-form changelog standard,
  template, and Computing Inputs hard rules (lifted from SKILL.md)

SKILL.md now reads as a router (~100 lines) with shared CI trigger
rules, post-release automation, precheck, and hard rules. Cross-links
between references replace the previous in-file jumps. Also fixes a
prettier-mangled redirect (`< some-pr-by-them >`) by using a `$PR`
variable instead of an angle-bracket placeholder.

* 📝 docs(version-release): add Hotfix and DB Migration variants to release-notes-style

The Canonical Structure was implicitly long-form (Minor / Weekly), and
hotfix authors had to read `changelog-example/hotfix.md` to learn it
existed. Make the divergence explicit:

- New § Variants for Shorter Releases describes Hotfix structure
  (Scope / What's Fixed / Upgrade / Owner) and DB Migration structure
  (Migration overview / Operator impact / Rollback) as overrides of the
  canonical long-form layout.
- Renamed the canonical section to "Canonical Structure (Long-Form:
  Minor / Weekly)" so the boundary is visible.
- Added Hotfix entry to Release Size Heuristics.
- Added a Hotfix subsection to Quick Checklist so the verification
  gates differ from long-form (no metric line / no Contributors / Owner
  resolved via gh).
2026-05-09 20:32:44 +08:00
YuTengjing 4c76d2430f 🐛 fix: remove signin captcha flow (#14573) 2026-05-09 19:49:04 +08:00
Innei 8ed31dfca4 🐛 fix(docker): replace pnpm init with static package.json in /deps (#14576)
`pnpm init` writes `devEngines.packageManager: { version: "^11.0.9" }`
into the generated package.json. corepack@latest rejects ranges in this
field with "Invalid package manager specification ... expected a semver
version", causing the subsequent `pnpm add pg drizzle-orm` to exit 1.

Skip init and write a minimal package.json directly so corepack has
nothing to validate.
2026-05-09 19:36:09 +08:00
YuTengjing c374892fea 🐛 fix: add temporary email auth error locale (#14564) 2026-05-09 18:50:32 +08:00
Rdmclin2 4617468e87 🐛 fix: add bot callback service (#14570)
fix: add bot callback service
2026-05-09 17:45:34 +07:00
LiJian 4c3a71a2c3 🐛 fix: sanitize sensitive comments and examples from production JS bundle (#14557)
* 🐛 fix: sanitize sensitive comments and examples from production JS bundle

- Replace app.example.com with RFC 2606 example.com in agent-browser skill content
- Replace password-stdin examples with interactive auth prompts
- Remove hardcoded password-like strings from code examples
- Reword flagged code comments in page-agent system role

Addresses TAC Security CASA Tier 2 DAST Info findings:
Information Disclosure - Suspicious Comments (CWE-615)

The flagged strings appeared in SPA production bundles:
- /_spa/assets/chat-*.js
- /_spa/assets/index-*.js

* 🐛 fix: revert --interactive to --password-stdin in auth vault examples

The --interactive flag does not exist in agent-browser CLI (only --password
and --password-stdin are supported). Using --interactive would cause auth
save to fail and block login workflows.

Reverted both auth vault examples to use echo | --password-stdin pattern,
which pipes the password via stdin — the recommended secure approach.
2026-05-09 18:19:31 +08:00
Arvin Xu 7892e553ea 💄 style(task): activity card stop run + register /tasks in SPA proxy (#14559)
*  feat(task): add stop run action to activity card menu

Surface the existing cancelTopic flow in the task detail activity card so
users can interrupt a running topic without opening the chat drawer.

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

*  feat(task): confirm before stopping a running topic

Wrap the new Stop run action in a confirmModal so an accidental click can't
silently abort an in-flight run.

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

* 🐛 fix(spa): register /tasks and /task in SPA proxy matcher

Without these matcher entries, the Next.js middleware never rewrote /tasks
and /task/:taskId to the SPA catch-all, so the activity feed entries 404'd
in production builds even though the routes were wired in the SPA router.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:13:24 +08:00
YuTengjing 793a8deb43 💄 style: update auth captcha retry copy (#14561) 2026-05-09 17:35:03 +08:00
Rdmclin2 e56ccf6a5c 🐛 fix: multiple account link (#14562)
* feat: avoid rebind link same account

* chore: update i18n locales

* feat: avoid discord account misslink

* feat: support slack account mis match

* fix: avoid claim conflict
2026-05-09 16:31:21 +07:00
Innei 9756daba2d 🐛 fix(onboarding): guard skip/mode-switch footer with feature flag, desktop & init checks (#14560)
- Only show the skip-and-switch footer when all conditions are met:
  AGENT_ONBOARDING_ENABLED, not desktop, server config initialized,
  and runtime enableAgentOnboarding flag is on
- Fix typo: swichMode → switchMode
- Expand tests with hoisted mocks covering each visibility condition
2026-05-09 17:09:12 +08:00
AmAzing- 2b165ec722 🎨 Refine Agent Signal receipt cards (#14558)
*  Refine agent document skill trees and delete confirms

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:50:31 +08:00
YuTengjing cef69e9b72 🐛 fix: flatten visual analysis tool schema (#14550) 2026-05-09 14:42:53 +08:00
LiJian d0b938a0cb 🐛 fix: recover hetero persistence state across Vercel replicas (#14539)
* 🐛 fix: recover hetero persistence state across Vercel replicas

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

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

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

* fix: add the test fixed

* fix: slove the some topic problem

---------

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

* 🐛 fix: allow recovery for orphan managed skill bundles

*  test: cover agent document group recovery paths

* 🐛 fix: render empty state for hidden skill indexes

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Refactor onboarding user profile handling: remove responseLanguage field

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

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

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

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

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

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

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

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

* refactor(onboarding): standardize AGENT_ONBOARDING_ENABLED naming in tests

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

Fixes LOBE-8290

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

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

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

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

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

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

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

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

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

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

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

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

* fix: slove some bugs

* fix: add the is dev back

* 🐛 fix: add async to handleAgentRunRequest in gatewayConnectionSrv

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

* 💄 style: tighten PickAgents card layout

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 00:08:24 +08:00
Rdmclin2 507909dc2c feat: add agent hono routes (#14535)
feat: add agent hono routes
2026-05-08 22:31:47 +07:00
YuTengjing 4721d14a81 🐛 fix: trim brief / task-template fetch overhead on home (#14516) 2026-05-08 23:06:22 +08:00
YuTengjing e1a5b27db0 feat(task): add comment tools and reparent support (#14515) 2026-05-08 22:42:10 +08:00
Innei 03621d0664 feat(explorer-tree): add generic ExplorerTree component built on @pierre/trees (#14094)
*  feat(explorer-tree): introduce generic ExplorerTree component

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

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

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

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

* 📝 docs: add ResourceManager ExplorerTree refactor design

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

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

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

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

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

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

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

*  feat(resource): add tree snapshot derivation

*  feat(resource): add tree mutation helpers

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

*  feat(resource): add tree controller

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

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

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

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

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

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

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

* 🐛 fix(spa): prebundle explorer tree dependency

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

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

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

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

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

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

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

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

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

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

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

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

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

*  feat(useDocumentTreeOps): integrate confirmModal for delete confirmation

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

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

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

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

---------

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

* fix: add telegram timeout error

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

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

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

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

---------

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

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

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

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

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

* fix: address Codex review feedback for PR #14514

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

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

---------

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

* chore: refactor lobeai to messager prefix

* feat: reigister messager platforms

* feat: support slack messager

* fix: verify im route redirect

* fix: link page style

* chore: optimize agent select and /agents commands

* feat:support lab switch

* feat: use same  agent select

* chore: add runtime error info

* chore: optimize error text

* feat: add slack messagger installation implementation

* chore: add more scope

* feat: add slack messager account link

* fix: open slack in a new link

* feat: optimze messager link page

* feat: optimize messager locales and bot options

* chore: optimize messager

* fix: slack integration detail

* fix: avoid taking over and fix slash commands

* chore: optimize slack app setup

* chore: update slack manifest and setup

* feat: support discrod platform

* feat: discord messger slash commands and agent picker

* chore: update discord messager

* feat: support db bot provider credentials

* chore: remove message router ensure  connected

* chore: remove notes field

* chore: add applicationId and credentails

* chore: squash db migations

* chore: remove installedAt and linkedAt field

* chore: remove messager releated env variables

* chore: remove old skill bot skill

* feat: add operationId when throwing error

* chore: abstract platform clients and registery

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

* feat: add integration detail

* feat: add platfom definition i18n files

* chore: abstract messenger router platform branches

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

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

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

* fix: telegram local button

* chore: remove messager docs

* feat: add discord installation process

* chore: remove discord bot username

* chore: adjust discord integration detail

* feat: extract platfom specific implementation

* chore: handle connection flow and redirect

* feat: add platform router for messager

* chore: move messager to agents group

* chore: update i18n files

* chore: update messager table sql

* chore: update messager sql

* fix: link with tenantId

* chore: move messger verify page to features/Messager

* chore: refactor messager verify page

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

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

* fix: Rebind by platform user when confirming messenger link

* chore: remove unnecessary journals

* chore: update i18n files

* fix: lint error and i18n

* fix: test cases

* chore: add lost test cases

* chore: try cpus 2

* chore: try remove optimize package import

* chore: fallback define config

* chore: try to reduce OOM

* chore: fallback

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-05-08 16:27:16 +07:00
AmAzing- 26da6b9ad4 Fix tool call timer reset on collapse and expand (#14513)
* 🐛 Preserve tool call timer across collapse and expand

* 🧪 Add coverage for execution timer reset cleanup

* 🐛 fix: clear execution timer cache after unmount
2026-05-08 15:01:53 +08:00
AmAzing- 1d4fb21885 🐛 fix: Review panel gating to use effective working directory (#14512)
* 🐛 Align working sidebar review with agent cwd

* 🐛 Align review cwd selector with GitStatus
2026-05-08 13:16:26 +08:00
YuTengjing 38c92fa04a 🐛 fix: sanitize provider tool names (#14510) 2026-05-08 11:47:07 +08:00
YuTengjing 555a375e67 🐛 fix: always recommend task templates regardless of brief count (#14508) 2026-05-08 11:17:26 +08:00
YuTengjing 6989e8f9e6 🐛 fix: sanitize Claude thinking history signatures (#14499) 2026-05-08 09:56:00 +08:00
Rdmclin2 e4d1d1fc17 👷 build(database): add messenger tables for IM bot integration (#14496)
* 👷 build(database): add messenger tables for IM bot integration

Adds three new tables to support the Messenger feature (Slack / Telegram
/ Discord / Feishu / MS Teams shared-bot integration):

- messenger_account_links: maps a LobeHub user to an IM account per
  (platform, tenant); tracks the active agent for `/switch` flows.
- messenger_installations: per-tenant OAuth install records (Slack
  workspaces, Feishu tenants, …); stores AES-GCM encrypted bot
  credentials and the installer.
- system_bot_providers: deployment-wide App-level bot credentials
  (one Discord App / Telegram bot / Slack App per deployment),
  replacing the env-var-based config.

All sensitive credentials are encrypted via KeyVaultsGateKeeper, the
same gatekeeper used by `agent_bot_providers`. SQL is idempotent
(`IF NOT EXISTS` / `DROP CONSTRAINT IF EXISTS`) per repo convention.

Includes models with full test coverage. Schema and migration only —
no router / service wiring in this PR.

* 🐛 fix(database): bridge stale messenger_account_links missing tenant_id

Some envs deployed a pre-squash version of the messenger migrations
where `messenger_account_links` was created without `tenant_id` and
used the legacy 2-column unique indexes. CREATE TABLE IF NOT EXISTS is
a no-op on those tables, so the new 3-column unique index then fails
with `column "tenant_id" does not exist` (PG 42703).

Add the same bridge logic the original 0102 migration carried — ALTER
ADD COLUMN IF NOT EXISTS for tenant_id and DROP INDEX IF EXISTS for the
two legacy indexes. Idempotent on fresh DBs.

* Revert "🐛 fix(database): bridge stale messenger_account_links missing tenant_id"

This reverts commit d5232564e4.
2026-05-08 01:10:34 +07:00
Arvin Xu 026c79a4c2 💄 style: simplify onboarding agent identity intervention card (#14505)
* 💄 style: simplify onboarding agent identity intervention card

- Drop redundant "Onboarding approval" eyebrow, "Agent name"/"Agent avatar" field grid, and "Applies to" target chips — the description above already conveys scope, and the avatar+name preview already shows the new identity
- Rephrase title to first-person agent voice ("I'll update my name and avatar") so the card reads as the agent announcing what it will do, not a generic admin form
- Remove the now-dead applyHint line under the avatar
- Prune unused i18n keys (eyebrow / applyHint / name / emoji / targets / targetInbox / targetOnboarding) across default + en-US + zh-CN
- Update webOnboarding intervention test to match the simplified card

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

* 🐛 fix: use field-aware title for partial saveUserQuestion approvals

The manifest routes name-only and emoji-only saveUserQuestion calls through the same intervention as the both-fields case, but the previous title hardcoded "I'll update my name and avatar". An emoji-only approval would over-promise a rename that never happens.

Pick titleNameOnly / titleAvatarOnly / title based on which fields are actually pending; cover all three branches in webOnboarding.test.tsx.

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

* 💄 style: drop redundant scope description from onboarding intervention

The field-aware title already says exactly what's about to change ("I'll
update my name" / "...avatar" / "...name and avatar"); the secondary line
explaining that the change applies to Inbox + the current onboarding chat
was extra reading without new information for someone mid-onboarding.

Remove the description Text + i18n key (default + en-US + zh-CN) and
collapse the now-single-child header Flexbox.

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-08 02:02:12 +08:00
Arvin Xu 1e2782ece4 🐛 fix(gateway): keep input loading on through execAgentTask round-trip (#14503)
* 🐛 fix(gateway): keep input loading on through execAgentTask round-trip

The Gateway branch in `sendMessageInternal` completed the parent
`sendMessage` op before awaiting `executeGatewayAgent`, so during the
`execAgentTask` network round-trip no operation was running. The send
button briefly flickered back to "send" until the child
`execServerAgentRuntime` op started.

Move `completeOperation` to after `executeGatewayAgent` resolves —
by then the child op is already running, so loading state never drops.

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

* ♻️ refactor(gateway): hand off parent op inside executeGatewayAgent

Make `executeGatewayAgent` accept an optional `parentOperationId` and
complete it the instant phase-1 init finishes — right after the child
`execServerAgentRuntime` op starts and the assistant message is
associated. Previously the caller had to call `completeOperation` after
`await executeGatewayAgent(...)` returned, which was fragile: any future
`await` added between the child startOperation and the function return
would silently extend the parent op's lifetime past phase-1.

Also wires `parentOperationId` through to `startOperation` so the
parent/child lineage is recorded on the new op.

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

* 🐛 fix(brief): persist agentId so brief cards render the producing agent's avatar

`BriefCard` only renders the agent avatar when the enriched `brief.agent`
is non-null, which in turn requires `briefs.agentId` to be set. Several
brief creation paths (task lifecycle synthesize/error/review, and the
agent-driven `lobe-brief` tool runtime) were inserting briefs without
`agentId`, leaving the avatar slot empty in the Daily Brief card.

Pass `assigneeAgentId` from the task in `TaskLifecycleService` and
`context.agentId` from the tool execution context in the brief runtime.
No backfill — internal testing only, historical rows stay null.

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

* 🐛 fix(gateway): honor stop clicks during phase-1 init

With the parent `sendMessage` op kept running through the
`execAgentTask` round-trip (so the input loading state stays on),
clicking Stop now reaches `cancelOperation(sendMessage)` mid-await but
`executeGatewayAgent` was unaware of the abort: the request finished,
the server task got created, the WS opened, and the agent ran despite
the cancel.

Fixes:

- Plumb the parent op's AbortSignal into `aiAgentService.execAgentTask`
  so the fetch itself aborts in-flight when cancel arrives during the
  round-trip.
- After every await in phase-1 init, re-check `signal.aborted` and bail
  out — the server task may already exist if cancel arrived after the
  request resolved, so fire `interruptTask` best-effort before throwing.
- In the caller catch path, skip `failOperation` when op status is
  already `cancelled` so we don't clobber the user-cancelled state with
  `failed`.

Adds a regression test that pre-aborts the controller, awaits
`executeGatewayAgent`, and asserts the signal is forwarded, the server
task is interrupted, and the child op / message association / WS
connect / parent completion are all skipped.

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

*  feat(review): add branch-compare diff mode with base ref picker

Introduces a Branch mode in the agent Review panel that diffs the current
HEAD against the remote default branch (resolved via `refs/remotes/origin/HEAD`,
overridable via a per-repo base picker). Pulls the comparison data through a
new `getGitBranchDiff` IPC that streams `git diff base...HEAD` and reuses the
existing per-file split + size-cap path, plus `listGitRemoteBranches` for the
picker. Renders a GitHub-style `base ▾ ← head` label with shrink/ellipsis
behaviour, swaps the loading spinner for `NeuralNetworkLoading`, and persists
the user's base override in localStorage keyed by working directory.

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

* 🐛 fix(agent-profile): hide right-panel toggle for heterogeneous agents

Heterogeneous runtimes (Claude Code, Codex, etc.) own their own toolchain
and don't surface the LobeHub right-panel content, so the toggle button is
a dead-end in their profile header.

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-08 02:01:26 +08:00
Innei b5ddac56dc 🐛 fix(assistant-group): pass per-segment content overrides to MessageContent (#14504)
When assistant content blocks are split into answer and workflow segments,
each segment now receives explicit `contentOverride` and `hasToolsOverride`
props so that the rendered markdown matches the segment's own content
instead of all segments reading the same store subscription.
2026-05-08 01:51:11 +08:00
Innei ad0da3753e feat(kb-tool): integrate BM25 search and docs_* read for inline documents (#14494)
*  feat(kb-tool): integrate BM25 search and docs_* read for inline documents

- searchKnowledgeBase now returns inline documents (BM25 over documents.content)
  alongside file chunks (vector). Inline custom/document records created via
  createDocument or `lh kb create-doc` are now discoverable through the agent tool.
- readKnowledge accepts both file_* and docs_* IDs. docs_* reads documents.content
  directly (no S3 lookup, no parse).
- chunkRouter.semanticSearchForChat: dual-path with Promise.allSettled — failures
  on either path no longer kill the whole call; surfaced via new `errors` field.
- formatSearchResults renders <files> and <documents> sections separately.

Fixes LOBE-8606
Fixes LOBE-8608

* style(TitleSection): add border radius to title input field

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

* 🐛 fix(kb-tool): preserve search-path errors in zero-result responses

When semanticSearchForChat returns no hits but includes errors (e.g. vector
search fails and BM25 finds nothing), use formatSearchResults which renders
error notes, instead of promptNoSearchResults which drops them silently.

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-05-08 01:32:42 +08:00
Arvin Xu e6905fe0fd feat(agent-sidebar): move tasks from welcome card to sidebar list (#14500)
Replace the inline `AgentTaskList` card on agent and inbox welcome
screens with a dedicated `Tasks` section in the agent sidebar that
groups items by status (Pending review / Backlog / In progress).
Sidebar fetch is scoped to active statuses only — `done` and
`canceled` are neither pulled nor rendered, and use a separate SWR
key from the kanban page so the two views don't trample each other's
state.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 00:32:01 +08:00
Hardy a9d2110565 🐛 fix: onFinish never called when browser tab is backgrounded during SSE streaming (#14461)
🐛 fix: remove rAF animation blocking onFinish when tab is backgrounded

Replace await-on-animation with synchronous flushQueue() to prevent
background tab throttling from stalling chat completions, MCP tool
calls, and agent loop continuation.
2026-05-08 00:10:31 +08:00
Arvin Xu e4d5f69b27 ♻️ refactor(agent): migrate remaining /api/agent routes to Hono (#14478)
* ♻️ refactor(agent): migrate remaining /api/agent routes to Hono

Move the static `route.ts` handlers under `src/app/(backend)/api/agent/`
into the existing Hono app at `src/server/agent-hono/`, leaving only the
SSE `stream` endpoint as a Next.js route. Behavior, URLs, and auth
semantics are unchanged.

- New middlewares: `qstashAuth` (QStash sig only) and `bearerSecretAuth`
  (factory for arbitrary `Bearer <secret>` checks)
- Migrated handlers: `run`, `webhooks/bot-callback`, `gateway`,
  `gateway/start`, `gateway/callback`, `webhooks/[platform]/[[...appId]]`
- `gateway/callback` keeps inline auth so the disabled-feature 204 still
  short-circuits before any auth check
- `gatewayCron` keeps `next/server`'s `after()` for the 10-min poll loop

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

* 🧪 test(agent-hono): cover migrated route handlers and new middlewares

Add unit tests for the handlers and middlewares introduced by the
/api/agent → Hono migration. Each test uses the same hand-built Hono
Context stub pattern as `toolResult.test.ts` (vitest can't resolve the
hoisted `hono` package, so a real Hono Context isn't available in
tests).

Coverage:
- middlewares/qstashAuth (sig pass/fail → next called/not, body forwarded
  to verifier)
- middlewares/bearerSecretAuth (503/401/200 paths, lazy secret eval)
- handlers/runStep (validation, lock 429 + Retry-After, success shape,
  upstash-retried header forwarding)
- handlers/botCallback (validation + service delegation + 500 on throw)
- handlers/gatewayCallback (disabled-feature 204, auth, zod validation,
  state.status → BotRuntimeStatus mapping)
- handlers/gatewayStart (start/restart paths, stop-before-ensure
  ordering, 500 on failure)
- handlers/platformWebhook (param validation, raw request passthrough)

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-07 23:37:23 +08:00
LiJian a372acd50d feat: add lobeAgents markdown tag for inline agent card rendering (#14495)
*  feat: add lobeAgents markdown tag for inline agent card rendering

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

* 📝 docs(agent-management): instruct LLM to render lobeAgents card after agent operations

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

* 🐛 fix(lobe-agents): support single-quoted attrs and preserve trailing paragraph siblings

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 23:37:20 +08:00
YuTengjing 0af5e51477 🐛 fix: sanitize assistant media in Responses input (#14497) 2026-05-07 23:26:22 +08:00
LiJian 40f0557158 feat(agent-management): render clickable agent card after createAgent (#14493)
 feat(agent-management): render clickable agent card after createAgent tool execution

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 22:59:07 +08:00
YuTengjing 62f06540ba 🐛 fix: show notification settings in desktop (#14491) 2026-05-07 19:58:53 +08:00
YuTengjing 43b064f803 feat: add RecommendTaskTemplates UI and default noop router (#14488) 2026-05-07 19:14:08 +08:00
YuTengjing 8e8a463a05 🐛 fix: use runtime config to hide LobeHub provider toggle (#14487) 2026-05-07 19:07:05 +08:00
Neko decc25554e 🐛 fix(memory-user-memory): should have nullable when parsing activities (#14489) 2026-05-07 19:04:12 +08:00
CanisMinor 1c8ec2681c 💄 style: update brief template style (#14483)
Co-authored-by: YuTengjing <ytj2713151713@gmail.com>
2026-05-07 17:34:47 +08:00
Innei 0a32fbc737 🐛 fix(desktop-overlay): hide model picker and stabilize panel enter (#14484) 2026-05-07 16:39:32 +08:00
sxjeru 7fc41a9677 🐛 fix: add provider settings normalization & add Gemma 4 models (#13313)
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: YuTengjing <ytj2713151713@gmail.com>
2026-05-07 12:46:19 +08:00
AmAzing- 22c880763d ️ revert: remove e2e topic group expansion workaround (#14480) 2026-05-07 12:16:24 +08:00
Arvin Xu d324736edf 💄 style: polish onboarding agent welcome and add web-onboarding tool UI (#14475)
* 💄 style: polish onboarding agent welcome and name suggestions

- Float NameSuggestions above ChatInput (out of greeting message), match width via WideScreenContainer
- Compact suggestion cards: emoji and name on one row, smaller padding, ellipsis prompt
- Migrate suggestion data from i18n to a typed config (`nameSuggestions.config.ts`) with EN/ZH content
- Expand pool to 50 differentiated names; ZH uses native Chinese names, EN uses English; sample 3 random items per group, refresh excludes current ids
- Click a card to fill ChatInput instead of sending immediately
- Tighten welcome footer copy in EN/ZH

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

* 💄 style: refine onboarding name suggestions and click-to-fill flow

- Click a suggestion fills ChatInput via editor.setDocument + focus instead of sending immediately
- Append localized avatar hint ("Use {emoji} as the avatar." / "头像用 {emoji}。") to the filled message
- Expand suggestion pool to 100 with bilingual EN/ZH content; mix 2/3/4-char Chinese names; rebalance emoji↔name pairings; tone the 4-char ZH names toward modern/youthful phrasing
- Update NameSuggestions.test.tsx to mock editor.setDocument/focus and i18n interpolation

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

*  feat(builtin-tools): add web-onboarding tool inspectors and write document render

- Add Inspector components for FinishOnboarding / ReadDocument / SaveUserQuestion / UpdateDocument / WriteDocument under @lobechat/builtin-tool-web-onboarding/client
- Add Render component for WriteDocument
- Wire WebOnboardingInspectors and WebOnboardingRenders into the central builtin-tools registries (inspectors.ts / renders.ts)
- Add tool display names (saveUserQuestion → "Recorded info", writeDocument → "Wrote a document") to AssistantGroup constants and chat locale
- Add plugin locale keys for docType (User Persona, SOUL.md) and pluralized inspector counters (chars / changes / interests); shorten saveUserQuestion API name to "Save"

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

* 🐛 fix: guard resolveNameSuggestion against undefined locale

When useTranslation is mocked without an i18n.language (e.g. Conversation.test.tsx), locale came in undefined and resolveNameSuggestion crashed on `.toLowerCase()`. Treat missing/unknown locales as a fallback to en.

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-07 10:32:32 +08:00
Arvin Xu 608498a950 feat(agent): inactivity watchdog finalize endpoint + agent-hono migration (#14476) 2026-05-07 09:54:47 +08:00
Innei 5e1a35f259 🐛 fix(conversation): reduce streaming re-renders with reference stabilization and self-subscribing components (#14470)
* 🐛 fix(conversation): reduce streaming re-renders with reference stabilization and self-subscribing components

- Add stabilizeReferences utility to pin unchanged subtrees to previous identity after parse()
- Make Tool, Tools, and MessageContent self-subscribe via store selectors instead of receiving data as props
- Stabilize handleExpandedChange and expandedKeys in WorkflowCollapse with useCallback/useMemo
- Add selectors: findBlockById, getToolsInBlock, getToolInBlock, getBlockContent, getBlockHasTools

* 🔧 chore(agent-mock): update todo-write-stress test case

* feat: refactor todo-write-stress case to utilize lobe-gtd API for task management and enhance workflow with structured plans and todos

- Updated tool steps to replace previous bash commands and file operations with lobe-gtd API calls for creating and updating todos and plans.
- Introduced structured plans for various phases of the migration process, enhancing clarity and organization.
- Implemented a breathing step to simulate processing between tool-call batches.
- Enhanced the overall flow of the todo-write-stress case to reflect a more realistic and organized task management approach.

refactor: optimize ContentBlocksScroll component with virtualized list for improved performance

- Added CSS styles to enable content visibility auto for off-screen workflow items, preserving React state while optimizing rendering.
- Updated Flexbox component to conditionally apply virtualized list styles based on the variant prop, enhancing layout performance.

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

* 🐛 fix(conversation): remove virtualized list styles to improve rendering performance

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

* 🐛 fix(conversation): address codex streaming review feedback

* ♻️ refactor(conversation): use query structural sharing helper

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-05-07 02:29:31 +08:00
Innei 6b010c8380 🐛 fix(editor-runtime): add mutation handlers for post-save synchronization (#14469)
* 🐛 fix(editor-runtime): add afterMutateHandler for post-mutation synchronization

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

* 🐛 fix(editor-runtime): enhance beforeMutateHandler with context and add meaningful content check

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

* 🐛 fix(editor-runtime): improve data source validation and streamline command dispatch logic

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

* 🐛 fix(editor-runtime): add test for Page Agent editTitle behavior without sending content or editorData

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

* 🐛 fix(editor-runtime): update LiteXML node extraction to include attributes and improve error logging

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

* 🐛 fix: use namespace import for GeneralChatAgent to fix vi.spyOn in tests

vi.spyOn on a module namespace object requires the production code to
access the class through the same namespace. Destructured imports capture
a direct binding that bypasses the spy, causing "Class constructor
GeneralChatAgent cannot be invoked without 'new'" in tests.

* 🐛 fix: replace vi.spyOn on class constructor with vi.mock for GeneralChatAgent

vi.spyOn wraps a class with a plain function that loses [[Construct]]
semantics in ESM, causing "Class constructor GeneralChatAgent cannot be
invoked without 'new'". Replace with vi.mock + hoisted mock constructor
that properly tracks calls while preserving new-ability.

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-05-07 02:21:35 +08:00
YuTengjing ead5631bab 🐛 fix: preserve nested router runtime id (#14474) 2026-05-07 01:45:12 +08:00
YuTengjing ddd5c20836 💄 style: add grok-4.3 to LobeHub-hosted xAI models (#14446) 2026-05-07 00:49:54 +08:00
YuTengjing c51835193f 🐛 fix: stabilize xAI Responses API tools (#14462) 2026-05-07 00:11:44 +08:00
Arvin Xu 0c375e4428 💄 style: update heterogeneous agent ChatInput placeholder text (#14454)
* 💄 style: update heterogeneous agent ChatInput placeholder text

Change 'Ask {{name}} to do a task...' to 'Describe a task or ask a question to {{name}}' for a more natural prompt consistent with Claude Code style.

* fix: also update TypeScript locale source for sendPlaceholderHeterogeneous

* fix: unify casing for popup window labels and simplify folder chooser text
2026-05-06 23:38:53 +08:00
YuTengjing 58cda8a645 🐛 fix: persist home sidebar collapse state (#14473) 2026-05-06 23:32:14 +08:00
AmAzing- 65ba4ad435 🐛 fix(e2e): expand visible topic groups in E2E steps (#14472)
🧪 Expand visible topic groups in E2E steps
2026-05-06 22:27:03 +08:00
AmAzing- 41ffd1e0d3 🧪 Fix streaming executor agent spy tests (#14471) 2026-05-06 21:32:51 +08:00
LiJian 02767bac55 🐛 fix: resolve template variables in server-side (execAgent) context engine (#14468)
In execAgent/bot mode, `serverMessagesEngine` is called from
`RuntimeExecutors.ts` without several `{{VARIABLE}}` placeholders that
the client-side `contextEngineering.ts` correctly resolves via stores
and lambdaClient. This caused literal `{{CREDS_LIST}}`, `{{username}}`,
`{{language}}`, `{{memory_effort}}`, `{{sandbox_enabled}}`, and
`{{CRON_JOBS_LIST}}` strings to leak into LLM prompts.

Fix: resolve each missing variable before building `contextEngineInput`:
- `{{username}}` / `{{language}}`: `UserModel.getInfoForAIGeneration()`
- `{{sandbox_enabled}}`: check `lobe-cloud-sandbox` in enabled tools
- `{{memory_effort}}`: read from `agentConfig.chatConfig.memory.effort`
- `{{CREDS_LIST}}`: `MarketService.market.creds.list()` (lobe-creds gate)
- `{{CRON_JOBS_LIST}}`: `AgentCronJobModel.findWithPagination()` (lobe-cron gate)

All fetches are best-effort (try/catch → empty string fallback) so a
transient error never breaks agent execution.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 21:09:22 +08:00
Innei be5d61d40a feat(desktop): add app tray visibility setting (#14463)
*  feat(desktop): add app tray visibility setting

* ♻️ refactor(desktop): move tray setting to appearance
2026-05-06 18:13:23 +08:00
Rylan Cai 282b20c454 🐛 fix context compression threshold config (#14439) 2026-05-06 17:08:10 +08:00
AmAzing- cc506c036d 🐛 fix: task breadcrumb title truncation (#14460)
 Fix task breadcrumb title truncation
2026-05-06 16:46:23 +08:00
LiJian 5fca91a488 🐛 fix: inject user response language into task summary chains (#14459)
Pass the user's preferred response language (from settings) to
chainTaskTopicHandoff and chainGenerateBrief so that task run titles
and briefs always output in the user's configured language instead of
following the agent's content language.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 16:41:20 +08:00
Neko c3530ad221 🐛 fix(app,agent-signal): for skills, target to skill document, and auto refresh (#14457) 2026-05-06 16:19:36 +08:00
Zhijie He 8b8b0f0579 💄 style: add step-image-edit-2 support (#14329)
Co-authored-by: YuTengjing <ytj2713151713@gmail.com>
2026-05-06 15:51:54 +08:00
YuTengjing 958bf52978 🐛 fix: preserve visual refs for bot uploads (#14456) 2026-05-06 15:38:39 +08:00
René Wang 480d4b2b4e 📝 docs: add May 4 weekly changelog (#14434) 2026-05-06 14:48:01 +08:00
YuTengjing 4d00c22e7f 🐛 fix: handle unsupported xAI parameters (#14445) 2026-05-06 14:45:09 +08:00
Innei f30d9da5a9 feat(agent-mock): add agent mock devtools with playback & fixture viewer (#14436)
* 📦 feat(agent-mock): scaffold package skeleton

* 🔧 chore(agent-mock): align deps + add vitest config

*  feat(agent-mock): add core types

*  feat(agent-mock): add chunkSplitter with code-point safety

*  feat(agent-mock): map ExecutionSnapshot → MockEvent[]

*  feat(agent-mock): add defineCase / llmStep / toolStep / errorStep DSL

*  feat(agent-mock): add snapshotToMockCase helper

*  feat(agent-mock): add todo-write-stress builtin case + registry

*  feat(agent-mock): add generator registry + tool-stress generator

*  feat(agent-mock): add 4 more builtin cases (long-reasoning, mixed, error, subagent)

*  feat(agent-mock): add subagent-tree + long-reasoning generators

*  feat(agent-mock): add MockPlayer state machine + step navigation

*  feat(agent-mock): add __agentMockSilent flag + signal bridge guard

*  feat(agent-mock): add executeMockStream with side-effect gating

*  feat(agent-mock): add dev-only devClearMockTopics TRPC procedure

*  feat(agent-mock): add dev API to list/read .agent-tracing snapshots

*  feat(agent-mock): add agentMockStore zustand

*  feat(agent-mock): add useMockCases hook

*  feat(agent-mock): add useAgentMockPlayer hook

*  feat(agent-mock): add useMockTopicCleanup hook

*  feat(agent-mock): add Fab entry component

*  feat(agent-mock): add Modal shell with tab bar

*  feat(agent-mock): add CaseList sidebar with search + groups

*  feat(agent-mock): add MiniBar floating playback controls

*  feat(agent-mock): add StatusGrid component

*  feat(agent-mock): add Controls (play/pause/step/speed)

*  feat(agent-mock): add ProgressBar

*  feat(agent-mock): add TargetPicker

*  feat(agent-mock): compose PlayerPanel

*  feat(agent-mock): add TimelinePanel + virtualized EventRow

*  feat(agent-mock): add read-only FixtureViewer with copy button

*  feat(agent-mock): add SettingsPanel with toggles + clear topics

* ♻️ refactor(agent-mock): address quality review (stable itemContent, type-safe error handling, clipboard catch)

*  feat(agent-mock): wire entry component (FAB + Modal + MiniBar)

*  feat(agent-mock): mount AgentMockDevtools in SPAGlobalProvider

* ♻️ refactor(agent-mock): switch Modal to imperative createModal API

* 🐛 fix(agent-mock): use close() + onOpenChangeComplete to preserve motion exit animation

* work

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

* minify

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

* 💄 refactor(agent-mock): rebuild devtools UI/UX with mono palette and IA reorg

Replace the in-modal sidebar + tab strip + MiniBar with a Fab-anchored
draggable Popover (case picker, transport, replay/loop, scrubbable progress,
stop, Open DevTools) and a token-driven Modal layout (two-row header,
Segmented view tabs, StatsStrip, sticky TransportBar). Wire EventRow and the
progress bars to seekToEventIndex (resolves the prior TODO), swap alert() for
toast.warning, persist loop and popover position to localStorage.

* work

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

* 🧹 chore(agent-mock): remove replay debug logs

* 👷 build: add @google/genai to pnpm allowBuilds

Fixes ERR_PNPM_IGNORED_BUILDS in CI — pnpm v11 blocks install
when a dependency with install scripts is not in the allowBuilds list.

* 🐛 fix: resolve TS type errors in useAgentMockPlayer

- parentMessageId: coerce `undefined` to `null` to match `string | null`
- threadId: coerce `null` to `undefined` for cancelOperations param

* ♻️ refactor: revert ConversationArea & sync-import AgentMockDevtools

- ConversationArea: restore messageMapKey(context), avoid needless field spread
- SPAGlobalProvider: switch AgentMockDevtools to sync import (dev-only, no need to lazy)

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-05-06 14:32:59 +08:00
LiJian 831b4ee5ca 🐛 fix: execAgent runtime should have agent management tools (#14371)
* 🐛 fix: add server runtime for lobe-agent-management tool

- Add `agentManagement.ts` server runtime in `serverRuntimes/`
- Implement all 9 API methods: `createAgent`, `updateAgent`, `deleteAgent`,
  `getAgentDetail`, `duplicateAgent`, `updatePrompt`, `installPlugin`,
  `searchAgent`, `callAgent`
- Uses `AgentModel` from `@lobechat/database` for agent CRUD
- Uses `DiscoverService` for marketplace search in `searchAgent`
- `callAgent` with `runAsTask: true` returns `execTask` state for task system
- Register `lobe-agent-management` in `serverRuntimes/index.ts`

Fixes LOBE-8434

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

* 🐛 fix: address review feedback for agent-management server runtime

- callAgent: always use task path on server (no `registerAfterCompletion` available for synchronous execution)
- installPlugin: create `user_installed_plugins` DB record via PluginModel so manifest is discoverable

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 13:56:19 +08:00
Neko c744eab116 feat(agent-signal,database,app,server): agent signal activities during nightly self-reflection will now push to briefs (#14437) 2026-05-06 13:33:54 +08:00
Rdmclin2 7697399da8 feat: optimize line bot (#14448)
* chore: optimize line config schema

* chore: optimize form render order

* chore: update i18n files
2026-05-06 11:50:31 +07:00
LobeHub Bot 05a9eae504 🌐 chore: translate non-English comments to English in edge-config (#14453) 2026-05-06 11:56:09 +08:00
Arvin Xu cc1e0d29d3 💄 style(brief-card): mute icon for resolved briefs on home page (#14452)
* 💄 style(brief-card): mute brief icon when brief is resolved

Resolved briefs now render the leading icon with muted gray colors instead
of the type's accent color, matching the existing "已标记为已解决" pill so the
card visually reads as inactive at a glance.

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

*  feat(page-agent): add custom Render for modifyNodes tool

Wires page-agent renders into the central registry and adds a per-operation
list view for modifyNodes (action icon, position chip, litexml preview, and
per-op success/error from pluginState.results), replacing the JSON fallback.

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

*  feat(brief): set trigger='task' on briefs created from task lifecycle

Populate the existing `trigger` column on briefs emitted by the task
lifecycle (error, synthesized topic, auto-review pass/retry/force-pass)
and the heartbeat watchdog (workflow + tRPC), so consumers can filter
briefs by source module.

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

* 💄 style(brief-card): show only the producing agent avatar

Stop fetching every agent in the task tree for brief cards. The stacked
Avatar.Group looked noisy for tasks with multiple subagents and didn't
convey ownership; render a single avatar for the agent that produced
the brief instead (`brief.agentId`).

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-06 11:20:11 +08:00
Arvin Xu 0e6eba61a9 feat(hetero-agent): server-side aiAgent.heteroIngest / heteroFinish + persistence handler (#14444)
*  feat(hetero-agent): add aiAgent.heteroIngest / heteroFinish procedures (LOBE-8535 phase 2a)

Wires `lh hetero exec` producer streams into the existing StreamEventManager
fanout: events flow CLI → tRPC → Redis Stream → gateway WS → renderer with
the same wire shape as gateway-driven runs.

- Reconcile server StreamEvent.type with @lobechat/agent-gateway-client's
  AgentStreamEventType so tool_execute / tool_result land natively
- HeterogeneousAgentService skeleton with sequential publish (preserves
  stepIndex ordering) + terminal agent_runtime_end fallback on finish
- Inline Zod schemas on aiAgentProcedure; topicId required (operationId
  reverse-lookup unreliable per LOBE-8516 design decision)

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

*  feat(hetero-agent): add HeterogeneousPersistenceHandler — server-side DB writes (LOBE-8535 phase 2b)

Mirrors src/store/chat/slices/aiChat/actions/heterogeneousAgentExecutor.ts
(1.8k lines) for the DB concerns. Renderer keeps its own copy for
desktop-host concerns (IPC, store dispatch, notifications); cloud / CLI
ingest goes through this handler instead.

- 3-phase tool persist: pre-register tools[] → create role:'tool' message
  → backfill result_msg_id (mirrors persistToolBatch lines 319–411)
- Subagent threads: lazy-create on first tagged chunk + per-turn assistant
  chaining + finalize on parent tool_result with terminal assistant
- Step boundaries: stream_start { newStep: true } flushes prior content
  and chains a new assistant off the last tool message
- Per-turn metadata persistence (step_complete phase=turn_metadata)
- Module-level state map keyed on operationId; idempotency via
  (stepIndex, type, timestamp). Multi-replica caveat documented — phase 3
  sandbox owns the endpoint per-instance so sticky routing is implicit.

Tests:
- 13 unit tests with fake-models harness covering bootstrap, idempotency,
  3-phase persist, step boundaries, subagent lifecycle, terminal events
- 2 fixture-driven tests replaying .heerogeneous-tracing/cc-streaming.json
  (502 events, 71 tool uses) end-to-end with idempotency assertions

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

* 🐛 fix(hetero-agent): restore runtime imports after lint auto-fix

ThreadStatus / ThreadType / AgentRuntimeErrorType are used as values, not
just types — the post-commit linter incorrectly converted the import to
`import type`, which broke the build.

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

*  feat(hetero-agent): full renderer event-branch parity + session resume (LOBE-8535 phase 2b' + 2c)

Renderer-parity additions to HeterogeneousPersistenceHandler:
- Echo-suppression: when CC streams an AuthRequired error string into
  `content` BEFORE emitting the structured error, the assistant ends up
  with both. Mirror the renderer's `shouldSuppressTerminalErrorEcho` logic
  (lines 113–130 of heterogeneousAgentExecutor.ts) so we keep only the
  structured error in those cases. Trigger conditions: `AuthRequired` code
  or explicit `clearEchoedContent` flag.
- 34 new branch-coverage tests against every event variant the renderer
  dispatches on (step_complete phases, stream_start with/without newStep,
  stream_chunk text/reasoning/tools_calling × main/subagent, all no-op
  variants, terminal error echo handling, subagent edge cases).

Phase 2c — session id persistence + resume helper:
- ChatTopicMetadata.heteroSessionId docstring updated: it's now the shared
  field for desktop and cloud paths (was tagged "desktop only").
- handler.finish() now accepts `sessionId` and writes it via
  TopicModel.updateMetadata (merges, preserves runningOperation peer).
- HeterogeneousAgentService passes sessionId through, exposes
  `getHeterogeneousResumeSessionId(topicId)` helper for phase 3 cloud
  sandbox routing to inject `--resume <id>` on the next CLI spawn.
- 9 tests covering happy path, missing session id, error result still
  persists, peer-field preservation, updateMetadata failure isolation,
  and the resume helper's lookup paths.

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

* 🐛 fix(hetero-agent): collision-safe idempotency key + mark-processed-after-success + portable fixture (PR #14444 review)

Three issues from PR review:

1. **Idempotency key collision** — the old `(stepIndex, type, timestamp)`
   triple collided when CC bursts multiple `stream_chunk` events through
   the same step within a single `Date.now()` millisecond. Later chunks
   got dropped as duplicates → silent assistant truncation. Now keys
   include a stable FNV-1a fingerprint of `event.data`, so distinct
   payloads stay distinct even at the same timestamp.

2. **Mark-processed-before-handle** — `processedKeys.add(key)` ran BEFORE
   `handleEvent`, and ingest swallowed throws. A transient DB error in
   any per-event write was silently lost: the event was marked done,
   the BatchIngester acked OK, retries skipped it, content was gone.
   Now: mark only after successful handling + propagate throws all the
   way to the BatchIngester so the batch retries. Idempotency map
   dedupes the events that already succeeded earlier in the batch.

   Knock-on: removed every `.catch(log)` from per-write paths. Renderer's
   "log + continue" posture doesn't fit the server (authoritative for
   cloud runs, silent partial writes diverge DB from WS view).

3. **Portable fixture** — `.heerogeneous-tracing/cc-streaming.json` is
   gitignored and missing in CI, so the fixture-driven test couldn't run.
   Replaced file IO with a synthetic stream that captures the same
   characteristics (multi-step, bursty same-millisecond text chunks,
   tool_use → tool_result pairs, step boundaries, terminal event). The
   synthetic fixture is also more meaningful — it has explicit assertions
   about chain-shape and bursty-text dedupe correctness.

Tooling adjustments to support the new contract:

- `persistToolBatch` restructured: payloads de-dup by id (so retries
  don't duplicate); `persistedIds` populated only AFTER successful
  per-tool create; phase 1 + phase 3 always run (idempotent re-writes)
  so a partial-failure retry can complete missed phase 3 backfills.
- `ensureSubagentRun`: thread/user/first-assistant create errors throw
  out instead of returning `undefined` and dropping the run.
  `ThreadModel.create` already uses `onConflictDoNothing` on id, so
  retrying the same generated id is safe.

Tests added (69 hetero-agent tests, was 66):
- Bursty same-timestamp distinct-content text chunks → all preserved
- Mark-processed-after-success retry contract (transient flake recovery)
- Synthetic fixture replays a multi-step CC-shaped run with chain-shape
  + idempotency + partial-batch retry assertions

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-06 10:30:19 +08:00
Arvin Xu 3e8016b502 🔨 chore(cli): update cli version to 0.0.11 (#14451)
🔨 chore: update cli version to 0.0.11

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 10:26:28 +08:00
Arvin Xu 970733aaeb ♻️ refactor(time): extract useActivityTime hook and move time keys to common (#14443)
♻️ refactor(time): extract useActivityTime hook and move time keys to common namespace

- Add `useActivityTime` hook wrapping `formatActivityTime` with i18n built in
- Move `time.formatThisYear/formatOtherYear/today/yesterday` from `discover` to `common` namespace
- Refactor chat header (hetero-agent), Task Activities, memory/home time, and Comment/Topic cards to use the hook so they show relative time (`5 minutes ago`) within 24h and absolute date afterwards
- Switch `PublishedTime` and `AgentTaskItem` to consume time keys from `common`

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 10:26:10 +08:00
Arvin Xu c72b1ee698 🐛 fix(changelog): replace gray-matter with browser-compatible frontmatter parser (#14435) 2026-05-06 10:13:46 +08:00
Arvin Xu 7bf923d762 🐛 fix(agent-runtime): finalize trace snapshot on error path (#14440)
* 🐛 fix(agent-runtime): finalize trace snapshot on error path

Propagated errors from RuntimeExecutors (e.g. `markPersistFatal` from a
parent_id FK violation) used to skip snapshot finalization entirely:
the success-path `finalizeSnapshot` block lived inside the try, so the
catch threw without writing the canonical
`agent-traces/<agentId>/<topicId>/<op>.json`. The partial sat orphaned
at `_partial/<op>.json`, the final S3 path returned 404, and the failed
op was invisible in the trace bucket while still showing as `status:
'error'` in Redis. (LOBE-8533)

Extract the finalize block into `finalizeSnapshotForOperation` and call
it from both the success branch and the error catch. The error call
synthesizes a failed step (the real one never reached
`appendStepToPartial` — it threw before the partial push), so step
counts stay aligned with the assistant message that triggered the call.

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

*  test: align expected strings with English-only labels and fix mobile router import sort

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

* 🐛 fix(agent-runtime): dedupe failed-step append and trust finalized step count

- finalizeSnapshotForOperation now merges the error event into an existing
  step record when the synthetic failedStep collides with one already
  written by the success-path append (e.g. saveAgentState or queue
  scheduling threw post-append). Prevents duplicate stepIndex entries
  that corrupt ordering and per-step metrics in trace reconstruction.
- totalSteps is derived from the finalized step array instead of
  state.stepCount, so the synthesized failed step is reflected in the
  snapshot total (Redis-loaded stepCount lags by one on the error path).

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:26:27 +08:00
Arvin Xu 10300ba0e1 feat(hetero-agent): support multimodal input across CLI / shared spawn / desktop (#14433)
*  feat(hetero-agent): support multimodal input across CLI / shared spawn / desktop

`spawnAgent` and `lh hetero exec` could only take a flat string prompt, so
attaching images required bypassing the shared layer (which is what desktop
actually did). This adds a unified `AgentPromptInput` shape — string sugar or
an array of text/image content blocks — and lifts image handling into the
shared `@lobechat/heterogeneous-agents/spawn/input` module.

Image sources accept URL (with optional id for cache dedupe), local path, or
inline base64. The shared `normalizeImage` fetches/reads/decodes, with
optional on-disk caching keyed by `sha256(id || url)`. `materializeImageToPath`
writes buffers to a cache dir (used by Codex `--image <path>`), with byte-
signature sniffing fallback when MIME is generic. `buildAgentInput` is the
single source of truth for per-agent serialization: Claude Code receives base64
image blocks inline in stream-json; Codex receives text on stdin + repeatable
`--image <path>` flags.

CLI gets three input modes: `--prompt <text>` + `--image <path|url|data:>`
(repeatable), `--input-json <file|->` for full content-block JSON, and stdin
auto-detection (JSON vs plain text by first non-whitespace character).
Mutually-exclusive flag combinations error early.

Desktop's `HeterogeneousAgentCtr` drops ~100 lines of duplicated cache /
sniffing code; helpers (`buildStreamJsonInput`, `resolveCliImagePaths`) become
thin wrappers around the shared functions. Driver interface and IPC contract
are unchanged.

`spawnAgent` is now async (image normalization fetches/reads before spawn).

Verified end-to-end: `lh hetero exec --type claude-code --prompt ... --image
red.png` → CC replied "I see a solid red color." `--input-json` mode also
verified. 28/28 desktop tests, 11/11 CLI hetero tests, 22/22 spawn package
tests pass.

Refs LOBE-8523 (phase 1a follow-up before phase 1b ingest).

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

* 🔧 chore(cli): include types/model-bank/business-const in workspace

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

* ♻️ refactor(types): inline crawler and python-interpreter types

Drop workspace deps on @lobechat/web-crawler and @lobechat/python-interpreter
from @lobechat/types by inlining CrawlSuccessResult / CrawlErrorResult /
CrawlUniformResult and PythonOutput / PythonResult into the relevant tool
type modules.

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

* 🔖 chore(cli): bump @lobehub/cli to 0.0.10

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

* 💄 style(github-tool): prefer description over command in inspector/render header

Show the human-readable `description` arg in the gh tool's collapsed
inspector chip and result-card header when provided; fall back to the
extracted subcommand. Full command is still visible in the expanded
Command code block.

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

* 🐛 fix(hetero-agent): treat generic Content-Type as unknown + handle async spawnAgent failures

Two issues raised on PR #14433 review:

**P1 — generic Content-Type bypassed sniffing in normalizeImage**

`fetchUrlImage` accepted any non-empty `Content-Type` as the final
`mediaType`, so CDN responses defaulting to `application/octet-stream` (or
`text/plain`) skipped URL/byte-based detection and forwarded an unrecognized
type into Claude Code's stream-json `media_type` field — Anthropic rejects
those even when the bytes are a valid PNG/JPEG. The same flaw existed for
base64 sources whose declared `mediaType` was generic.

Introduce `pickImageMediaType(headerType, url, buffer)`: the header value is
preferred only when it's a recognized `image/*` type we know how to extension-
map; otherwise it falls through to URL extension hint → byte-signature sniff
→ raw header → `image/png` final fallback. Applied uniformly to URL fetch,
URL cache hit, and base64 decode paths. Path sources are unchanged (their
"header" is the file extension, which is already authoritative when present).

**P2 — async spawnAgent rejections crashed the CLI**

`spawnAgent` is now async and can reject during image normalization (missing
local `--image` path, fetch failure, decode error). The CLI awaited it
outside any try/catch, so user-input errors surfaced as unhandled rejections
with stack traces instead of the friendly `log.error + process.exit` path
used for prompt validation.

Wrap the `await spawnAgent(...)` in try/catch, log the error message, exit 1
(matching the existing "Stream error from agent process" convention).

**Tests**

- `buildAgentInput.test.ts`: 3 new tests covering octet-stream URL
  Content-Type → byte sniff, octet-stream base64 declared type → byte sniff,
  generic header + URL extension hint preferred over header.
- `hetero.test.ts`: 1 new test verifying spawnAgent rejection produces clean
  `exit(1)` instead of an unhandled rejection.

Manually verified:
  `lh hetero exec --image /tmp/does-not-exist.png`
  → `[ERROR] Failed to start agent: ENOENT: no such file or directory…` + exit 1

Refs LOBE-8523.

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-05 23:06:52 +08:00
Tsuki 431abf36d6 feat(mobile): add homeRouter to mobile tRPC router (#14438)
Enable mobile app to access home.getSidebarAgentList for migrating
SessionList from sessionId to agentId (LOBE-8401).

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-05 19:12:32 +08:00
AmAzing- ce516fff9d 🐛 fix(space): show document update time (#14366) 2026-05-05 14:32:32 +08:00
Zhijie He 9e231835b2 💄 style: add grok-4.3 for xAI (#14382) 2026-05-05 12:24:43 +08:00
LobeHub Bot 79b84a68ec 🌐 chore: translate non-English comments to English in brief-types and conversation (#14410) 2026-05-05 12:23:26 +08:00
LobeHub Bot 56e811f5bd 🌐 chore: translate non-English comments to English in agentSignal and builtin-tool-claude-code (#14432) 2026-05-05 11:53:02 +08:00
Arvin Xu 5fb795b092 feat(cli): add lh hetero exec for standalone heterogeneous agent runs (#14431)
* 🌐 i18n: add taskDetail.runAll keys for subtask dependency runner

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

*  feat(cli): add `lh hetero exec` for standalone heterogeneous agent runs (LOBE-8523 phase 1a)

Phase 1a of LOBE-8516: a Node-side `spawnAgent()` plus the CLI command that
drives it. Standalone-only — no `--topic` / `--operation-id` / no server
ingest. Output is `AgentStreamEvent` JSONL on stdout, one event per line.

Why phase 1a is its own milestone: it lets us validate the producer pipeline
end-to-end (`spawn → JsonlStreamProcessor → adapter → toStreamEvent`) under a
plain Node process, get Device-mode + manual debugging unblocked, and ship
without waiting on phase 2's server `heteroIngest` procedures.

## Shared `spawnAgent({ agentType, prompt, resumeSessionId, cwd, command })`

- Lives in `@lobechat/heterogeneous-agents/spawn`. Pure Node — no Electron, no
  image cache, no on-disk tracing, no proxy env composition. Desktop main keeps
  its own bespoke spawn path for those host concerns; this minimal version is
  what the CLI sandbox + terminal use case needs.
- CC: stream-json stdin format + the established preset flags. Codex: `exec` /
  `exec resume` form with `--json --skip-git-repo-check --full-auto`.
- Returns `SpawnAgentHandle` with: async-iterable `events`, `exit` promise,
  `kill(signal)` (Unix process-group kill, Windows direct), `pid`, raw `stderr`.
- Internally a single-queue async iterator coordinates between the stdout
  listeners and the consumer — keeps backpressure simple, no extra deps.

## `lh hetero exec` command

```
lh hetero exec --type claude-code|codex
  [--prompt - | --prompt <text>]   # default stdin
  [--resume <sessionId>]
  [--cwd <path>]                    # default process.cwd()
  [--command <bin>]                 # default `claude` / `codex`
  [--operation-id <id>]             # uuid v4 generated if omitted
```

- Reads prompt from stdin when omitted or `-`.
- Forwards child stderr to ours so users see auth prompts / missing-binary
  errors.
- Ctrl-C → SIGINT to the child's process group (Unix); a second Ctrl-C
  escalates to SIGKILL.
- Exit code passthrough: child code 0/non-0 stays as-is; SIGINT / SIGTERM /
  SIGKILL map to POSIX 130 / 143 / 137.

## Out of scope (phase 1b — next PR)

- `--topic` / `--operation-id` flags as REQUIRED + the BatchIngester
- `--render none|jsonl` flag (phase 1a is implicit JSONL)
- trpc `aiAgent.heteroIngest` / `heteroFinish` calls
- Gateway WS interrupt subscription

## Validation

- `bunx vitest run packages/heterogeneous-agents` — 113 passing (8 new
  spawnAgent tests + the 105 pre-existing on canary)
- `bunx vitest run apps/cli/src/commands/hetero.test.ts` — 7 passing
  (all `--type` / `--prompt` / `--operation-id` / exit-code-passthrough /
  SIGINT-mapping branches)
- Real end-to-end: `bun src/index.ts hetero exec --type claude-code --prompt
  'Reply with exactly the word HELLO and nothing else.'` produced clean
  AgentStreamEvent JSONL (stream_start → 2 stream_chunks → step_complete
  turn_metadata → step_complete result_usage → stream_end → agent_runtime_end),
  every line stamped with the same auto-generated operationId.

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

* 🐛 fix(spawn): serialize pipeline pushes so flush waits for in-flight chunks

When stdout emits multiple chunks back-to-back — or `'end'` lands while an
earlier `pipeline.push()` is still awaiting the Codex tracker's filesystem
reads — the per-chunk `.then` handlers ran concurrently. Two consequences:

1. Out-of-order events. Push #2's events could resolve before push #1's,
   so the JSONL stream came out shuffled.

2. Late-event loss. `'end'` would call `pipeline.flush()` and immediately
   set `streamEnded = true` while prior pushes were still pending. The
   async iterator could then return `{ done: true }` before those pushes
   queued their events.

Fix: thread every `push()` / `flush()` / error-surface call through a single
`pipelineQueue` `Promise` chain, the same shape the desktop controller uses
for its broadcast queue. `flush()` now reliably runs after every queued
push has drained, so `streamEnded` is the very last write.

Two regression tests cover the failure modes by spying on
`AgentStreamPipeline.push` to inject deterministic delays:

- "preserves event ordering across async pipeline.push() calls" — chunk A
  resolves slower than chunk B; without the chain B arrives first.
- "iterator drains slow in-flight pushes before flushing the stream" —
  `'end'` fires while a 40 ms push is still pending; without the chain
  the iterator returns done before the chunk's events queue.

Bisected: both tests fail without the chain, pass with it. E2E re-smoke
(`bun src/index.ts hetero exec --type claude-code` simple text + tool-using
prompt + stdin) still produces clean ordered JSONL.

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-05 10:41:58 +08:00
Neko fbe71e76db test(workflows,workflows-hono): mixed export of agent signal types for workflow (#14429) 2026-05-05 04:57:52 +08:00
Arvin Xu d83f0a0f2f ♻️ refactor(chat): introduce agentDispatcher.selectRuntimeType (#14428)
* 🔥 refactor: remove dead Search Summary chain

Footer.tsx in web-browsing Search portal had near-zero usage. Removing it
makes the entire chain dead: triggerAIMessage, summaryPluginContent,
fillPluginMessageContent, saveSearchResult, plus the inSearchWorkflow param
threaded through internal_execAgentRuntime.

Part of LOBE-8519 — clears the path before introducing agentDispatcher.

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

*  feat: add agentDispatcher.selectRuntimeType

Centralizes the client / gateway / hetero routing decision so every entry
point shares one source of truth. parentRuntime override lets sub-agent
dispatches inherit their parent operation's runtime.

Part of LOBE-8519 — call sites are migrated in following commits.

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

* ♻️ refactor: route sendMessage through selectRuntimeType

Compute runtimeType once per sendMessage call and dispatch off it instead of
re-deriving the hetero/gateway/client decision inline. Behavior is identical;
this just centralizes the routing rule (LOBE-8519, A1).

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

* ♻️ refactor: route regenerate / continue through selectRuntimeType

regenerateUserMessage and continueGenerationMessage in the conversation store
now consult selectRuntimeType for routing. Hetero variants of both are not yet
implemented (they currently fall through to client mode with a TODO + warning).

Also drops chatStore.continueGenerationMessage — the conversation-store version
is the only caller; the chat-store duplicate had zero production usage.

Part of LOBE-8519 (A2, B4 deletion, B5).

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

* ♻️ refactor: route resume helpers through selectRuntimeType

approveToolCalling / rejectToolCalling / rejectAndContinueToolCalling now
consult selectRuntimeType (via #shouldUseGatewayResume) using the operation's
own ConversationContext, instead of the bare isGatewayModeEnabled() check.
Behavior is preserved (gateway resume vs. local resume); hetero resume is not
yet implemented and falls through to the client local path.

Part of LOBE-8519 (A3, A4, A5).

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

* ♻️ refactor: route sub-agent dispatch through selectRuntimeType

directMentionRoute and callAgent now consult selectRuntimeType using the
parent agent's config so sub-agent dispatches inherit the parent runtime.
Only the client path is wired today; gateway / hetero variants warn + fall
through with TODOs for follow-up.

Part of LOBE-8519 (B3, B6).

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

* ♻️ refactor: rename internal_execAgentRuntime to executeClientAgent

Aligns the client runner's name with executeGatewayAgent and
executeHeterogeneousAgent so the three runtimes share a consistent
verb-noun pattern. Pure rename — no behavioral changes; log prefixes
and test mock variables follow the new name.

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-05 01:09:01 +08:00
Arvin Xu fe65741a32 ♻️ refactor(hetero-agent): extract producer pipeline into shared package (#14425)
* 💄 style(todo-progress): use colorFillSecondary so left/right borders are visible against QueueTray

The colorBorderSecondary stroke nearly vanished against the dark elevated bg, so the TODO card looked open on the sides when stacked under QueueTray. Match QueueTray's outer border token (colorFillSecondary) for a consistent visible seam; inner dividers keep colorBorderSecondary as a softer secondary level.

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

* ♻️ refactor(hetero-agent): extract producer pipeline into shared package

LOBE-8516 phase 0. Move the JSONL framing + adapter conversion + toStreamEvent
chain out of the renderer into a new `@lobechat/heterogeneous-agents/spawn`
entry, then have desktop main run it before broadcasting. Renderer now
consumes ready-made `AgentStreamEvent`s on `heteroAgentEvent`, dropping ~50
lines of in-renderer adapter wiring.

This unifies the wire shape across desktop main, the upcoming `lh hetero exec`
CLI, and the server `heteroIngest` handler — every consumer gets the same
stamped `AgentStreamEvent` with no per-consumer adapter step.

The desktop CC flow is unchanged behavior-wise: same adapter, same persistence
ordering, same step-boundary semantics; only the seam between main and
renderer moved.

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

* ♻️ refactor(hetero-agent): pull codex tracker into shared spawn, drop desktop's gateway-client dep

Two cleanups on top of the phase 0 refactor:

1. Move `CodexFileChangeTracker` (+ its test) out of `apps/desktop/src/main/modules/heterogeneousAgent/` into `packages/heterogeneous-agents/src/spawn/`. `AgentStreamPipeline` now auto-instantiates it when `agentType === 'codex'`, so the desktop controller (and the future `lh hetero exec` CLI) stays agent-agnostic — no more "if codex { wire tracker via transformPayload }" branching at the call site. The public `transformPayload` hook is removed since it had no other consumer.

2. Re-export `AgentStreamEvent` / `AgentStreamEventType` from `@lobechat/heterogeneous-agents/spawn` and drop `@lobechat/agent-gateway-client` from `apps/desktop/package.json`. The gateway-client package is a browser-side WebSocket client; producer-side callers (desktop main, sandbox CLI) shouldn't carry it as a direct dep — they only need the type, which now flows through the producer-side entry.

Type predicate on Codex payloads tightened to a non-`Required<>` shape so the moved file passes the root tsconfig's `strict: true` (apps/desktop's tsconfig was lax).

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

* 🧑‍💻 chore(local-testing): harden electron-dev.sh process management

Lifecycle improvements for the local-testing helper so smoke runs against the desktop dev session are reliable:

- `find_project_pids` now also catches user-started `bun run dev` Electron sessions (matches by project electron path, not just `--remote-debugging-port`), the launcher subshell saved to PIDFILE, and any process bound to the CDP port. Vite match tightened to `electron-vite[/.].*\bdev\b` so unrelated Vite invocations aren't swept up.
- `do_stop` expands seed PIDs into their descendant trees (DFS via `pgrep -P`), SIGTERMs the whole tree, waits 5s, then SIGKILLs survivors. Belt-and-suspenders sweep for stragglers + anything still bound to the CDP port. Closes the long-standing "Helper processes survive the kill" gotcha.
- `do_start` detects existing project Electron/vite before tearing it down so the user sees what's being killed; waits for port + user-data-dir locks to release before relaunching to avoid the "user data directory in use" race.
- `wait_for_cdp` uses an explicit deadline + early bail-out if the launcher PID dies, instead of the previous fixed-step loop. `wait_for_renderer` no longer pre-sleeps 10s.

`setsid` use is intentional; it puts the launched Electron in its own session so the whole tree shares a PGID we can signal in one shot. Note: `setsid` is GNU coreutils — on macOS without `brew install util-linux` the script will fail at the launch step. Documented as a known limitation; no fallback added.

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

* 🐛 fix(hetero-agent): gate session-complete on stdout fully drained

Node may emit `proc.on('exit')` BEFORE child stdio fully closes (documented
in child_process: "stdio streams might still be open"). Phase 0 of LOBE-8516
moved adapter ownership to main, so renderer no longer flushes its own
adapter on session-complete — meaning trailing events synthesized by
`pipeline.flush()` (e.g. Codex's `tool_end` for unfinished tool calls) would
race against, and lose to, the `heteroAgentSessionComplete` broadcast,
leaving renderer-side persistence to finalize on incomplete state.

Fix: in `proc.on('exit')`, await `streamFinished(stdout)` (covers `'end'`,
`'close'`, and `'error'`) BEFORE awaiting the broadcast queue. The first
await ensures the `stdout.on('end')` handler has had a chance to schedule
`pipeline.flush()` onto the queue; the second drains it. Only then do we
broadcast complete / error.

Regression test repros the documented Node race by emitting `exit` before
`stdout.end()` and asserts every `heteroAgentEvent` (including the
synthesized `tool_end` from `pipeline.flush()`) lands before
`heteroAgentSessionComplete`. Bisected: test fails without the gate, passes
with it.

Also: add `packages/heterogeneous-agents` to `apps/desktop/pnpm-workspace.yaml`
to mirror the new workspace dep added in the phase 0 refactor.

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

* 🐛 fix(hetero-agent): drop builtin-tool-claude-code dep, inline the 3 CC wire shapes the adapter needs

Phase 0 added `@lobechat/heterogeneous-agents` as a runtime dep of the desktop
main process. That transitively pulled in `@lobechat/builtin-tool-claude-code`
(declared in the shared package's deps), which the desktop pnpm workspace
doesn't list — CI install on the desktop project fails:

    ERR_PNPM_WORKSPACE_PKG_NOT_FOUND  In ../../packages/heterogeneous-agents:
    "@lobechat/builtin-tool-claude-code@workspace:*" is in the dependencies but
    no package named "@lobechat/builtin-tool-claude-code" is present in the
    workspace

The dep is also a layer-violation: `heterogeneous-agents` is the producer
side (CLI stream → AgentStreamEvent), `builtin-tool-claude-code` is the UI
tool definition (renderers / inspectors / agent template). Producer
shouldn't depend on UI-tool packages, even if today the import is just
types/constants — the dep cascade still drags `shared-tool-ui` etc. into
every workspace that wants the adapter.

Fix: inline the three things the adapter actually uses (`'TodoWrite'` tool
name string, `TodoWriteArgs` interface, `ClaudeCodeTodoItem` interface).
They reflect upstream Claude Code's wire schema — if `claude` ever renames
`TodoWrite`, the adapter and the downstream renderers must both update
regardless of whether they share a constant. Renderer-side packages
(`builtin-tools/codex/TodoListRender`, etc.) keep importing the canonical
`ClaudeCodeApiName` from `@lobechat/builtin-tool-claude-code`.

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-05 01:04:09 +08:00
YuTengjing b5e4cd0805 🐛 fix: revoke sessions after password reset (#14424) 2026-05-05 00:05:05 +08:00
YuTengjing f565ca9450 🐛 fix: revoke sessions after password reset (#14424) 2026-05-04 23:55:48 +08:00
YuTengjing e6d49fdb76 🐛 fix: track visual analysis trigger (#14399) 2026-05-04 23:52:49 +08:00
YuTengjing 47c524a388 🐛 fix: handle Claude assistant prefill errors (#14398) 2026-05-04 23:28:26 +08:00
Arvin Xu cb4412421f ♻️ refactor(local-system,cloud-sandbox): drop "Local" prefix from tool names (#14364)
* ♻️ refactor(local-system,cloud-sandbox): drop "Local" prefix from tool names

LLM-facing tool names dropped the redundant "Local" / "LocalFiles" prefix
to shrink manifest/system-prompt token footprint:
editLocalFile→editFile, globLocalFiles→globFiles, listLocalFiles→listFiles,
moveLocalFiles→moveFiles, readLocalFile→readFile,
searchLocalFiles→searchFiles, writeLocalFile→writeFile.

Also removed `renameLocalFile` entirely from the new surface — `moveFiles`
already covers in-place renames by changing only the filename in newPath.

Old long names are still recognised in the rendering path
(client Render/Inspector/Intervention/Streaming registries, placeholders,
workflow display labels, i18n keys) and in Gateway/CLI routing, so
historical messages and older Gateway versions keep working.

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

* ♻️ refactor(local-system): reuse LocalSystemApiName / LocalSystemIdentifier exports

Drop the inline LOCAL_SYSTEM_IDENTIFIER / READ_FILE / LIST_FILES consts in
the snapshot materializer and import the canonical values from the package.
Mark LocalSystemApiName `as const` (matching CloudSandboxApiName) so values
narrow to literal types and satisfy LocalSystemToolSnapshot.apiName.

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-04 23:19:29 +08:00
Arvin Xu 78b3dbed03 feat: devtools gallery rebuild, Review polish, queue-tray images (#14423) 2026-05-04 23:12:59 +08:00
Arvin Xu 95375cec79 ♻️ refactor(builtin-tools): retire lobe-tools alias and slim lobe-notebook to render-only (#14422)
* ♻️ refactor(builtin-tools): retire lobe-tools alias and slim lobe-notebook to render-only

- Drop the deprecated `'lobe-tools'` identifier alias from the inspector / render
  registries plus its backward-compat checks in dbMessage selectors and the dev
  RenderGallery fixtures.
- Hoist the only surviving notebook UI (the `createDocument` document card) into
  `packages/builtin-tools/src/notebook/`, mirroring the github tool layout.
  Marked the new module `@deprecated` with a ~3-month removal target.
- Delete `packages/builtin-tool-notebook/src/client/` entirely and unregister
  notebook from the inspectors / interventions / placeholders / streamings
  registries (it can no longer be invoked by the LLM, so those surfaces are dead
  code). Manifest / executor / ExecutionRuntime stay so legacy tool calls keep
  resolving.

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

* 🔧 chore(builtin-tools): drop redundant antd peer dep

antd is already provided by the workspace and peered through
@lobehub/ui, so listing it explicitly on builtin-tools is noise.

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-04 20:58:00 +08:00
Arvin Xu aa3c7e585b 💄 style(builtin-tools): add UI render for github marketplace tool (#14420)
*  feat(builtin-tools): add UI render for github marketplace tool

Register an Inspector + Render for the marketplace `github` MCP tool
(single `run_command` API that wraps the `gh` CLI). Mirrors the codex
pattern under packages/builtin-tools/src/github/.

- Inspector: GitHub brand chip with the parsed gh subcommand and a
  success/error indicator after the call resolves.
- Render: ToolResultCard with the full gh command (sh-highlighted) and
  the output, auto-detected as JSON for `gh api` / `--json` calls.

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

*  feat(builtin-tools): add inspector renders for moveLocalFiles and exportFile

Cloud-sandbox and local-system both expose moveLocalFiles, and cloud-sandbox additionally
exports exportFile, but none of these had inspector components registered, so the title
area in tool calls fell back to the default loading text. Add a shared
createMoveLocalFilesInspector factory and a cloud-only ExportFileInspector, then wire them
into both packages' inspector registries.

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

* 💄 style(builtin-tools): drop redundant "GitHub:" prefix in github inspector

The chip already shows the GitHub icon and a `gh` prefix next to the subcommand,
so the leading "GitHub:" text was duplicating that signal. Always render the chip
(even when no subcommand has streamed yet) and remove the now-stale margin and
streaming-only branch.

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

* 💄 style(builtin-tools): hoist gh prefix out of github inspector chip

Move the literal `gh` text to plain leading copy with the GitHub icon as a separator,
and let the chip carry only the gh subcommand (e.g. `api /repos/...` or `search code ...`).
Reads more like the actual command and lets the verb stand out as the chip's first token.

Also seed a github run_command fixture in /devtools so the chip layout is preview-able.

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

* 💄 style(builtin-tools): hoist github icon out of chip too

Move the GitHub icon next to the literal `gh` prefix so the chip carries only the
gh subcommand (api /repos/..., search code ..., etc.). Reads as: [icon] gh [chip].

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-04 20:28:47 +08:00
Arvin Xu 11e6619a3c feat(server,task): batch run subtasks in dependency order (#14418)
*  feat(server,task): batch run subtasks in dependency order

Adds a "Run all" entry on the subtasks panel that kicks off the first
dependency layer; subsequent layers fire automatically as upstream tasks
complete. Layer planning (Kahn topo sort + cycle detection) lives in a
new TaskGraphService and runs server-side via two TRPC procedures.

Also fixes a pre-existing bug where `task.updateStatus(completed)` was
flipping unlocked dependents to `running` without ever invoking the
runner — leaving them in a phantom running state with no topic in
flight. Cascade now goes through TaskRunnerService.cascadeOnCompletion
from all three completion paths (TRPC updateStatus, brief approval,
judge auto-pass), so dependency chains advance end-to-end on their own.

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

* 🐛 fix(server,task): preserve edges to in-flight and out-of-scope upstreams

The graph used to drop any dependency edge whose upstream wasn't in the
runnable set. That silently freed two correctness-breaking cases:

- A backlog subtask that depends on a *running / scheduled* sibling
  landed in layer 1 and got kicked off before its blocker finished.
- A descendant that depends on a task *outside the current subtree*
  (allowed by the schema) lost its blocker entirely and ran prematurely.

Edges are now classified per dependency: terminal-OK upstreams drop the
edge; in-batch runnable upstreams keep their in-degree contribution; any
other status — in-flight, runnable but out of scope, or unknown — marks
the dependent as `blockedExternally` and excludes it from the layered
plan. External blockage propagates transitively through in-batch edges
so we never run a downstream of a blocked task either. `planForParent`
fetches statuses for cross-scope upstreams so the classifier has real
data to decide on.

The UI surfaces the new bucket via `RunSubtasksPreview` and keeps the
modal open (with the run button disabled) when a plan has nothing to
start but does have blocked tasks worth explaining.

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-04 19:44:40 +08:00
Arvin Xu 41719dfd29 🐛 fix(gateway): unstick input loading on auth_failed + recoverable auth_expired (#14419)
* 🐛 fix(gateway): complete local op on auth_failed to unstick input loading

When the gateway client receives `auth_failed` (server has GC'd the op or
the refreshed JWT no longer matches), the local op stayed `running`
forever — input kept the stop button, and `topic.metadata.runningOperation`
never cleared, so every revisit re-fired the same broken reconnect.

Treat `auth_failed` as session-terminal alongside `session_complete` so
`onSessionComplete` fires and `completeOperation` runs.

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

*  feat(gateway): support recoverable auth_expired with token refresh

When the JWT expires while the operation is still alive on the server,
sending `auth_failed` is wrong — the op is fine, only the credential
went stale. Treat that as a separate, recoverable signal instead.

Server (agent-gateway repo) emits a new `auth_expired` message and
keeps the WebSocket open. The client refreshes its JWT (via the
existing `aiAgentService.refreshGatewayToken`), updates the in-flight
client, and reconnects. `auth_failed` stays terminal for cases where
the op truly no longer exists.

Mirrors the device-gateway-client pattern (`auth_expired` event +
`updateToken` + `reconnect`). If no `tokenRefresher` is wired in (or
the refresh itself fails), we fall back to terminal so the input
doesn't stay stuck on the loading state.

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

* 🐛 fix(gateway): disconnect ws on auth_expired without tokenRefresher

The server keeps the WebSocket open after `auth_expired` (so the client
can refresh and re-auth on the same connection). When no `tokenRefresher`
is wired in, we mark the local op complete but were leaving the socket —
heartbeat and autoReconnect kept running indefinitely after the op was
gone, leaking background connections.

Mirror the refresh-failure branch and call `client.disconnect()` before
firing onSessionComplete.

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

* ♻️ refactor(gateway): make tokenRefresher required on connectToGateway

Both real callers (executeGatewayAgent + reconnectToGatewayOperation)
already supply a refresher built from `aiAgentService.refreshGatewayToken`,
and there's no scenario where a Gateway op runs without a topic to refresh
against. The optional path was carrying its own foot-gun (socket leak if
forgotten) and a defensive ternary on `result.topicId` that the type
already rules out.

Required-only collapses both into the existing refresh-failure branch.

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

* ♻️ refactor(gateway): inline token refresh, take topicId instead of refresher

Both callers of connectToGateway built identical refresher closures over
`aiAgentService.refreshGatewayToken(topicId)`. Pass `topicId` directly and
let connectToGateway call the service inline — gateway.ts already imports
aiAgentService for the cancel-handler path, so no new coupling.

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

* 💄 chore(gateway): rewrite stale auth_expired comment

The "no refresher provided" branch is gone — fold that case out of the
comment and explain why the catch branch needs explicit disconnect().

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-04 19:39:31 +08:00
Arvin Xu b66e83a57c 🐛 fix(security): add max pagination limits to tRPC endpoints (#14415)
* fix(security): add max(50) pagination cap to file.recentFiles and file.recentPages

Fixes GHSA-jr3g-w7rp-fhm9: unbounded limit parameter in recentFiles
and recentPages endpoints allowed authenticated users to trigger
arbitrarily large DB queries (amplified 3x before the DB call).

Adds .max(50) Zod constraint to cap both endpoints at 50 items.

* fix(security): add pagination caps to topic.getTopics, rankTopics, recentTopics

Fixes GHSA-jr3g-w7rp-fhm9:
- getTopics.pageSize: .max(100)
- rankTopics input: .max(50)
- recentTopics.limit: .max(50)

* fix(security): add pagination caps to session.getSessions and rankSessions

Fixes GHSA-jr3g-w7rp-fhm9:
- getSessions.pageSize: .max(100)
- rankSessions input: .max(50) (multi-JOIN aggregate query)

* fix(security): add max(100) pagination cap to agent.queryAgents

Fixes GHSA-jr3g-w7rp-fhm9: unbounded limit parameter in queryAgents
allowed resource exhaustion via arbitrarily large DB queries.

* fix(security): add max(100) pagination cap to document.queryDocuments

Fixes GHSA-jr3g-w7rp-fhm9: unbounded pageSize parameter in queryDocuments
allowed resource exhaustion via arbitrarily large DB queries.

* 🐛 test(web-crawler): remove zhihu test cases after rule removal

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-04 18:53:28 +08:00
Arvin Xu bc103b2e11 ♻️ refactor(web-crawler): remove zhihu-specific crawl rules (#14414) 2026-05-04 14:54:46 +08:00
AmAzing- d28b401aaf refactor: agent list reuse to isolate drawer state (#14411)
 Refactor agent list reuse to isolate drawer state
2026-05-04 12:01:06 +08:00
Neko a79cdd19f8 ️ perf(server,agent-signal): improved skill intent detection (#14409) 2026-05-04 06:33:58 +08:00
Neko 222f525bf4 ♻️ refactor(types,agent-signal): request trigger will use agent-signal enum (#14408) 2026-05-04 04:56:47 +08:00
Neko 317fdcec13 feat(app,agent-signal): new agent recent activities to display for signal receipts triggered (#14407) 2026-05-04 04:14:54 +08:00
Neko 162d6cfa67 🐛 fix(userMemories): should parse and validate date string for time intent (#14406) 2026-05-04 04:14:13 +08:00
Arvin Xu 2870cc73c2 feat(builtin-tool-task): add Inspector + Render, batch createTasks/runTasks (#14403)
*  feat(builtin-tool-task): add Inspector + Render, batch createTasks/runTasks

Adds chip-style Inspector and per-API Render to the lobe-task tool, plus two
batch APIs (createTasks, runTasks) so an agent can plan or launch a set of
subtasks in a single call instead of calling createTask/runTask N times.

runTask/runTasks call taskService.run, actually triggering TaskRunnerService
and producing a topic+operationId — distinct from updateTaskStatus(running),
which only flips a flag. The system prompt now spells this out so the model
stops conflating the two. Already-running, missing-assignee, and per-item
failures surface back to the agent with clear messages.

Fixes LOBE-8438

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

* 🐛 fix(server,task): implement createTasks/runTask/runTasks in server runtime

The manifest exposes these APIs to the model, but only the client-side
executor was implemented. Server-side tool execution (src/server/services/
toolExecution/builtin.ts) throws "Builtin tool ... is not implemented" when
the runtime is missing a method, so production paths that route through the
server runtime would fail at runtime.

- Extracted createTaskImpl as a reusable closure so createTasks loops can
  reuse the parent-resolution + assignee-validation flow without copy-paste
- runTask / runTasks call taskCaller.run(...) which already routes to
  TaskRunnerService — same execution path as the UI/CLI run buttons
- runTasks continues past per-item failures and reports them in the summary
  (matching the client executor's behavior)
- Added 7 tests (20 total in this file) covering happy path, per-item
  failure, missing identifier, and current-task fallback

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

* 🐛 fix(task-drawer): hide topic feedback input until run terminates

Feedback can only steer the next run, so showing the input while the
topic is pending/running was misleading — gate it on terminal status
(completed/failed/canceled/timeout).

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-04 01:15:41 +08:00
Arvin Xu d5097c7964 💄 fix(builtin-tool-agent-documents): wire Inspectors into registry, switch to chip UI (#14404)
* 💄 fix(builtin-tool-agent-documents): wire Inspectors into registry, switch to chip UI

The Inspector components for lobe-agent-documents existed but were never
registered in packages/builtin-tools/src/inspectors.ts, so the chat UI fell
back to the default "(id:316c6ad5-10e7-46ff-8ccf-15f2359c19...)" header
that shows raw param dumps. Registering them is the root fix.

While in there, refactored all 9 inspectors to the chip pattern used by the
other builtin tools — full UUIDs are noisy in a one-line header, so document
ids are truncated to their first 8 chars (prefixed ids like agd_… are left
intact since they're already short). Each inspector now surfaces the most
useful per-API context: title chip when known (Read/Create), id chip + new
title (Rename/Copy), op count + success ratio (Modify), char count
(Replace), target scope + doc count (List), rule type (UpdateLoadRule),
red dashed line-through (Remove). Shared chip styles live in one
_styles.ts so the visual language stays consistent.

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

* 📝 docs(.agents/skills): add builtin-tool skill

Self-contained reference for building/extending lobe-* builtin tools —
SKILL.md entry point plus architecture / tool-design / ui deep-dives.
Sits alongside the other agent skills.

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-04 00:56:06 +08:00
Neko aa3d245cfd ♻️ refactor(server,prompts,builtin-tool-skill-maintainer): correct context passing, skill format, chained (#14397) 2026-05-03 23:30:44 +08:00
Arvin Xu 61c3f42f10 🐛 fix: sync DEFAULT_MODEL into desktop business-const stub (#14402)
🐛 fix: sync DEFAULT_MODEL/DEFAULT_MINI_MODEL into desktop business-const stub

#14379 moved DEFAULT_MODEL and DEFAULT_MINI_MODEL into @lobechat/business-const,
but the desktop workspace stub at apps/desktop/stubs/business-const wasn't
updated, breaking the desktop client build with MISSING_EXPORT errors.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 23:29:04 +08:00
YuTengjing 2dd52c6813 feat: show original pricing and prioritize DeepSeek (#14391) 2026-05-03 01:27:52 +08:00
Arvin Xu 3f82249ed1 💄 style: add feedback input at bottom of TopicChatDrawer (#14392)
*  feat: add feedback input at bottom of TopicChatDrawer (LOBE-8441)

Mount a comment box inside the Topic Run drawer so users can leave
feedback and trigger a follow-up topic run without leaving the drawer.
Send button calls addComment then runTask (without continueTopicId, so
a brand-new topic is started instead of resurrecting the completed one).

Existing AgentTaskDetail/CommentInput is untouched — the new component
lives next to TopicChatDrawer and stays separate.

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

* 🐛 fix: close TopicChatDrawer after submitting feedback

Closing the drawer once the comment is persisted and the new run is
kicked off matches user expectation — leaving it open made it look
like the existing topic was the one being run again.

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-03 01:07:43 +08:00
LobeHub Bot b49c1c15b7 🤖 style: update i18n (#14383)
💄 style: update i18n

Co-authored-by: canisminor1990 <17870709+canisminor1990@users.noreply.github.com>
2026-05-03 01:07:03 +08:00
YuTengjing df32dd4966 feat: support model defaults and DeepSeek pricing (#14379) 2026-05-02 23:21:09 +08:00
YuTengjing b5d7696dbd feat: add visual understanding tool (#14378) 2026-05-02 22:18:50 +08:00
Arvin Xu d2d81ba64a 💄 style(document-modal): show skeleton for title while loading (#14377)
* 💄 style(document-modal): show skeleton for title while document is loading

Replace the "Untitled" placeholder and AutoSaveHint with a skeleton in both the modal header and the in-page title editor while the document is still being fetched, so the empty fallback no longer flashes before content arrives.

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

*  feat(task-detail): add run-now dropdown next to cancel-schedule button

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

*  feat(task-artifacts): show created time and sort newest first

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-01 18:51:09 +08:00
YuTengjing b2130f7612 🐛 fix: handle auth captcha retries (#14346) 2026-05-01 18:27:04 +08:00
Arvin Xu 626d274859 🔨 chore(release-template): clean up changelog templates (#14375)
* 🔨 chore(release-template): drop Highlights from db-migration changelog

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

* 🔨 chore(release-template): drop version numbers from changelog templates

Patch releases auto-bump on merge, so the version isn't known when the
changelog is authored. Replace `# 🚀 LobeHub v<x.y.z> (YYYYMMDD)` with
`# 🚀 LobeHub Release (YYYYMMDD)` in all changelog examples and the
GitHub Release Changelog Template inside SKILL.md, and replace the
hard-coded `Since v...` / `Full Changelog: v...v...` lines in the
weekly-release example with the same `<previous-tag>` placeholder
already used by the SKILL.md template.

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-01 16:46:16 +08:00
Arvin Xu 9c509680b9 🚀 release: sync main branch to canary (#14374)
Automatic sync from main to canary. Merge conflicts detected.

**Resolution steps:**
```bash
git fetch origin
git checkout sync/main-to-canary-20260501-25207007930
git merge origin/main
# Resolve conflicts
git add -A && git commit
git push
```

> Do NOT merge canary into a main-based branch — always merge main INTO
the canary-based branch to keep a clean commit graph.
2026-05-01 16:33:03 +08:00
Arvin Xu 70f81ad1a1 🚑 fix: resolve unresolved merge conflict markers in main→canary sync
Keep canary-side logic in useSend (active home agent), feedback action
planner procedure-state, useSend test mocks, and e2e Home chat-input
step. The main-side blocks referenced removed symbols and outdated
action-planning code that would break compile/tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:18:01 +08:00
Arvin Xu c401d1b97f Merge remote-tracking branch 'origin/main' into sync/main-to-canary-20260501-25207007930 2026-05-01 15:57:49 +08:00
lobehubbot eddb0c991b 🔖 chore(release): release version v2.1.56 [skip ci] 2026-05-01 07:49:26 +00:00
lobehubbot 6340ab55e9 chore: merge main into canary (has conflicts to resolve) 2026-05-01 07:47:44 +00:00
Neko 86a23b5555 👷 build(database): add metadata and trigger to briefs table (#14354)
* 👷 build(database): add metadata and trigger to briefs table

*  test(server): should not use adhoc Date.now() (#14280)
2026-05-01 15:47:02 +08:00
Arvin Xu 3cb06e07e3 💄 style(taskDetail): force daily briefs for scheduled tasks; switch activity timestamps to absolute date (#14367)
*  feat(brief): always synthesize a brief on scheduled-task ticks

Heartbeat ticks remain mid-loop nudges and are still skipped, but
schedule-mode tasks now bypass both the trivial-content rule gate and
the LLM emit-vote so each scheduled run produces a daily brief.

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

* 💄 style(taskDetail): switch activity timestamps to absolute date once gap exceeds one day

Adds formatActivityTime helper to @lobechat/utils/time: relative phrasing
under 24h, localized date (e.g. "4月29日" / "Apr 29") afterwards, with the
full datetime exposed via the native title attribute on hover.

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

* 🐛 fix(brief): fork chainGenerateBrief prompt so scheduled ticks always produce a brief

The default prompt instructs the LLM to pair `emit=false` with an empty
title, so even after we bypassed the emit-vote for scheduled tasks the
downstream `!title || !summary` guard could still drop the brief and
silently break the "every schedule tick must produce a brief" contract.

chainGenerateBrief now takes a forceEmit flag; when true it swaps to a
scheduled-tick prompt that removes the skip branch and mandates a
non-empty title/summary, including the "no new activity today" path.
synthesizeTopicBrief passes forceEmit=true for schedule-mode tasks.

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

* Update @google/genai version to ~1.50.1

* 💄 style(conversation): stack TodoProgress + QueueTray as a floating overlay above ChatInput

Move TodoProgress out of normal flow and render it together with QueueTray
inside ChatInput as a single absolute-positioned overlay anchored to the
input's top edge. The overlay no longer pushes ChatList up; instead it sits
as a "cover layer" above the scroll viewport.

To keep chat content reachable above the overlay, expose the overlay's
measured height via the conversation input store (ResizeObserver in
ChatInput) and have VList consume it as `paddingBottom = max(24, height +
12)` — the +12 compensates for ChatInput's `marginTop: -12`. BackBottom
also reads the same height via a new `bottomOffset` prop so the
back-to-bottom button lifts above the overlay instead of being occluded.

QueueTray sits on top, TodoProgress below; TodoProgress squares its top
corners (`topAttached`) when QueueTray is present so the two panels fuse
into a clean stack with no notches at the seams.

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

*  test(utils): make formatActivityTime title assertion timezone-independent

The test hardcoded `2026-05-01 13:00:00` (UTC+8 author tz), so it failed in
UTC CI as `2026-05-01 05:00:00`. Derive the expected title via the same
dayjs format the implementation uses so the assertion holds regardless of
the runner's timezone.

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

*  test(conversation): include chatInputOverlayHeight in store mock state

The store always initializes chatInputOverlayHeight to 0 via inputInitialState,
so the State type rightly keeps it required. The selectors test mock simply
missed the field after the slice gained it; supply 0 to match the real
initial state instead of weakening the type to optional.

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

* ♻️ refactor(brief): split judge from generate, persist decision on task topic

Split the brief-emission flow into two independent stages so judgment and
copy-generation are no longer entangled in a single LLM call (which made
the scheduled-tick fork necessary in the first place).

- Rule layer (`shouldEmitTopicBrief`) goes three-state: `'yes' | 'no' |
  'unknown'`. Conclusive cases (error / review-handled / review-configured
  / heartbeat / trivial-non-scheduled / scheduled) bypass the LLM entirely;
  only manual + non-trivial topics fall through to `'unknown'`.
- New `chainJudgeBriefEmit` (small chain, returns `{emit, reason}`) is
  invoked ONLY on the `'unknown'` branch. Title/summary copy is no longer
  in scope for this call.
- `chainGenerateBrief` drops the `forceEmit` fork and the `emit` field —
  it now assumes the caller has already decided to emit and just produces
  `{title, summary}`. Saves tokens on skip paths since we never draft copy
  for a brief that won't be persisted.
- Every decision (rule or LLM) is persisted to
  `taskTopics.handoff.briefDecision` via a new `updateBriefDecision` model
  method using `jsonb_set + COALESCE` so existing handoff fields aren't
  disturbed. Gives operators a per-topic audit trail of why a brief was
  or wasn't produced.

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

* ♻️ refactor(brief): emit on errors, defer heartbeat to LLM judge

Two follow-up tweaks to the rule layer (`shouldEmitTopicBrief`):

- `reason === 'error'` is no longer a hard skip — the user must be told the
  run failed. Returns `{emit: 'yes', reason: 'execution-error'}` so once
  the error path is folded into `synthesizeTopicBrief` (separate
  consolidation refactor) the verdict is correct without further changes.
  Currently dead code: `onTopicComplete` still builds an urgent error
  brief inline at the `else if (reason === 'error')` branch.
- Heartbeat ticks change from a hard `'no'` to `'unknown'`. Most ticks are
  mid-loop noise but the occasional one warrants surfacing, and only the
  LLM can read the content to tell. Heartbeat is at minimum 10 min so the
  added judge call per tick is acceptable.

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-01 15:37:15 +08:00
Innei c9b44935ed revert: revert pnpm v11 migration (#14372)
* Revert "👷 build: disable pnpm gvs for desktop ci (#14357)"

This reverts commit 948ba5ec68.

* Revert "👷 build(repo): migrate to pnpm v11 and consolidate workspace config (#14316)"

This reverts commit 1d9b6099bd.
2026-05-01 14:45:28 +08:00
Innei 948ba5ec68 👷 build: disable pnpm gvs for desktop ci (#14357)
* 👷 build: disable pnpm gvs for desktop ci

* 👷 build: increase desktop install heap

* 👷 build: raise linux desktop file limit

* 👷 build: skip desktop package rebuild

* 👷 build: hoist desktop isolated install

* 👷 build: skip desktop dependency collector

* 👷 build: mark desktop modules externally handled

* 👷 build: limit desktop native runtime deps

* 👷 build: include get-windows runtime resolver deps
2026-05-01 13:17:21 +08:00
LiJian d0091901dc 🐛 fix(skill): skip OAuth redirectUri on desktop to prevent broken app (#14345)
🐛 fix(skill): skip OAuth redirectUri on desktop to prevent broken app:// navigation

On desktop (Electron), window.location.origin is app://renderer which the system browser cannot navigate to. Skip passing redirectUri so market shows a default success page instead, relying on existing window-close monitoring and fallback polling to detect OAuth completion.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-01 13:00:55 +08:00
Arvin Xu 8c3b83f8b3 🐛 fix(local-system): tokenize mdfind keywords, scope glob to home, align tool prompts (#14358)
* 🐛 fix(local-system): tokenize mdfind keywords, scope glob to home, align tool prompts

- mdfind treats free-form keywords as a single literal substring; "LobeHub
  Financial Statement" never matches "Financial_Statement_LobeHub.pdf".
  Split on whitespace and AND each token (still substring-matched) so
  ordering doesn't matter.
- Unix/Windows glob fell back to process.cwd() — meaningless inside a
  packaged Electron app. Default to os.homedir() instead so unscoped
  patterns can actually find user files.
- systemRole/systemRole.desktop documented `query`/`onlyIn`/`path` for
  searchLocalFiles/grepContent/globLocalFiles, but the manifest exposes
  `keywords`/`scope`. The wrong names were silently dropped, so the LLM
  could never scope its searches. Aligned the prompts with manifest and
  noted the new keyword-tokenization semantics.

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

* 🐛 fix(local-system): preserve glob/grep error in tool message content + tidy file row UI

Two independent bugs that combined to break Glob/Grep tool messages and
then made search hits look ugly in the result list.

Empty `content` on glob failure
- LocalSystemExecutionRuntime.normalizeResult dropped `raw.error` when
  mapping `globLocalFiles`/`grepContent`, so a failure from the IPC layer
  (e.g. fast-glob throwing EACCES while traversing the wrong cwd) became
  `{ result: {...}, success: false }` with no error attached.
- ComputerRuntime.errorOutput then did
  `result.error?.message || JSON.stringify(result.error)`. With error
  undefined that yields the value `undefined` (not the string), which
  collapsed into `content: ""` downstream — the chat store still saved
  `pluginState` so users saw a tool message with state set but the
  Response panel completely blank.
- Propagate `raw.error` through normalizeResult and harden errorOutput
  with a "Tool execution failed" fallback so the LLM and the debug panel
  always get a real string.

Search results layout
- FileItem stacked filename and a redundant full path on a single
  baseline-aligned row, so the path column repeated the filename and
  felt visually off-balance.
- Switch to a two-line layout: filename on top, parent directory only
  (collapsed via displayRelativePath when available) underneath, both
  vertically centered against the file icon.
- Promote the "open containing folder" action from hover-only to a
  permanent right-side button so it's reachable in one click.
- Bump the SearchFiles scroll container so the taller rows still show a
  reasonable number of hits before scrolling, and add a Downloads-style
  fixture to the dev panel render gallery.

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

* 🐛 fix(local-system): harden executor toResult to never emit empty content and to keep state on failure

The earlier fix patched normalizeResult and ComputerRuntime.errorOutput,
but the central funnel where every executor return is shaped —
LocalSystemExecutor.toResult — still trusted the runtime output blindly:

- the success=false branch dropped `state` entirely, which meant any
  partial pluginState a runtime had built up was thrown away the moment
  it reported an error (renderers then re-rendered as if the call had
  produced nothing).
- both branches passed `output.content` through verbatim, so an
  upstream regression that forgot to populate content (the recent Glob
  EACCES path) would still surface as a blank Response panel.

Make toResult the strict gate it claims to be: derive a non-empty
content from `output.content -> output.error.message -> "Tool execution
failed"`, and always propagate `state` regardless of `success`.

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

* 🔒 chore(devtools): sanitize searchLocalFiles fixture to use synthetic data

Replace real-looking filenames, paths and corporate identifiers in the
RenderGallery fixture with neutral sample-user / sample-quarterly-report
placeholders. The fixture is checked into the repo and shipped to every
contributor's dev panel — it shouldn't carry data that resembles a
specific person's Downloads/iMessage/WeChat layout.

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-01 02:19:31 +08:00
Arvin Xu b031513321 🐛 fix(brief): keep recurring tasks active when resolving their result briefs (#14348)
* 🐛 fix(brief): keep recurring tasks active when resolving their result briefs

Approving a `result` brief on a recurring (`automationMode='schedule'`)
task was flipping the parent task to `completed`, which removed it from
the active board and stopped future scheduled runs from surfacing on it.
A daily brief is one occurrence — accepting it is a UI dismissal, not a
lifecycle terminal.

The discriminator is the **task's** automation mode, not the brief's
`cronJobId`. A manual run of a recurring task has `cronJobId=null` but
the task is still recurring, so a cronJobId-based check would let that
case slip through.

- Server: `BriefService.resolve` now loads the task and only completes
  it when `automationMode !== 'schedule'`.
- Server: `enrichBriefsWithAgents` also batches the task lookup and
  exposes `taskAutomationMode` on the listed briefs so the UI can label
  the action correctly without an extra round-trip.
- UI: the result action label switches to "Mark as resolved" /
  "标记为已解决" when `taskAutomationMode === 'schedule'`.

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

* 💄 style(brief): unify result brief action to "Confirm" and key off task status

Replace the dual confirmDone/markResolved labels with a single brief.action.confirm,
and gate task completion on task.status !== 'scheduled' so heartbeat-mode tasks
parked between ticks are also kept active when one of their result briefs is
approved.

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

* 💄 style(brief): restore "Confirm complete" for terminal-accept; "Confirm" only for status='scheduled'

Bring back brief.action.confirmDone alongside the new brief.action.confirm.
The dual-label discriminator is the parent task's runtime status: tasks parked
at 'scheduled' show "Confirm" (dismiss-only — server keeps them active for the
next tick), all other states show "Confirm complete" since approving will flip
the task to completed. Server keeps its task.status !== 'scheduled' guard.

Threads taskStatus on BriefItem / BriefWithAgents (replacing the previously
removed taskAutomationMode) so the UI label matches the actual server effect.

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

* 🐛 fix(brief): make BriefItem.taskStatus optional for locally-constructed briefs

TaskActivities.tsx builds a BriefItem from a TaskDetailActivity row and has no
task-status info to pass through. Marking the field optional matches the prop
shape on BriefCardActions and lets the activity feed compile again.

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-01 01:26:03 +08:00
Innei c2b379139d feat(followUpAction): add quick-reply chips below assistant messages (#14350)
*  feat(followUpAction): add shared types and JSON schema for follow-up chip extraction

* 🐛 fix(followUpAction): tighten JSON schema literal types with top-level as const

*  feat(followUpAction): add base + onboarding prompt builders

*  feat(followUpAction): add server service to extract chips via fast LLM

* 🐛 fix(followUpAction): drop empty chips and consolidate schemas in schema.ts

*  feat(followUpAction): expose extract via lambda TRPC router

*  feat(followUpAction): add client service wrapper around TRPC mutation

*  feat(followUpAction): add zustand store with abort/timeout actions

* 🐛 fix(followUpAction): stabilize empty selector ref and abort on reset

*  feat(followUpAction): add FollowUpChips component with reply icon style

*  feat(followUpAction): add onboarding glue hook with phase/greeting guards

*  feat(followUpAction): wire chips + glue hook into onboarding conversation

* 🐛 fix(followUpAction): drop unused eslint-disable directive in client service

* 🐛 fix(followUpAction): tighten types and align prompt with schema bounds

* 🐛 fix(followUpAction): use fresh phase for chip extraction across phase boundaries

* 🐛 fix(followUpAction): type SUGGESTION_RESPONSE_JSON_SCHEMA against GenerateObjectSchema

The earlier `as const` widened to readonly literal types, which is incompatible
with the mutable `GenerateObjectSchema` interface required by `generateObject`.
Replace with an explicit type annotation so the literal is checked at definition
and stays assignable at the call site.

* ️ perf(followUpAction): only refresh user/agent caches at onboarding phase boundaries

The previous logic refreshed both useUserStore and the webOnboarding builtin
agent after every assistant turn, but their content only changes when the
phase advances or onboarding finishes. Compare prev vs next phase/finishedAt
from syncOnboardingContext and skip the two refresh calls when neither moved,
saving an RPC per intra-phase turn.

* 🐛 fix(followUpAction): read finishedAt from agentOnboarding subobject

* ♻️ refactor(followUpAction): take agentId from caller and resolve model from agent config

Drops the env-var override path on the server. The service is meant to be
generic across consumers, so the caller now passes the agentId of the
conversation context. The service resolves model/provider from
AgentModel.getAgentConfigById, falling back to DEFAULT_SYSTEM_AGENT_CONFIG.topic
when the agent has no explicit model. The onboarding caller passes the
webOnboarding builtin agent id; future consumers pass theirs.

* 🐛 fix(followUpAction): resolve latest text assistant message server-side via topicId

*  feat(followUpAction): mirror assistant language and ban deferral chips

Two prompt rule changes:

1. Match the assistant message's language instead of forcing English. The
   chip should be in the script the user would naturally reply in.
2. Prefer questions with explicit options when the message contains
   several, and ban "Let me think / Skip / You decide / Let me explain"
   style escape-hatch chips entirely. Every chip must be a concrete
   reply the user might actually send; the user can always type
   freely, so meta deferral chips just waste a slot.

* 🐛 fix(followUpAction): bump timeout to 20s and silence TRPC-wrapped abort

The previous 3s timeout aborted the LLM call before generateObject could
respond — a typical extract round-trip is ~10s. Bump to 20s.

Also silence the TRPCClientError that wraps the abort: TRPC re-throws
DOMException as TRPCClientError("signal is aborted ..."), so the
original `instanceof DOMException` check missed it and noise
`[FollowUpAction] extract failed` warnings hit the console on every
manual clear / new turn. Now we also short-circuit on `signal.aborted`.

* feat: enhance chat input functionality with new flags

- Added `disableMention` and `disableSlash` props to `ChatInput` and `StoreUpdater` to control mention and slash command triggers.
- Introduced `disableFollowUpVariant` and `disableQueue` props to manage placeholder behavior and message queuing during agent streaming.
- Updated `FollowUpChips` to handle topic IDs and prevent rendering during message generation.
- Refactored onboarding context retrieval to streamline fetching of user persona and state.
- Removed deprecated onboarding state API references and adjusted related tests.
- Improved follow-up action handling to discard stale results based on active request controllers.

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

*  feat: enhance agent marketplace onboarding with summaries and improved state management

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

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-05-01 01:20:45 +08:00
Arvin Xu 6d1d8a0d16 💄 style(brief): use Footprints icon and hide view-run until card hover (#14347)
* 💄 style(brief): use Footprints icon and hide view-run until card hover

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

* 💄 style(brief): swap icon to Workflow for the View run shortcut

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-01 00:34:01 +08:00
Arvin Xu dc3c48e469 🐛 fix(local-system): forward all search params and guard empty mdfind (#14355)
* 🐛 fix(local-system): forward all search params and guard empty mdfind

- Pass through all resolved searchFiles params (keywords, fileTypes,
  date range, scope, etc.) instead of dropping everything except
  `directory`, which previously caused the executor to call mdfind
  with no query.
- Surface missing fields (`keywords`, `fileTypes`, `contentContains`,
  date range, sort, etc.) on `SearchFilesParams` so the cross-runtime
  type matches the actual contract.
- Short-circuit Spotlight search when there is no query expression so
  mdfind doesn't print its usage text and get parsed as phantom file
  hits, and drop unstattable rows instead of fabricating 0-byte
  placeholders.

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

* 🐛 fix(skills): guard empty command and forward description in desktop execScript

Desktop skills' execScript dropped `description` before IPC, so when an LLM tool call arrived without `command` (aborted stream, empty args, etc.) the runner crashed on `command.slice(0, 50)` and surfaced as "Failed to execute command: ...".

- runner.ts: return a proper error result when `command` is missing instead of throwing
- lobe-skills.desktop.ts: forward `options.description` to localFileService.runCommand for better logs and as a fallback when command is absent

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

* 💄 style(local-system): show empty state when file search returns no results

Previously the SearchFiles result panel rendered an empty Flexbox when there were 0 hits, leaving the area visually blank below "Number of searches: 0". Reuse the same Block + Empty pattern as web-browsing search and the existing `search.emptyResult` i18n key.

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

* 🐛 fix(local-file-shell): expand leading ~ in file operation paths

Node fs APIs don't expand `~` like a shell would, so paths supplied by
the LLM or pasted by users were failing with ENOENT. Apply expandTilde
across read/write/edit/move/rename/list/glob/grep/search and the desktop
search controller.

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

* 💄 style(local-system): show empty state when listed directory has no files

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-01 00:22:14 +08:00
AmAzing- 79dc61ac50 🐛 fix: subtask assignee refresh and rollback handling (#14353)
* Handle subtask assignee rollback refresh

* Ignore cache refresh failures after successful task update
2026-04-30 22:45:11 +08:00
AmAzing- 506bb7b29f Fix task subtitle and assignee trigger layout (#14351) 2026-04-30 19:05:51 +08:00
Innei 807af0688f 🐛 fix: type tag cloud pointer event (#14352) 2026-04-30 19:00:54 +08:00
Innei 1d9b6099bd 👷 build(repo): migrate to pnpm v11 and consolidate workspace config (#14316)
* 👷 build(repo): migrate to pnpm v11 and consolidate workspace config

Made-with: Cursor

* 👷 fix pnpm v11 install config
2026-04-30 17:56:22 +08:00
LiJian 5fc7eea754 🐛 fix: inject skill instruction into tool system role (#14342)
*  feat: inject skill instruction into tool system role

Consume the `instruction` field from market SDK's `listTools` response
and pass it as `systemRole` on the tool manifest, so the LLM receives
skill-level guidance documentation via `<tool.instructions>` in the
system prompt.

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

* feat: update market-sdk

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 17:06:28 +08:00
YuTengjing a9716975a7 💄 style: unify notification setting item copy (#14343) 2026-04-30 16:56:45 +08:00
Arvin Xu c77d201c49 💄 style(brief): open run topic drawer from daily brief card (#14340)
*  feat(brief): open run topic drawer from daily brief card

Adds a "View run" shortcut to the brief card's actions row that opens
the corresponding topic chat drawer in place on the home page, so the
user can inspect the agent's actual run without navigating to the task
detail page.

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

* 🌐 i18n(brief): refine zh-CN copy for view run action

"查看执行" was ambiguous (could read as "execute"); use "查看运行轨迹"
to make it clear the action opens the agent's actual run trace.

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-04-30 15:47:24 +08:00
Arvin Xu 39107ba107 ️ perf(agent,working-sidebar): cut Review tab open latency ~9× on large dirty trees (#14338)
* ️ perf(agent,working-sidebar): cut Review tab open latency ~9× on large dirty trees

Two changes that together drop "open Review tab" from ~1.7s to ~190ms on a
working tree with 200+ dirty files:

- GitCtr.getGitWorkingTreePatches: replace N-parallel `git diff` subprocesses
  with one bulk `git diff HEAD --` for tracked files (split per-file in JS) and
  direct `fs.readFile` synthesis for untracked. Eliminates the main-process
  fork storm and `.git/index` lock contention. IPC drops 635ms → ~160ms.
- Review/index.tsx: replace default-expand-all with a size budget
  (≤100KB cumulative patch OR 50 files). Caps Shiki tokenizer cost on first
  paint and removes the 1064ms renderer freeze; small-diff workflows still
  get 50 panels open, big-refactor workflows clamp to 2–3.

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

* 🐛 fix(agent,working-sidebar): handle special-char paths and bulk diff overflow

Address two P2 review issues on the perf refactor (#14338):

- Quote untracked paths in synthetic diff headers. Direct interpolation of
  entry.filePath into `diff --git` / `+++` lines emitted malformed headers
  for filenames containing TAB / LF / CR / quote / backslash, causing the
  patch parser to choke (e.g. TAB-containing names triggered "bad git-diff -
  inconsistent new filename"). New quoteGitPath mirrors git's own
  quote_c_style: prefix lives inside the quotes, control bytes get octal
  escapes. Plain ASCII spaces stay unquoted to match git's output.

- Replace fixed-buffer bulk diff with streamed spawn + per-file fallback.
  The 64 MB execFile maxBuffer would reject the entire bulk diff on
  overflow, leaving every tracked file as an empty placeholder. Now bulk
  output streams via spawn (no ceiling), salvages partialStdout on failure,
  and routes any uncovered tracked entry through fetchTrackedPatchPerFile
  with concurrency 8 — restoring the per-file truncation/binary handling
  the original implementation had.

Adds GitCtr.test.ts covering quote/dequote round-trips for the problem
characters the reviewer called out.

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-04-30 15:46:09 +08:00
YuTengjing d0e99aada4 🐛 fix: stop router fallback for invalid requests (#14285) 2026-04-30 15:15:25 +08:00
Arvin Xu 13e8ef9c7b 💄 style(brief): show artifacts in card and extract DocumentModal (#14339)
*  feat(brief): show artifacts in card and extract DocumentModal

Wire `brief.artifacts` (already populated by topic-brief synthesis) into
TaskBriefCard and the home BriefCard so completed-topic deliverables
show up inline; clicking a doc card opens it in a modal.

The per-task PageModal becomes a reusable `DocumentModal` (props-based:
documentId/open/onClose), and the preview trigger state moves from task
store to a new `preview` slice in document store — any surface can now
call `useDocumentStore.openDocumentPreview(id)`.

Also:
- PageAgentPanelOverrideProvider: ephemeral right-panel state for
  PageEditor in transient surfaces (modal); defaults collapsed and
  doesn't write the persisted global preference.
- PageEditor.fullWidthHeader: layout flag so the modal's header spans
  both columns instead of the left pane only.

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

* 💄 style(shared-tool-ui): unify label-to-content spacing in file inspectors

Replace trailing-space spacing with explicit 6px marginInlineEnd on the label
span in Read/Edit/Write/List inspectors so they match the 6px gap already used
by chip-based renderers (Bash, Grep, Glob).

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

* 🐛 fix(brief): clear preview state on document modal teardown

`previewDocumentId` is global (`useDocumentStore`) and the modal opens on
any truthy value. Without cleanup, navigating away with the modal open
left a stale id behind, and the next surface that mounted a preview
modal (e.g. /home daily brief) would immediately reopen the old doc.

Extract a `<DocumentPreviewModal />` connector that resets the preview
state on unmount, and use it everywhere the global preview should be
rendered (TaskDetailPage, DailyBrief). Future mount points get the
cleanup for free.

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

* 🐛 fix(brief): coerce globalExpand to boolean in panel control hook

`systemStatusSelectors.showPageAgentPanel` returns `boolean | undefined`
(zenMode short-circuit ANDs with an optional flag), but
`PageAgentPanelControl.expand` is `boolean`. Coerce with `!!` so the
non-override branch satisfies the type.

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-04-30 14:34:19 +08:00
CanYuanA 8387067807 🐛 fix: fix PDF chunking logic to prevent vectorization failure (#14327) 2026-04-30 13:55:36 +08:00
Tsuki 375e6381ce feat(mobile-router): add task and brief routers (#14337)
 feat(mobile-router): add task and brief routers to mobile tRPC router

Expose task and brief endpoints to the mobile client so the React Native
app can manage tasks and daily briefs via the same tRPC contract used by
the web client.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 13:09:38 +08:00
Arvin Xu f7c1ebf652 🚀 release: sync main branch to canary (#14317)
Automatic sync from main to canary. Merge conflicts detected.

**Resolution steps:**
```bash
git fetch origin
git checkout sync/main-to-canary-20260429-25113686179
git merge origin/main
# Resolve conflicts
git add -A && git commit
git push
```

> Do NOT merge canary into a main-based branch — always merge main INTO
the canary-based branch to keep a clean commit graph.
2026-04-30 13:08:39 +08:00
Arvin Xu 156a870cf3 🐛 fix(model-runtime): preserve LLM finishReason through callbacks transformer (#14336)
* 🐛 fix(model-runtime): preserve LLM finishReason through callbacks transformer

Soft interrupts from providers (Gemini RECITATION / MAX_TOKENS, etc.)
emit a `type: 'stop'` chunk carrying the finishReason string, but
`createCallbacksTransformer` was only using it as a terminal-event flag
and never aggregating the value. Downstream the `OnFinishData` payload
had no `finishReason` field, so RuntimeExecutors recorded an `llm_result`
event without it — the harness silently rendered an empty assistant
message even though tokens were billed.

Capture the value in the callbacks aggregator, surface it on
`OnFinishData`, and write it into the `llm_result` tracing event so
soft-interrupt cases are diagnosable.

Fixes LOBE-8403

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

* 🐛 fix(model-runtime): keep first finishReason across multi-stop streams

Anthropic emits two `'stop'` chunks per stream — `message_delta` with
the real `stop_reason` (`end_turn` / `max_tokens` / `tool_use`) followed
by a `message_stop` sentinel. Last-write-wins clobbered the meaningful
reason with the sentinel string, defeating the very tracing signal this
fix is meant to provide.

Switch to first-non-empty-wins so the real provider reason survives.
The empty-string fallback covers cases where an early provider chunk
arrives before the reason is known.

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-04-30 12:46:30 +08:00
Innei f017dcd0ea test: fix home cold route CI coverage 2026-04-30 12:40:31 +08:00
lobehubbot 719a554456 🔖 chore(release): release version v2.1.55 [skip ci] 2026-04-30 12:37:58 +08:00
Innei 3b1eef72d8 🐛 fix(chat): preserve topics across cold route sends (#14284)
**Hotfix Scope:** Topic preservation across cold chat-entry routes

> Keeps newly created Topics visible when a first message is sent before
the destination chat route has fully hydrated.

- **Page Agent empty-session regression** — Sending the first message in
an empty Page Agent panel no longer clears the newly created Topic and
returns the panel to an empty state. (Resolves LOBE-8351)
- **Home cold-route send regression** — Sending from the Home default
Chat Input now routes to the newly created Inbox Topic even when
`/agent/:aid` has never been opened and the route chunk has no warm
cache.
- **Page-scoped Copilot consistency** — Page Copilot and File Copilot
share the same provider-level topic reset behavior, so stale Topics are
cleared only when entering or switching the scoped Agent.
- **Regression coverage** — Added focused unit coverage for Home default
sends, route parity coverage remains intact, and added an E2E scenario
for the no-cache Home send path.

- `bunx vitest run --silent='passed-only'
'src/routes/(main)/home/features/InputArea/useSend.test.ts'
'src/spa/router/desktopRouter.sync.test.tsx'
'src/routes/(main)/agent/features/Conversation/ChatHydration/index.test.tsx'
'src/routes/(main)/agent/_layout/AgentIdSync.test.tsx'`
- `BASE_URL=http://localhost:3007
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres bun
run test -- --tags '@HOME-CHAT-COLD-001'` from `e2e/`

- Self-hosted: pull the new image and restart. No schema or environment
changes.
- Cloud: ships through the normal hotfix deployment after merge.

@Innei

Fixes LOBE-8351
2026-04-30 12:37:58 +08:00
lobehubbot 9e20cd6b3a 🔖 chore(release): release version v2.1.54 [skip ci] 2026-04-30 12:37:18 +08:00
LobeHub Bot a5f4b4b569 🌐 chore: translate non-English comments to English in agent-runtime examples and siliconcloud provider (#14332)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 12:14:03 +08:00
LiJian 5a15f759d6 refactor(creds): add local/desktop credential injection guidance (#14306)
*  feat(creds): add local/desktop credential injection guidance

Teach AI how to use credentials in non-sandbox (desktop/local) environments via
getPlaintextCred + runCommand inline env vars, alongside the existing sandbox flow.

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

* 🔒 fix(creds): use runCommand env param for secure credential passing

Inline secrets in the command string would be visible in the Intervention UI
and logs. Use runCommand's env parameter instead, and correct the misleading
file credential guidance (getPlaintextCred returns a fileUrl, not a local path).

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 12:07:29 +08:00
Arvin Xu b7ecf2fd4d feat(agent,working-sidebar): add Review tab with bulk git diffs (#14334)
 feat(agent,working-sidebar): add Review tab with bulk git working-tree diffs

Adds a Codex-style Review tab to the agent working sidebar (peer to the
existing Resources content, surfaced as Space). When the active topic has a
working directory bound, the sidebar shows two chip-style tabs — Space (left)
and Review (right) — and the Review pane lists every dirty file with its
unified diff rendered via PatchDiff.

A single new IPC method `git.getGitWorkingTreePatches(dirPath)` enumerates
the working tree once via `git status --porcelain -z`, then runs every
per-file `git diff` in parallel inside main; tracked entries hit
`git diff HEAD -- <file>` while pure untracked files use
`git diff --no-index /dev/null <file>`. Each patch is capped at 256 KB and
classified into added / modified / deleted with additions/deletions counts
parsed off the patch text, so the renderer needs exactly one round trip and
zero per-file fetches.

The Review pane defaults to all files expanded, with PatchDiff render gated
on the panel's expanded state so collapsed entries don't pay the shiki
highlight cost. Adds a unified/split viewMode toggle in the Review subheader,
shows an Unstaged-N chip alongside it, and ships a custom small expand caret.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 11:53:49 +08:00
Tsuki 24062bb412 💄 style(daily-brief): add skeleton loading state (#14333)
💄 style(daily-brief): add skeleton loading state for DailyBrief component

LOBE-8400

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 11:40:14 +08:00
LobeHub Bot 61d432a991 🤖 style: update i18n (#14330)
💄 style: update i18n

Co-authored-by: canisminor1990 <17870709+canisminor1990@users.noreply.github.com>
2026-04-30 10:17:41 +08:00
Arvin Xu f59954137a 💄 style(task): add start-scheduling button in automation popover (#14323)
*  feat(task): add start-scheduling button in automation popover

Lets users mark a configured task as "scheduled" without firing an
immediate run, so the cron/heartbeat tick owns the first execution.

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

* 🐛 fix(task): hide start-scheduling button in heartbeat mode

Heartbeat tasks are re-armed only by maybeRearmHeartbeat after a topic
completes — there is no dispatcher that picks up `scheduled` heartbeat
tasks, so the button would leave a paused/backlog task dormant.

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-04-30 10:03:52 +08:00
Neko 1324b67590 ♻️ refactor(server): agent signal now is easier to use (#14326) 2026-04-30 05:36:26 +08:00
Neko f390d04ef2 🐛 fix(server): prefer to use tool call id first (#14322) 2026-04-30 05:07:51 +08:00
Arvin Xu 84df8a9994 ♻️ refactor(task-brief): auto-synthesize topic briefs (#14324)
*  feat(task-lifecycle): auto-synthesize topic briefs (LOBE-8333)

Replaces agent-driven createBrief on the non-review "done" path with a
programmatic synthesis: rule-based decision + DB-collected artifacts +
a dedicated LLM for user-facing title/summary. Handoff and brief stay
separate (agent-internal vs user-facing language) and the new path is
gated behind task.config.brief.mode === 'auto' so existing tasks keep
the legacy tool-driven behavior until the GrowthBook flag flips.

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

*  feat(generate-brief): let LLM gate emission per topic content

Pure rules can only skip the obvious cases (error, judge-handled,
automation tick, trivial content). They can't tell that "I clarified
my understanding and will start drafting next" is a working note, not
a delivery. Add an `emit: boolean` to GENERATE_BRIEF_SCHEMA and have
the prompt instruct the model to judge — emit=false discards the
brief without writing to the table.

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

* ♻️ refactor(task-model): move topic-artifact query into TaskModel

DB queries belong on the model, not in a service helper. Replaces
the standalone collectTopicArtifacts() with TaskModel.getDocumentsPinnedSince(),
which lives next to pinDocument / getPinnedDocuments and returns
joined { id, kind, title } rows. synthesize.ts is now pure decision
logic — no more drizzle imports.

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-04-30 00:58:39 +08:00
YuTengjing 9aea74659f 🐛 fix: restore task agent panel toggle (#14321) 2026-04-30 00:46:28 +08:00
Arvin Xu 105321bfe1 🐛 fix(file-loaders): support UTF-16 encoded text files in TextLoader (#13615)
* 🐛 fix: support UTF-16 encoded text files in TextLoader

The TextLoader previously hardcoded UTF-8 encoding when reading files,
causing UTF-16 encoded CSVs (e.g. Google Ads Keyword Planner exports)
to be parsed with null bytes, producing garbled content and database
insert failures.

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

* ♻️ refactor(file-loaders): tighten TextLoader UTF-16 detection

- Use TextDecoder('utf-16be') instead of manual byte-swap loop, which
  also avoided in-place mutation of the read buffer.
- Replace the 2-byte heuristic with a 512-byte sample, count ASCII-pair
  shape on both halves so UTF-16BE without BOM is detected too, and
  files whose first character is non-ASCII no longer slip through.
- Add tests for UTF-8 BOM, UTF-16LE no-BOM, and UTF-16BE no-BOM.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 23:59:50 +08:00
YuTengjing b0b6e67d5f feat: support DeepSeek Anthropic runtime (#14312) 2026-04-29 23:57:18 +08:00
YuTengjing d2aa3cd1b4 🐛 fix(model-bank): reject lobehub model ids no longer in the bank (#14261) 2026-04-29 23:52:11 +08:00
AmAzing- babdc6ade5 Fix task drawer agent metadata hydration (#14315) 2026-04-29 22:55:55 +08:00
YuTengjing 7e6255096a ♻️ refactor: use virtual model id for default onboarding model (#14311) 2026-04-29 22:44:20 +08:00
Neko 0e7eda4b47 feat(agent-signal,server,prompts,builtin-tool-memory): score based orchestration, consolidate better (#14314) 2026-04-29 22:42:58 +08:00
lobehubbot 71cfba9906 🔖 chore(release): release version v2.1.55 [skip ci] 2026-04-29 14:09:35 +00:00
Innei b8fe675508 🐛 fix(chat): preserve topics across cold route sends (#14284)
**Hotfix Scope:** Topic preservation across cold chat-entry routes

> Keeps newly created Topics visible when a first message is sent before
the destination chat route has fully hydrated.

## 🐛 What's Fixed

- **Page Agent empty-session regression** — Sending the first message in
an empty Page Agent panel no longer clears the newly created Topic and
returns the panel to an empty state. (Resolves LOBE-8351)
- **Home cold-route send regression** — Sending from the Home default
Chat Input now routes to the newly created Inbox Topic even when
`/agent/:aid` has never been opened and the route chunk has no warm
cache.
- **Page-scoped Copilot consistency** — Page Copilot and File Copilot
share the same provider-level topic reset behavior, so stale Topics are
cleared only when entering or switching the scoped Agent.
- **Regression coverage** — Added focused unit coverage for Home default
sends, route parity coverage remains intact, and added an E2E scenario
for the no-cache Home send path.

##  Verification

- `bunx vitest run --silent='passed-only'
'src/routes/(main)/home/features/InputArea/useSend.test.ts'
'src/spa/router/desktopRouter.sync.test.tsx'
'src/routes/(main)/agent/features/Conversation/ChatHydration/index.test.tsx'
'src/routes/(main)/agent/_layout/AgentIdSync.test.tsx'`
- `BASE_URL=http://localhost:3007
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres bun
run test -- --tags '@HOME-CHAT-COLD-001'` from `e2e/`

## ⚙️ Upgrade

- Self-hosted: pull the new image and restart. No schema or environment
changes.
- Cloud: ships through the normal hotfix deployment after merge.

## 👥 Owner

@Innei

Fixes LOBE-8351
2026-04-29 22:06:01 +08:00
Innei 990942fb45 feat(agent-marketplace): fetch onboarding templates from market API (#14286)
*  feat(agent-marketplace): implement onboarding agent marketplace picker

Adds a new builtin tool `@lobechat/builtin-tool-agent-marketplace` that
opens a categorized agent picker UI during web onboarding. The picker
fetches the live curated catalog from the marketplace API
(`/api/v1/agents/onboarding-full`) via a TRPC procedure that injects the
trust-token, and lets the user select template agents to install.

Highlights:

- Self-contained marketplace package with manifest, system role, executor,
  and ExecutionRuntime
- React intervention component with category sidebar, skeleton loading
  state, and avatar/empty/error UI; all user-visible strings i18n-driven
- Dependency-inverted fetcher: package exports `setAgentTemplatesFetcher`,
  app registers a TRPC-backed implementation in AgentOnboardingPage
- New TRPC `market.agent.getOnboardingFull` proxies the upstream API with
  trust-token authentication; client never sees secrets
- Splits the existing `saveUserQuestion` intervention into agent identity
  and user profile cards for clearer onboarding approval UX
- Wires marketplace into `builtin-tools` registry, executor map, and
  onboarding metrics; web-onboarding agent system prompt updated to
  reference the picker

Closes LOBE-7801

*  feat(onboarding): enhance early exit handling and marketplace integration in onboarding flow

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

* 🐛 fix(agent-marketplace): register server runtime, scope picks per-topic, and harden onboarding handoff prompts

The summary phase silently skipped the marketplace handoff because the
server toolExecution registry had no runtime for `lobe-agent-marketplace`,
so every `showAgentMarketplace` call returned "not implemented" and the
agent fell through to `finishOnboarding`. The runtime-injected phase
guidance and action hints also instructed the agent to call
finishOnboarding directly after the summary, contradicting the new
system role.

- Register `agentMarketplaceRuntime` in
  `src/server/services/toolExecution/serverRuntimes` so the executor
  can actually run.
- Scope the in-memory `picks` map by `topicId` and reject a second
  `showAgentMarketplace` call in the same conversation with a clear
  "already opened, finish on next turn" message.
- Tighten the success content to instruct the model to STOP the current
  turn after opening the picker and run closing + finishOnboarding on
  the FOLLOWING user turn.
- Update `OnboardingActionHintInjector`, `PHASE_GUIDANCE.summary`,
  `toolSystemRole` and `web-onboarding/systemRole` so all four prompt
  layers agree: open the picker exactly once during summary, do not
  call finishOnboarding in the same turn, and do not call the
  submit/skip/cancel APIs ourselves.
- Stop treating short affirmations like "好的" / "行" / "ok" as
  early-exit signals; they are confirmation of the summary and should
  let the picker handoff proceed normally.

Verified end-to-end with `bun run agent-evals run onboarding/web-onboarding-v3
--case-id fe-intj-crud-v1 --model deepseek-v4-pro`: hard assertions all
pass, judge moves from 7/10 (premature finishOnboarding in same turn)
to 8/10 with picker opened once and finishOnboarding deferred to the
next turn.

* fix(ci): attempt 1 for PR #14286

Auto-generated by pr-dispatcher (task: 01KQBY8GAC1MNQCJ6T6X5DEP2F, attempt: 1).

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

* 🐛 fix(agent-marketplace): wire picker submit + fix marketplace-already-opened detection

The marketplace picker confirm flow was sending the user's selection back as a
synthetic user message, and the action hint kept telling the model to open the
marketplace again — leading to a death loop where the agent re-opened the
picker instead of summarizing + persisting + finishing onboarding.

Two issues:

1. Pick confirm forwarded the selection as a user message instead of forking
   the agents and resuming from the tool result. Wire `prepareCustomInteractionSubmit`
   into the intervention's submit branch so it runs `installMarketplaceAgents`
   client-side and returns a descriptive `toolResultContent`. Plumb a
   `createUserMessage: false` + `toolResultContent` option through
   `submitToolInteraction` (slice + chat store): when set, skip the synthetic
   user message, override the tool message content, and resume runtime from the
   tool message (`parentMessageType: 'tool'`) so the LLM sees the install
   result and continues from there.

2. `OnboardingActionHintInjector.marketplaceAlreadyOpened` read `msg.tool_calls`,
   but this provider runs in pipeline phase 4.5 (virtual tail guidance) BEFORE
   `ToolCallProcessor` (phase 5) converts DB-shape `tools` → OpenAI-shape
   `tool_calls`. Detection always returned false → the hint kept saying
   "call showAgentMarketplace" → death loop. Fix: match on `tools[].apiName`
   (with `tool_calls` kept as a fallback). Also rewrote the Summary-phase hints
   to reflect the new flow (picker resolves directly via tool result, no
   synthetic user reply needed).

Includes intervention bar portal-target plumbing for approval actions.

*  feat(onboarding): wire marketplace picker analytics on agent onboarding page

Mount AnalyticsBridge under AgentOnboardingPage to inject useAnalytics() into
setOnboardingAnalyticsClient, so onboarding_marketplace_shown/picked events
emit through PostHog instead of being silently dropped. Adds spm fields to
align with onboardingFeedback's telemetry shape.

* ♻️ refactor: move DEFAULT_ONBOARDING_MODEL to business-const

Made-with: Cursor

*  test(customInteractionHandlers): add tests for persisting marketplace picks and resolutions

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

*  feat(onboarding): enhance agent marketplace integration with metadata persistence

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

*  feat(agent): add web onboarding agent selectors and integrate into Actions and Usage components

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

---------

Signed-off-by: Innei <tukon479@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-29 21:25:16 +08:00
Innei ecec2e87e3 🐛 fix: use fileId for proxy URL in knowledge queries (#14051)
KnowledgeRepo queries use COALESCE(d.id, f.id) as id, which returns the
document's `docs_xxx` ID when a document exists for the file. Using this
as the proxy URL path (`/f/docs_xxx`) fails because the file proxy route
looks up the `files` table by `file_xxx` ID.

Fix: use `item.fileId` (always the actual file ID) for proxy URLs in
`getKnowledgeItems` and `recentFiles` handlers.

Closes #12196
2026-04-29 20:02:23 +08:00
Innei 7b6978271a feat(chat): support local file mention snapshots (#14278)
*  support local file mention snapshots

*  feat(local-file-mention): implement useLocalFileMention hook for local file search functionality

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

* 🐛 fix desktop project file index fallback

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-04-29 19:39:31 +08:00
YuTengjing 28c2e9002a 🔥 chore: drop useSkillConnection hook (moved into cloud feature) (#14308) 2026-04-29 19:32:15 +08:00
Rdmclin2 b9034ce9c1 🐛 fix: e2e page related tests (#14309)
* chore: add default home locales

* fix: e2e tests

* fix: LobeAI locales

* fix: Lobe AI locales

* fix: test case errors

* chore: update i18n files
2026-04-29 17:49:56 +07:00
Rdmclin2 2eb7ee824f feat: support Line (#14207)
* feat: support Line

* chore: update Line docs

* feat: support line platform

* chore: update markdown files

* fix: lint error

* fix: home padding block
2026-04-29 15:37:27 +07:00
YuTengjing e78949cd23 🐛 fix: reset task agent transient state (#14303) 2026-04-29 16:37:13 +08:00
Arvin Xu afae236628 🐛 fix(task): manual run no longer eats the next scheduled tick (#14304)
Daily/weekly schedules dedup'd by calendar day, so a manual "run now"
earlier in the day would advance lastHeartbeatAt and make the dispatcher
skip today's scheduled tick. Dedup now compares against today's target
H:M instead — a 21:00 schedule still fires after a 18:00 manual run,
while post-target runs and same-tick re-dispatch are still skipped.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 16:21:09 +08:00
Neko 8830c6d560 ♻️ refactor(server): prevent writing useless metadata into documents for agent signal managed skills (#14291) 2026-04-29 15:57:03 +08:00
Arvin Xu f42fc7d65d 🐛 fix: include all properties in task_topic_handoff response_format required (#14297)
Azure / OpenAI strict structured outputs require every key in `properties`
to appear in `required`; the schema only listed `title` and `summary`,
so every generateHandoff call returned 400 "Missing 'keyFindings'".

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:54:29 +08:00
Neko e5e154afcb ♻️ refactor(agent-signal): simplify structure of agent-signal (#14290) 2026-04-29 15:45:21 +08:00
Rdmclin2 346812ab88 🔨 chore: update i18n files & fix title skeleton (#14301)
* chore: update i18n files & fix title skeleton

* chore: update taskTemplate json

* chore: update i18n files
2026-04-29 13:23:26 +07:00
YuTengjing a099749b41 ♻️ refactor(taskTemplate): use string icon identifiers (#14302) 2026-04-29 13:54:41 +08:00
Arvin Xu fbe8ab3891 ♻️ refactor(context-engine): drop ____builtin suffix from tool names (#14289)
♻️ refactor(context-engine): drop ____builtin suffix from tool names

Builtin tools now generate two-segment names like documents____upsertDocumentByFilename instead of documents____upsertDocumentByFilename____builtin. The "default" plugin type was already suffix-less, and "default" is no longer in active use, so collapsing builtin into the same shape removes redundant LLM-facing tokens. resolve() falls back to type 'builtin' for two-segment names and still parses legacy three-segment ____builtin names from message history.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 11:25:24 +08:00
Arvin Xu 2965cbc83a docs(lobehub-skill): add video/image model lookup guide to generate & model references (#14264)
* docs(lobehub-skill): add video/image model lookup guide to generate reference

* docs(lobehub-skill): add full model type list and default-type warning to model reference

* docs(lobehub-skill): fix incorrect tip about lh model list default behavior

* 🐛 fix(builtin-skills): close template literal in model reference

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-04-29 11:04:04 +08:00
AmAzing- fc44aaef38 Persist model detail panel expansion state (#14294) 2026-04-29 10:35:13 +08:00
Arvin Xu a2b8f4c81a 🐛 fix: consolidate agent-documents tools and fix empty readDocument (#14288) 2026-04-29 09:33:48 +08:00
Arvin Xu 6f9f5643d1 feat: polish task list & detail, expose topic operation ID (#14282)
* ♻️ refactor: remove schedule config popup from task list item

The task list row should only display the schedule trigger tag, not act
as an entry point for editing the automation. Configuration stays
available on the task detail page via TaskProperties.

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

* 💄 style: mute BriefIcon when task is resolved

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

* 💄 style: flatten task markdown card, drop container background and padding

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

*  feat: expose task topic operationId and add copy menu item

Surfaces the persisted `task_topics.operationId` through the task detail API
so the topic card menu can offer a "Copy operation ID" entry alongside
"Copy topic ID", aiding debugging of completed runs.

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

* 🐛 fix: skip empty text block when Claude Code prompt is image-only

Anthropic rejects `{ text: '', type: 'text' }` with "messages: text content blocks must be non-empty", so uploading an image with no text would 400.

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

*  feat: add topic actions menu and share button to task topic drawer

- Add "..." dropdown next to title with Copy topic ID / Copy operation ID
- Add Share icon next to close button, reusing SharePopover and ShareModal
- Pass topicId through SharePopover so it works outside the chat store scope
- Use getContainer={false} on Drawer to escape App's isolation stacking context, letting popups render above the drawer

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-04-29 02:40:38 +08:00
yueyinqiu e4877436fe uncomment image / video / text2music in modelTypeOptions (Form.tsx) (#14275) 2026-04-29 02:32:16 +08:00
Zhijie He 04775f66ff 💄 style: migrate Hunyuan to TokenHub for Hy3 Preview (#14108)
Co-authored-by: YuTengjing <ytj2713151713@gmail.com>
2026-04-29 02:31:21 +08:00
Neko 9fff5fccf0 feat(app,server,agent-signal,cli): new policy for Skill management running inside of Agent Signal (#14281) 2026-04-29 02:18:57 +08:00
Rdmclin2 5a46c5a971 feat: refactor home (#14266)
* feat: refactor home

* feat: add home agent id switch

* fix: useSend ensure agent map init

* feat: add custom image/video generation menu item

* chore: remove agent list ,group list and modetag

* fix: default home agent fallback

* fix: built in agent builder creation

* feat: add deepseek pro v4 hot picks

* chore: support agent select scrolling

* feat: add bot integration banner

* fix: lint error

* chore: update home page styles

* chore: adjust padding

* test: add image item to sidebar items test fixtures

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

* test: remove obsolete home starter e2e tests

The mode-tag buttons (Create Agent / Create Group / Write) no longer
exist after the Home refactor, so these scenarios cannot run.

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-04-29 00:57:22 +07:00
YuTengjing 5722b7159b feat: add task manager copilot (#14272) 2026-04-29 01:21:40 +08:00
lobehubbot 682657ba50 🔖 chore(release): release version v2.1.54 [skip ci] 2026-04-27 15:41:37 +00:00
2131 changed files with 172496 additions and 22258 deletions
-298
View File
@@ -1,298 +0,0 @@
---
name: bot
description: 'Bot platform architecture (Discord, Slack, Telegram, Feishu/Lark, QQ, WeChat). Use when working on inbound webhooks, Chat SDK message routing, agent execution from chat platforms, queue-mode callbacks, gateway lifecycle (websocket/polling), bot provider CRUD/credentials, or platform-specific clients/adapters/schemas. Triggers on bot, channel, webhook, mention, Chat SDK, agent bot provider, gateway, bot-callback, qstash bot.'
---
# Bot System
> **Last updated: 2026-04-08.** Implementation evolves quickly — this doc is a map, not the source of truth. Always read the key files below to verify behavior, especially per-platform quirks. Update this doc when the architecture changes.
LobeChat agents can answer inside external chat platforms. Inbound messages flow through the Chat SDK (`chat` npm package), get routed to the right agent by `(platform, applicationId)`, executed via `AiAgentService`, and replied back through a per-platform `PlatformClient`. There are **two execution modes** (in-memory vs queue/QStash) and **three connection modes** (`webhook`, `websocket`, `polling`).
## Supported Platforms
| Platform | id | Default mode | Markdown | Edit | Notes |
| -------- | ---------- | ------------------------------- | ----------------- | ------ | -------------------------------------------------------------------------------------- |
| Discord | `discord` | `websocket` | yes | yes | Persistent gateway via Chat SDK adapter; reaction-thread quirks; native slash commands |
| Slack | `slack` | `websocket` (Socket Mode) | yes (mrkdwn) | yes | Multi-mode — user can pick `webhook` per provider |
| Telegram | `telegram` | `webhook` | yes (HTML) | yes | `setMyCommands` menu via `registerBotCommands` |
| Feishu | `feishu` | `websocket` (Lark SDK WSClient) | **no** (stripped) | yes | Multi-mode; shared client with Lark |
| Lark | `lark` | `websocket` | **no** | yes | Same client/schema as Feishu, different domain |
| QQ | `qq` | `websocket` | **no** | **no** | All replies are final-only |
| WeChat | `wechat` | `polling` (iLink long-poll) | **no** | **no** | 10-minute gateway window |
`supportsMarkdown=false` ⇒ outbound markdown is stripped to plain text via `stripMarkdown` and the AI is told not to use markdown. `supportsMessageEdit=false` ⇒ no progress edits — only the final reply is sent.
**Multi-mode connection** — Slack/Feishu/Lark/QQ ship as websocket but support `webhook` per-provider via `settings.connectionMode`. The runtime always merges schema defaults into stored settings before resolving the mode (`resolveBotProviderConfig` / `resolveConnectionMode` in `platforms/utils.ts`), so the schema's `field.default` is the source of truth — set it correctly when adding a new multi-mode platform.
## Inbound Flow (one webhook → reply)
```
Platform server
│ POST /api/agent/webhooks/[platform]/[appId]
route.ts ── catch-all `[[...appId]]` route
BotMessageRouter (singleton)
│ • lazy-loads bot per `platform:applicationId`
│ • merges schema defaults + provider.settings (mergeWithDefaults)
│ • builds Chat SDK Chat<any> with createIoRedisState (if Redis available)
│ • registerHandlers: onNewMention / onSubscribedMessage / onNewMessage(/.dm)
│ • registerCommands: /new (reset topic), /stop (interrupt)
chatBot.webhooks[platform](req) ← Chat SDK parses → fires events
AgentBridgeService.handleMention / handleSubscribedMessage
│ • activeThreads guard (no duplicate runs per thread)
│ • adds 👀 reaction (eyes), startTyping
│ • merges debounced/queued skipped messages (mergeSkippedMessages)
│ • extractFiles (buffer → fetchData → url)
│ • formatPrompt (sanitize mention + speaker tag + referenced_message)
├── In-memory mode ──► AiAgentService.execAgent({ stepCallbacks })
│ → onAfterStep edits progress message live
│ → onComplete edits final reply, splits via splitMessage(charLimit)
└── Queue mode (isQueueAgentRuntimeEnabled) ──► execAgent({ stepWebhook, completionWebhook, webhookDelivery: 'qstash' })
→ returns immediately, callbacks land at /api/agent/webhooks/bot-callback
```
The router caches loaded bots in memory. Cache is **invalidated** by `BotMessageRouter.invalidateBot(platform, appId)` whenever the TRPC `update`/`delete` mutations run, so new credentials/settings take effect on the next webhook.
## Execution Modes
### In-memory (default)
`AgentBridgeService.executeWithInMemoryCallbacks` wraps `execAgent` with `stepCallbacks`. Lives in one process — Promise-based wait, 30-min timeout, edits the same `progressMessage` after every step. Topic title is summarized inline via `SystemAgentService`.
### Queue (`isQueueAgentRuntimeEnabled`)
`AgentBridgeService.executeWithWebhooks`:
1. Posts the `renderStart` placeholder, captures `progressMessageId`.
2. Calls `execAgent` with `stepWebhook` and `completionWebhook` pointing at `${INTERNAL_APP_URL ?? APP_URL}/api/agent/webhooks/bot-callback`, plus `webhookDelivery: 'qstash'`.
3. Returns immediately; the bridge `finally` block keeps the active-thread marker held until the `completion` callback fires.
`/api/agent/webhooks/bot-callback/route.ts` verifies the QStash signature and hands off to `BotCallbackService.handleCallback`:
- `type: 'step'``handleStep` re-renders `renderStepProgress`, edits `progressMessageId` (skipped if `displayToolCalls=false` or platform `supportsMessageEdit=false`).
- `type: 'completion'``handleCompletion` writes the final reply (or error/interrupted message), removes the 👀 reaction, clears active-thread tracker, fires async `summarizeTopicTitle`.
`BotCallbackService.createMessenger` reloads provider + credentials from DB and rebuilds a `PlatformClient` per call (no in-memory state).
## Commands
Defined in `BotMessageRouter.buildCommands` and registered via two paths:
- **Native slash commands** (Slack/Discord): `bot.onSlashCommand('/<name>', ...)`
- **Text-based fallback** (Telegram/Feishu/QQ/Lark/WeChat): `bot.onNewMessage(/^\/(new|stop)(\s|$|@)/, ...)` plus a per-mention `tryDispatch` so commands work even before subscribe.
Built-in commands:
- `/new` — clears `topicId` in thread state, next message starts a fresh topic.
- `/stop` — interrupts the active execution (calls `AiAgentService.interruptTask` if `operationId` is known; otherwise queues a deferred stop via `requestStop`/`pendingStopThreads`, also aborts the startup phase via `startupControllers`).
To add a command, append to `buildCommands` — it auto-registers everywhere; on Telegram it also surfaces in the `/` menu via `client.registerBotCommands``setMyCommands`.
## Active-thread State (statics on `AgentBridgeService`)
- `activeThreads: Set<threadId>` — prevents duplicate runs per thread (must guard before stale-topic check, otherwise concurrent messages can drop).
- `activeOperations: Map<threadId, operationId>` — needed by `/stop` once `execAgent` returns.
- `startupControllers: Map<threadId, AbortController>` — cancels pre-`operationId` work (topic/tool prep).
- `pendingStopThreads: Set<threadId>``/stop` arrived before `operationId` existed; consumed once available.
In **queue mode**, the bridge `finally` skips cleanup so the marker persists until `BotCallbackService.handleCompletion` calls `clearActiveThread`.
## Topic Lifecycle in Threads
- `handleMention` always treats the message as the start of a new conversation.
- `handleSubscribedMessage` reads `topicId` from `thread.state`. If the topic is stale (`> 4 hours` since `updatedAt`), state is cleared and it retries as a fresh mention.
- If `execAgent` fails with a Postgres FK violation on `topic_id` (cached topic was deleted), the bridge clears state and retries as a mention.
- `subscribe()` is gated by `client.shouldSubscribe(threadId)` — Discord top-level channels return `false` so we don't follow up there.
## Attachments
`AgentBridgeService.extractFiles` resolves attachments in priority order:
1. `att.buffer` — already downloaded by the adapter (WeChat/Feishu inbound).
2. `att.fetchData()` — adapter-provided lazy download with auth (Telegram, Slack, Feishu history). **Required** when URLs are token-protected — naive `fetch(url)` later in `ingestAttachment.ts` has no credentials.
3. `att.url` — public CDN fallback (Discord, public QQ).
`inferMimeType` / `inferName` patch Telegram-style `photo` payloads (no `mimeType`/`name` from Bot API → defaults to `image/jpeg`) so vision models actually see them. Quoted-message attachments are also pulled from `raw.referenced_message.attachments` (Discord).
## Concurrency
`settings.concurrency` is `'queue'` or `'debounce'`:
- `debounce` → Chat SDK debounces inbound messages by `debounceMs`; `mergeSkippedMessages` joins skipped texts/attachments into the current message before handing to the agent.
- `queue` → Chat SDK serializes per-thread; the bridge's own `activeThreads` set is still required because in queue mode the SDK lock releases before the agent finishes.
## Gateway (persistent platforms)
Webhook platforms run fine in serverless functions. Persistent platforms (`websocket`, `polling`) need a long-running listener — that's the **gateway**.
**`GatewayService.startClient(platform, appId, userId)`** (`src/server/services/gateway/index.ts`):
- On Vercel + persistent mode → `BotConnectQueue.push` (Redis hash) and mark runtime status `queued`. The cron picks it up.
- On Vercel + webhook mode → start the client inline (one HTTP call).
- Off-Vercel → `GatewayManager` singleton holds long-lived clients in process.
**`GET /api/agent/gateway/route.ts`** (cron, `Bearer ${CRON_SECRET}`):
- Iterates registered platforms and starts every enabled persistent provider with `durationMs = 10min`, then in `after(...)` polls `BotConnectQueue` every 30s for new connect requests, until the window expires.
- `getEffectiveConnectionMode(platform, settings)` is the only place that resolves per-provider mode — respect it everywhere.
**`POST /api/agent/gateway/start/route.ts`** is the non-Vercel `ensureRunning` entry point (`Bearer ${KEY_VAULTS_SECRET}`).
**Runtime status** is stored in Redis at `bot:runtime-status:platform:appId` with TTL ≈ `durationMs + 60s`. States: `starting | connected | disconnected | failed | queued`. Updated by each `PlatformClient.start/stop` and by the gateway service.
## Platform Definitions
Each platform exposes a `PlatformDefinition` registered in `platforms/index.ts`:
```ts
{
id: 'discord',
name: 'Discord',
connectionMode: 'websocket', // recommended default
schema: FieldSchema[], // applicationId + credentials + settings
clientFactory: new DiscordClientFactory(),
supportsMarkdown?: boolean, // default true
supportsMessageEdit?: boolean, // default true
documentation?: { portalUrl, setupGuideUrl },
}
```
`schema` drives both server validation (`mergeWithDefaults`, `extractDefaults`) **and** the auto-generated UI form. Top-level keys `applicationId` / `credentials` / `settings` map to DB columns. Common settings fields live in `platforms/const.ts` (`displayToolCallsField`, `makeServerIdField(platform?)`, `makeUserIdField(platform?)`). The `serverId` / `userId` factories take a platform identifier so the field's hint can render platform-specific "how to find this ID" guidance (Discord Developer Mode, Telegram @userinfobot, etc.); pass no argument to fall back to generic copy.
Each platform implements `PlatformClient` (see `platforms/types.ts`):
- Lifecycle: `start(opts?)`, `stop()`
- Inbound: `createAdapter()` → Chat SDK adapter map
- Outbound: `getMessenger(platformThreadId)``{ createMessage, editMessage, removeReaction, triggerTyping, updateThreadName? }`
- Formatting: `formatMarkdown?`, `formatReply?` (usage-stats footer when `showUsageStats`)
- Helpers: `extractChatId`, `parseMessageId`, `sanitizeUserInput`, `shouldSubscribe`, `resolveReactionThreadId`
- Optional patches: `applyChatPatches(chatBot)` (Discord uses this for `forwardedInteractions` + `threadRecovery`)
- Optional menu: `registerBotCommands(commands)` (Telegram `setMyCommands`)
`ClientFactory.validateCredentials` is called from the TRPC `testConnection` mutation — implement it to hit the platform API and return useful per-field errors.
## Database
**Schema** (`packages/database/src/schemas/agentBotProvider.ts`):
```ts
agent_bot_providers (
id uuid pk,
agent_id text fk agents.id (cascade),
user_id text fk users.id (cascade),
platform varchar(50), // 'discord' | 'slack' | …
application_id varchar(255),
credentials text, // KeyVaults-encrypted JSON
settings jsonb default '{}',
enabled boolean default true,
timestamps
)
unique (platform, application_id)
```
**Model** (`packages/database/src/models/agentBotProvider.ts`):
- User-scoped: `create / update / delete / query / findById / findByAgentId / findEnabledByApplicationId`. Credentials are encrypted/decrypted via the injected `KeyVaultsGateKeeper`.
- Static (system-wide): `findByPlatformAndAppId`, `findEnabledByPlatform` — used by webhook routing & gateway sync, since they don't have a user context yet.
**TRPC router** (`src/server/routers/lambda/agentBotProvider.ts`):
| Procedure | Notes | |
| -------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------ |
| `listPlatforms` | Returns `SerializedPlatformDefinition[]` (no `clientFactory`) | |
| `create` / `update` / `delete` | Calls `BotMessageRouter.invalidateBot` + `GatewayService.stopClient` so changes take effect | |
| `list` / `getByAgentId` / `getRuntimeStatus` | Decorate rows with Redis runtime status | |
| `connectBot` | Returns \`{ status: 'started' | 'queued' }\` |
| `testConnection` | Calls `clientFactory.validateCredentials` | |
| `wechatGetQrCode` / `wechatPollQrStatus` | iLink onboarding flow | |
Client service: `src/services/agentBotProvider.ts`. Store actions: `src/store/agent/slices/bot/action.ts`. UI: `src/routes/(main)/agent/channel/{list,detail}` — settings form is auto-generated from each platform's `schema`.
## Reply Templates
`src/server/services/bot/replyTemplate.ts` exports `renderStart`, `renderStepProgress`, `renderFinalReply`, `renderError`, `renderStopped`, `splitMessage`. Step progress carries elapsed time, last LLM content, last tools, totals; final reply uses `client.formatMarkdown` then `client.formatReply` (which optionally appends `formatUsageStats`). `splitMessage(text, charLimit)` chunks at paragraph → line → hard cut.
`src/server/services/bot/ackPhrases/` provides randomized ack phrases.
## Key Files
```plaintext
Webhook routes:
src/app/(backend)/api/agent/webhooks/[platform]/[[...appId]]/route.ts — inbound catch-all
src/app/(backend)/api/agent/webhooks/bot-callback/route.ts — qstash bot callback
src/app/(backend)/api/agent/gateway/route.ts — cron gateway (10min window)
src/app/(backend)/api/agent/gateway/start/route.ts — non-Vercel ensureRunning
Bot service:
src/server/services/bot/index.ts — barrel
src/server/services/bot/BotMessageRouter.ts — lazy bot loading + handler registration + commands
src/server/services/bot/AgentBridgeService.ts — Chat SDK ↔ AiAgentService bridge, both exec modes
src/server/services/bot/BotCallbackService.ts — qstash callback handler
src/server/services/bot/formatPrompt.ts — speaker tag + referenced_message + sanitize
src/server/services/bot/replyTemplate.ts — render*/splitMessage
src/server/services/bot/ackPhrases/ — randomized acks
src/server/services/bot/__tests__/ — unit tests for the above
Platform abstraction:
src/server/services/bot/platforms/index.ts — registry singleton + exports
src/server/services/bot/platforms/types.ts — PlatformClient/Definition/FieldSchema/ClientFactory
src/server/services/bot/platforms/registry.ts — PlatformRegistry class
src/server/services/bot/platforms/utils.ts — mergeWithDefaults, getEffectiveConnectionMode, formatUsageStats, runtimeKey
src/server/services/bot/platforms/const.ts — shared FieldSchema fragments (displayToolCalls, serverId, userId)
src/server/services/bot/platforms/stripMarkdown.ts — used by no-markdown platforms
Per-platform (each ships definition.ts, schema.ts, client.ts, const.ts, protocol-spec.md):
src/server/services/bot/platforms/discord/ — websocket gateway + chat patches
src/server/services/bot/platforms/slack/ — multi-mode (Socket Mode / webhook), markdownToMrkdwn
src/server/services/bot/platforms/telegram/ — webhook, markdownToHTML, registerBotCommands
src/server/services/bot/platforms/feishu/ — feishu + lark share client/schema (definitions/{feishu,lark,shared}.ts)
src/server/services/bot/platforms/qq/ — websocket, no markdown, no edit
src/server/services/bot/platforms/wechat/ — long-poll, no markdown, no edit
Gateway:
src/server/services/gateway/index.ts — GatewayService (Vercel-aware startClient/stopClient)
src/server/services/gateway/GatewayManager.ts — long-running client registry (non-Vercel)
src/server/services/gateway/botConnectQueue.ts — Redis hash queue with TTL
src/server/services/gateway/runtimeStatus.ts — Redis bot:runtime-status keys
Database:
packages/database/src/schemas/agentBotProvider.ts — agent_bot_providers table
packages/database/src/models/agentBotProvider.ts — encrypted CRUD + system-wide finders
TRPC + client:
src/server/routers/lambda/agentBotProvider.ts — TRPC router
src/services/agentBotProvider.ts — client wrapper
src/store/agent/slices/bot/action.ts — Zustand actions
UI:
src/routes/(main)/agent/channel/list.tsx — channel list
src/routes/(main)/agent/channel/detail/ — auto-generated form (Header/Body/Footer)
src/routes/(main)/agent/channel/const.ts — platform icons
Types & runtime status:
src/types/botRuntimeStatus.ts — BOT_RUNTIME_STATUSES enum + snapshot type
```
## Adding a New Platform
1. Create `src/server/services/bot/platforms/<id>/`:
- `definition.ts``PlatformDefinition` registered in `platforms/index.ts`
- `schema.ts``FieldSchema[]` (`applicationId` + `credentials` + `settings`); reuse fragments from `../const.ts`
- `client.ts``class XClientFactory extends ClientFactory` returning a `PlatformClient` (lifecycle + adapter + messenger + helpers)
- `const.ts``DEFAULT_X_CONNECTION_MODE`, history limits, etc.
- `protocol-spec.md` — protocol notes (every existing platform has one)
2. Pick the right `connectionMode` — webhook is much simpler if the platform supports it.
3. If the platform can't render markdown, set `supportsMarkdown: false` and implement `formatMarkdown` via `stripMarkdown`.
4. If it can't edit messages, set `supportsMessageEdit: false``BotCallbackService` will skip step edits and only send the final reply.
5. Implement `validateCredentials` so the UI's "Test connection" button gives useful errors.
6. Add the platform icon in `src/routes/(main)/agent/channel/const.ts` and register the platform in `src/server/services/bot/platforms/index.ts`.
7. Add i18n keys under `channel.*` in `src/locales/default/setting.ts` (or wherever the channel namespace lives) — the schema's `label`/`description`/`placeholder`/`enumLabels` are i18n keys.
+130
View File
@@ -0,0 +1,130 @@
---
name: builtin-tool
description: Build a new builtin tool package under `packages/builtin-tool-<name>/`. Use when adding a new agent-callable toolset, designing its API surface (manifest / ApiName / Params / State), implementing the Executor + ExecutionRuntime, building the Inspector / Render / Placeholder / Streaming / Intervention / Portal UI, or wiring a tool into the central registries (`packages/builtin-tools/src/{index,identifiers,inspectors,renders,placeholders,streamings,interventions,portals}.ts` and `src/store/tool/slices/builtin/executors/index.ts`). Triggers on "new builtin tool", "add a tool", "tool inspector", "tool render", "tool placeholder", "tool streaming", "tool intervention", "BuiltinToolManifest", "BaseExecutor", "ExecutionRuntime".
---
# Builtin Tool Authoring Guide
A builtin tool is a package the agent runtime can call. It ships **five faces**:
| Face | Lives in | Audience |
| -------------------- | -------------------------------------------------------------------------------------- | ------------------------------------- |
| **Manifest + types** | `src/{manifest,types,systemRole}.ts` | The LLM (tool spec + system prompt) |
| **ExecutionRuntime** | `src/ExecutionRuntime/` | Server / desktop / any runtime caller |
| **Executor** | `src/client/executor/` | Frontend (wraps stores/services) |
| **Client UI** | `src/client/{Inspector,Render,…}/` | Chat UI |
| **Registry wiring** | `packages/builtin-tools/src/*.ts` + `src/store/tool/slices/builtin/executors/index.ts` | Framework |
---
## Read These First
| Question | Doc |
| ------------------------------------------------------------------------------------ | ---------------------------------- |
| Where do files live? What does each face do? Wiring? | [architecture.md](architecture.md) |
| How do I name the tool, design APIs, write the manifest, executor, ExecutionRuntime? | [tool-design.md](tool-design.md) |
| How do I build Inspector / Render / Placeholder / Streaming / Intervention / Portal? | [ui.md](ui.md) |
---
## When to Use This Skill
- Creating a new `packages/builtin-tool-<name>/` package
- Adding a new API method to an existing builtin tool
- Building or restyling any of the 6 client surfaces for a tool
- Wiring a tool into the central registries
- Debugging "tool not found / API not found / render not showing / placeholder stuck" errors
---
## Top-Level Design Principles
1. **`lobe-<domain>` identifier is permanent.** It's stored in message history. Renames need `@deprecated` aliases (see `packages/builtin-tools/src/inspectors.ts:88-89`). Get it right the first time.
2. **ApiName is an `as const` object**, not a TS enum. It doubles as the runtime list `BaseExecutor` iterates over.
3. **Three result fields, three audiences:**
- `content: string` → the LLM reads it
- `state: Record<…>` → the UI's `pluginState`; **result-domain only**, never echo all params back
- `error: { type, message, body? }` → both LLM and UI; `type` is a stable code
4. **Split execution from frontend wiring.**
- `src/ExecutionRuntime/` — pure runtime, no React, no Zustand, accepts services via constructor. **The default place for new logic.**
- `src/client/executor/``BaseExecutor` subclass that calls `ExecutionRuntime` (or stores/services directly when frontend-only).
5. **UI defaults to "do nothing".** Inspector is required (the header strip). Render/Placeholder/Streaming/Intervention/Portal are added **only when there's something specific to show** — empty registries are fine.
6. **Style with `createStaticStyles + cssVar.*`** (zero-runtime). Fall back to `createStyles + token` only when you genuinely need runtime values. Use `@lobehub/ui` components, not raw antd.
7. **i18n keys live in `src/locales/default/plugin.ts`.** Inspector titles must come from `t('builtins.<identifier>.apiName.<api>')` so something renders while args stream.
---
## Package Layout (preferred, post-2026 convention)
```
packages/builtin-tool-<name>/
├── package.json
└── src/
├── index.ts # exports manifest + types + systemRole + Identifier (no React, no stores)
├── manifest.ts # BuiltinToolManifest with JSON Schema for every API
├── types.ts # ApiName const + Params/State interfaces per API
├── systemRole.ts # System prompt teaching the model when/how to use the APIs
├── ExecutionRuntime/ # ✅ Default home for runtime logic (server- or anywhere-callable)
│ └── index.ts
└── client/
├── index.ts # Re-exports for the registries
├── executor/ # ✅ Frontend executor — extends BaseExecutor, often delegates to ExecutionRuntime
│ └── index.ts
├── Inspector/ # required — header chip per API
├── Render/ # optional — rich result card
├── Placeholder/ # optional — skeleton during streaming/execution
├── Streaming/ # optional — live output renderer (e.g. RunCommand, WriteFile)
├── Intervention/ # optional — approval / edit-before-run UI
├── Portal/ # optional — full-screen detail view
└── components/ # shared subcomponents used by the surfaces above
```
**Older packages** (`builtin-tool-task`, `builtin-tool-calculator`, etc.) still have `src/executor/` as a sibling of `src/client/`. That's grandfathered; **don't relocate without a deliberate refactor**. New packages and new APIs added to existing packages should follow the layout above.
`package.json` exports map:
```json
"exports": {
".": "./src/index.ts",
"./client": "./src/client/index.ts",
"./executor": "./src/client/executor/index.ts",
"./executionRuntime": "./src/ExecutionRuntime/index.ts"
}
```
---
## Authoring Checklist
Before opening the PR:
- [ ] Identifier follows `lobe-<domain>` and is **stable** (lives in message history).
- [ ] Every `<Name>ApiName` value has: a manifest `api[]` entry, an executor method, an Inspector, an i18n `apiName.*` key.
- [ ] `Params` interfaces match the JSON Schema; `State` interfaces match what the executor returns and what the UI surfaces read.
- [ ] System prompt disambiguates confusable APIs and points to batch variants.
- [ ] Runtime logic lives in `ExecutionRuntime/`; the `client/executor/` only wires stores/services and delegates.
- [ ] Executor returns `{ success, content, state, error? }` via a single `toResult()` funnel — `content` always non-empty (default to `error.message`).
- [ ] Inspector handles `isArgumentsStreaming`, `isLoading`, `partialArgs`, missing `pluginState`.
- [ ] Render returns `null` until it has data; only created for APIs with rich results.
- [ ] Placeholder added if the API has a perceivable execution lag (search, list, crawl).
- [ ] Streaming added for APIs that emit incremental output (run command, write file, code execution).
- [ ] Intervention added if `humanIntervention` is set in the manifest.
- [ ] All registry files updated (see [architecture.md → Registry wiring](architecture.md#registry-wiring)).
- [ ] i18n keys in `src/locales/default/plugin.ts` plus dev seeds in `en-US`/`zh-CN`.
- [ ] `bunx vitest run --silent='passed-only' 'packages/builtin-tool-<name>'` passes.
- [ ] `bun run type-check` passes.
---
## Reference Tools
Pick the closest neighbor and copy:
| If your tool is… | Read first |
| ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| Pure-compute, no UI state | `packages/builtin-tool-calculator/``ExecutionRuntime` reuses executor (mathjs/nerdamer work everywhere) |
| CRUD over a domain entity | `packages/builtin-tool-task/` — full Inspector + Render set, batch variants |
| Heavy UI (Inspector/Render/Placeholder/Portal) | `packages/builtin-tool-web-browsing/` — search-style result UI, Portal for detail view |
| Desktop / filesystem with all surfaces (incl. Streaming + Intervention) | `packages/builtin-tool-local-system/``ExecutionRuntime` injects an `ILocalSystemService`, executor calls it |
| Server-side pure (no client executor) | `packages/builtin-tool-web-browsing/` — only `ExecutionRuntime` is exported; the chat client doesn't run it |
| Needs human approval before running | `packages/builtin-tool-local-system/src/client/Intervention/` — per-API approval components |
+315
View File
@@ -0,0 +1,315 @@
# Builtin Tool Architecture
## The Five Faces
A builtin tool ships five distinct faces, each compiled into a different bundle:
```
┌─────────────────────────────────────────────────────────────────┐
│ ./ │
│ Manifest + Types + systemRole │
│ ─ Pure data, no React, no Node-only deps. │
│ ─ Imported by: server (LLM tool spec), client (registries), │
│ anyone who needs to know "what tools exist". │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ ./executionRuntime │
│ src/ExecutionRuntime/index.ts │
│ ─ Pure runtime logic. Accepts services via constructor — │
│ never imports concrete services or stores directly. │
│ ─ Imported by: server (BuiltinServerRuntimeOutput), tests, │
│ and the client executor as a delegate. │
│ ─ Returns: BuiltinServerRuntimeOutput { content, state, … } │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ ./executor │
│ src/client/executor/index.ts │
│ ─ BaseExecutor subclass. Wires Zustand stores and frontend │
│ services into ExecutionRuntime, then funnels through │
│ toResult() into BuiltinToolResult { content, state, error, │
│ success }. │
│ ─ Imported by: src/store/tool/slices/builtin/executors/ │
│ index.ts (registered as a singleton). │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ ./client │
│ src/client/{Inspector,Render,Placeholder,Streaming, │
│ Intervention,Portal,components}/ │
│ ─ React 'use client' surfaces. Read args + pluginState. │
│ ─ Imported by: packages/builtin-tools/src/{inspectors, │
│ renders,placeholders,streamings,interventions,portals}.ts. │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Registry wiring │
│ packages/builtin-tools/src/*.ts │
│ src/store/tool/slices/builtin/executors/index.ts │
│ ─ Aggregator maps: identifier → { apiName → component }. │
└─────────────────────────────────────────────────────────────────┘
```
The split exists so:
- Server bundles import only `./` and `./executionRuntime` and never touch React.
- Frontend bundles import `./client` and never touch Node-only services.
- The runtime is testable without React or Electron present.
---
## Why ExecutionRuntime is the Default Home for Logic
**Old pattern (grandfathered):** business logic in `src/executor/` directly. Examples: `builtin-tool-task`, older tools. Works, but the executor mixes runtime logic with frontend service plumbing — hard to reuse on the server.
**New pattern (preferred):** business logic in `src/ExecutionRuntime/`, frontend wiring in `src/client/executor/`. Examples: `builtin-tool-local-system`, `builtin-tool-web-browsing`, `builtin-tool-calculator`.
```
ExecutionRuntime
├─ accepts services via constructor (or `static create(opts)`)
├─ returns BuiltinServerRuntimeOutput (content + state + success)
└─ no React, no Zustand, no `@/services/...` direct imports
client/executor
├─ extends BaseExecutor<typeof <Name>ApiName>
├─ holds a `runtime = new <Name>ExecutionRuntime(realService)` instance
├─ each ApiName method:
│ 1. resolve scope / pull defaults from BuiltinToolContext
│ 2. call runtime.<method>(args)
│ 3. funnel through toResult() → BuiltinToolResult
└─ exported singleton: export const <name>Executor = new <Name>Executor()
```
### Service injection
`ExecutionRuntime` should declare a TypeScript interface for the services it needs and accept the implementation via constructor. Server callers wire in real implementations; tests wire in mocks. Example from `local-system`:
```ts
export interface ILocalSystemService {
readLocalFile: (params: any) => Promise<any>;
writeFile: (params: any) => Promise<any>;
/* … */
}
export class LocalSystemExecutionRuntime extends ComputerRuntime {
constructor(private service: ILocalSystemService) {
super();
}
/* methods delegate to this.service.* */
}
```
The `client/executor` instantiates it once with the real service:
```ts
import { localFileService } from '@/services/electron/localFileService';
import { LocalSystemExecutionRuntime } from '../../ExecutionRuntime';
class LocalSystemExecutor extends BaseExecutor<typeof LocalSystemApiEnum> {
private runtime = new LocalSystemExecutionRuntime(localFileService);
/* … */
}
```
### When ExecutionRuntime is the only thing you ship
Some tools are server-only — there's no frontend executor. `builtin-tool-web-browsing` is the canonical example: only `./` and `./executionRuntime` are exported, no `./executor`, and the runtime is constructed by the server-side `ToolExecutionService`. Skip `client/executor/` entirely for those.
### When the executor reuses the runtime as-is
Pure-compute tools (`builtin-tool-calculator`) often have an executor whose ApiName methods call `executor.calculate(args)` and an `ExecutionRuntime` whose methods call `calculatorExecutor.calculate(args)` — same logic, two thin wrappers. That's fine; the duplication buys you the bundle split.
---
## The Result Contract
### `BuiltinServerRuntimeOutput` (what ExecutionRuntime returns)
```ts
{
content: string; // the LLM-facing text — never undefined; default to error message
state?: any; // result-domain object the UI reads as pluginState
success: boolean; // mandatory
error?: any; // raw error; the executor will repackage
}
```
### `BuiltinToolResult` (what the executor returns to the runtime)
```ts
{
success: boolean;
content?: string;
state?: any;
error?: { type: string; message: string; body?: any };
metadata?: Record<string, any>; // rare; e.g. { agentCouncil: true }
stop?: boolean; // rare; halt the orchestration step
}
```
### The `toResult` funnel (mandatory)
Every executor method returns through a single `toResult()` to enforce two invariants:
1. **`content` is never undefined.** A missing content collapses downstream into `''`, leaving the Debug pane blank while `pluginState` was already saved. See the `globLocalFiles` regression in `local-system/src/client/executor/index.ts:60-84`.
2. **`state` survives failures.** Renderers can keep showing partial output even when `success: false`.
```ts
private toResult(output: BuiltinServerRuntimeOutput): BuiltinToolResult {
const errorMessage = typeof output.error?.message === 'string' ? output.error.message : undefined;
const safeContent = output.content || errorMessage || 'Tool execution failed';
if (!output.success) {
return {
success: false,
content: safeContent,
state: output.state,
error: output.error
? { type: 'PluginServerError', message: errorMessage ?? safeContent, body: output.error }
: undefined,
};
}
return { success: true, content: safeContent, state: output.state };
}
```
---
## `BaseExecutor` — How Method Dispatch Works
`BaseExecutor.invoke(apiName, params, ctx)` does:
```ts
if (!this.hasApi(apiName)) return { error: { type: 'ApiNotFound', }, success: false };
return (this as any)[apiName](params, ctx); // method name MUST equal apiName value
```
So:
- **Method names must equal `<Name>ApiName` values, exactly.** A typo silently routes to "ApiNotFound".
- **Methods must be class fields, not class methods**, because `this` is lost when registry calls `executor.invoke(apiName, params, ctx)`. Always declare as `methodName = async (…) => { … }`.
- **Always destructure `apiEnum` and `identifier` as `readonly` instance fields**, not getters — `BaseExecutor.hasApi/getApiNames` reads them synchronously.
---
## `BuiltinToolContext` — What the Executor Receives
The runtime hands every executor method an optional `BuiltinToolContext` as the second argument:
| Field | Use |
| ----------------------------- | -------------------------------------------------------------- |
| `agentId` | Default agent for "current agent" semantics (e.g. `listTasks`) |
| `groupId` | Group chat scope |
| `topicId` | Current topic — needed when creating messages/operations |
| `taskId` | Current task identifier — fallback for "implicit" param |
| `documentId` | Current page/document scope |
| `messageId` | The tool message being created (for state attachments) |
| `sourceMessageId` | The user message that triggered this tool turn |
| `operationId` | Operation lineage (use for cancellation, tracing) |
| `scope` | `'task' \| 'agent' \| …` — toggles default behaviors |
| `signal: AbortSignal` | Honor for long-running ops |
| `stepContext` | Cross-message runtime state (GTD todos, etc.) |
| `registerAfterCompletion(cb)` | Defer side-effects past message-update race |
| `groupOrchestration` | Group orchestration callbacks |
**Use rule:** read with `?.`, fall back to explicit params, **never silently override** an explicit param with a context value.
---
## i18n Integration
Source of truth: `src/locales/default/plugin.ts`. Keys follow `builtins.<identifier>.<topic>.<…>`:
| Key | Use |
| ------------------------------------- | ------------------------------------------------------------ |
| `builtins.<identifier>.title` | Display title (overrides `manifest.meta.title` when present) |
| `builtins.<identifier>.apiName.<api>` | Inspector header label (one per ApiName) |
| `builtins.<identifier>.inspector.<…>` | Extra Inspector strings ("no results", chips, counters) |
| `builtins.<identifier>.<feature>.<…>` | Render / Intervention strings, free-form per tool |
For dev preview, also seed `locales/zh-CN/plugin.json` and `locales/en-US/plugin.json`. Run `pnpm i18n` before opening a PR — it's slow, so do it once at the end. (See the **i18n** skill for the full workflow.)
---
## Registry Wiring
Five core files plus optional ones. Miss any and you'll see "tool not found", a missing chip, a blank result card, a stuck spinner, or an approval dialog that never appears.
| File | Add what |
| -------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| **Required** | |
| `packages/builtin-tools/src/index.ts` | Import `<Name>Manifest`; push entry to `builtinTools`. Set `hidden`/`discoverable` flags. |
| `packages/builtin-tools/src/identifiers.ts` | Add `<Name>Manifest.identifier` to `builtinToolIdentifiers`. |
| `packages/builtin-tools/src/inspectors.ts` | Import `<Name>Inspectors, <Name>Manifest`; add to `BuiltinToolInspectors`. |
| `src/store/tool/slices/builtin/executors/index.ts` | Import `<name>Executor`; add to `registerExecutors([…])`. |
| **Conditional — add only if the surface exists** | |
| `packages/builtin-tools/src/renders.ts` | Add to `BuiltinToolsRenders` if any API has a Render. |
| `packages/builtin-tools/src/placeholders.ts` | Add to `BuiltinToolPlaceholders` if any API has a Placeholder. |
| `packages/builtin-tools/src/streamings.ts` | Add to `BuiltinToolStreamings` if any API has a Streaming renderer. |
| `packages/builtin-tools/src/interventions.ts` | Add to `BuiltinToolInterventions` if any API has an Intervention component. |
| `packages/builtin-tools/src/portals.ts` | Add to `BuiltinToolsPortals` if the tool has a Portal. |
| `packages/builtin-tools/src/displayControls.ts` | Add if Render must show/hide based on result content (rare; see ClaudeCode/Codex). |
### Optional flags in `packages/builtin-tools/src/index.ts`
```ts
{
identifier: TaskManifest.identifier,
manifest: TaskManifest,
type: 'builtin',
hidden: true, // hide from chat-input Tools popover
discoverable: false, // exclude from agent builder / skill discovery
}
```
Lists in the same file you may need to touch:
- `defaultToolIds` — added to the agent's tool list by default
- `alwaysOnToolIds` — forced on regardless of user selection (use sparingly)
- `runtimeManagedToolIds` — enable state controlled by runtime, not user UI; **must mirror the rules map** in `src/server/modules/Mecha/AgentToolsEngine/index.ts` and `src/helpers/toolEngineering/index.ts`
---
## File-Map at a Glance
```
packages/builtin-tool-<name>/
├── package.json # exports: ., ./client, ./executor, ./executionRuntime
└── src/
├── index.ts # export Manifest, Identifier, types, systemPrompt
├── manifest.ts # BuiltinToolManifest + Identifier const
├── types.ts # ApiName + Params/State per API
├── systemRole.ts # System prompt (multiple variants OK: systemRole.desktop.ts)
├── ExecutionRuntime/
│ └── index.ts # <Name>ExecutionRuntime — pure runtime, service injection
└── client/
├── index.ts # exports for the registries
├── executor/
│ └── index.ts # <Name>Executor extends BaseExecutor; export <name>Executor
├── Inspector/
│ ├── index.ts # <Name>Inspectors record
│ └── <ApiName>/index.tsx # one folder per API (or .tsx file when trivial)
├── Render/
│ ├── index.ts # <Name>Renders record
│ └── <ApiName>/ # rich renders → folder with subcomponents
├── Placeholder/
│ ├── index.ts
│ └── <ApiName>.tsx # usually a single skeleton file
├── Streaming/
│ ├── index.ts
│ └── <ApiName>/ # live-output renderer
├── Intervention/
│ ├── index.ts
│ └── <ApiName>/ # approval / edit-before-run UI
├── Portal/
│ ├── index.tsx # routing component (switch on apiName)
│ └── <ApiName>/ # full-screen detail view
└── components/ # FileItem, EngineAvatar, etc. — shared subcomponents
```
Skip every `client/<surface>/` directory you don't need — empty registries are fine.
+478
View File
@@ -0,0 +1,478 @@
# Tool Design (Naming, Manifest, Executor, Runtime)
This doc covers everything that **isn't UI**: the tool's identifier, API surface, manifest, types, system prompt, ExecutionRuntime, and the executor that wires it into the frontend.
For UI surfaces (Inspector / Render / Placeholder / Streaming / Intervention / Portal), see [ui.md](ui.md).
For where files live and how registries work, see [architecture.md](architecture.md).
---
## 1. Naming
| Thing | Convention | Example |
| ----------------------- | -------------------------------------------------------------- | ------------------------------------------------------------ |
| Package directory | `packages/builtin-tool-<kebab>/` | `builtin-tool-task` |
| npm name | `@lobechat/builtin-tool-<kebab>` | `@lobechat/builtin-tool-task` |
| Tool `identifier` | `lobe-<kebab-domain>`**persisted in message history** | `lobe-task`, `lobe-calculator`, `lobe-knowledge-base` |
| Identifier const | `<Name>Identifier` exported from `manifest.ts` (or `types.ts`) | `export const TaskIdentifier = 'lobe-task'` |
| API name const | `<Name>ApiName``as const` object, **camelCase verbs** | `createTask`, `listTasks`, `runTask` |
| Executor class | `<Name>Executor extends BaseExecutor<typeof <Name>ApiName>` | `TaskExecutor` |
| Executor singleton | `<name>Executor` (camelCase) | `export const taskExecutor = new TaskExecutor()` |
| ExecutionRuntime class | `<Name>ExecutionRuntime` | `LocalSystemExecutionRuntime`, `WebBrowsingExecutionRuntime` |
| Inspector / Render etc. | `<ApiName>Inspector` / `<ApiName>Render` | `CreateTaskInspector`, `SearchInspector` |
### Identifier rules
- **`lobe-` prefix is mandatory** — many switches in the codebase key off it.
- Pick a **domain noun**, not a verb (`lobe-task`, not `lobe-task-manager`).
- The identifier is **persisted in message history** — renaming after release means the `@deprecated` alias trick (register the legacy identifier as a second key in `inspectors.ts` / `renders.ts` pointing at the new module). Get it right the first time.
### ApiName rules
- Verb + noun, camelCase: `createTask`, `viewTask`, `runTasks`.
- **Plural variant for batch** (`createTasks`, `runTasks`) — describe in the manifest description that it's preferred over multiple single calls. The system prompt should also push the batch form.
- Reserve **clear separation between mutating verbs** (`updateTaskStatus`, `editTask`) and **execution verbs** (`runTask`). The system prompt must warn the model when these are confusable — see `task` for the canonical "do NOT use updateTaskStatus(running) to start a task" warning.
- Read-only verbs: `list*`, `view*`, `get*`, `search*`. Mutating: `create*`, `edit*`, `update*`, `delete*`. Triggers/effects: `run*`, `execute*`, `submit*`.
---
## 2. `types.ts` — ApiName + Params/State
Define `<Name>ApiName` as `as const` so it doubles as a runtime enum (used by `BaseExecutor`) and a literal type. Then declare `Params` and `State` per API.
```ts
export const TaskIdentifier = 'lobe-task';
export const TaskApiName = {
createTask: 'createTask',
createTasks: 'createTasks',
listTasks: 'listTasks',
/* …one entry per API, group logically (CRUD then run-style) */
} as const;
export type TaskApiNameType = (typeof TaskApiName)[keyof typeof TaskApiName];
// One block per API
export interface CreateTaskParams {
name: string;
instruction: string; /* … */
}
export interface CreateTaskState {
identifier?: string;
success: boolean;
}
export interface CreateTasksParams {
tasks: CreateTaskParams[];
}
export interface CreateTasksItemResult {
error?: string;
identifier?: string;
name: string;
success: boolean;
}
export interface CreateTasksState {
failed: number;
results: CreateTasksItemResult[];
succeeded: number;
}
```
**The result-domain rule for `State`** (memory: "pluginState is result-domain, not call-domain"):
- Include only fields the UI **renders after the call returns** — ids the LLM didn't have when calling, counts, summary numbers, server-assigned status.
- **Don't echo all params.** The Inspector/Render gets `args` for free.
- Keep batch results as `{ succeeded, failed, results }` so the Render can show a one-line summary plus a detail list.
---
## 3. `manifest.ts` — JSON Schema for the LLM
```ts
import type { BuiltinToolManifest } from '@lobechat/types';
import { systemPrompt } from './systemRole';
import { TaskApiName, TaskIdentifier } from './types';
export const TaskManifest: BuiltinToolManifest = {
identifier: TaskIdentifier,
type: 'builtin',
systemRole: systemPrompt,
meta: {
avatar: '📋',
title: 'Task Tools',
description: 'Create, list, edit, delete tasks with dependencies',
readme: 'Optional long description shown in tool detail pages',
},
api: [
{
name: TaskApiName.createTask,
description:
'Create a new task. Optionally attach as a subtask via parentIdentifier. ' +
'Prefer createTasks when planning a batch.',
parameters: {
type: 'object',
required: ['name', 'instruction'],
properties: {
name: { type: 'string', description: 'Short, descriptive name.' },
instruction: {
type: 'string',
description: 'Detailed instruction for what the task should accomplish.',
},
parentIdentifier: {
type: 'string',
description:
'Identifier of the parent task (e.g. "TASK-1"). If provided, the new task becomes a subtask.',
},
priority: {
type: 'number',
description: 'Priority level: 0=none, 1=urgent, 2=high, 3=normal, 4=low. Default is 0.',
},
},
},
},
/* …one entry per ApiName */
],
};
```
### Manifest writing checklist
- **Every API in `<Name>ApiName` has exactly one entry in `api[]`.** Easy to drift after a refactor.
- **`description` on each API is the model's only docs.** Make it long enough for the LLM to pick the right tool. Mention edge cases ("If you provide any filter, omitted filters are not applied implicitly"), defaults, and the relationship to sibling APIs ("To START a task, use runTask — updateTaskStatus only flips a flag").
- **`parameters` is JSON Schema** (`LobeChatPluginApi`). Use `enum`, `required`, `items`, `oneOf`, `additionalProperties: false` etc. — these survive into the LLM's tool spec.
- **Use `additionalProperties: false`** on parameter objects so the model can't sneak unknown fields past validation.
- **Number parameters with semantic values** (`priority: 0=none, 1=urgent, …`) should describe the mapping in the description. Don't rely on `enum` alone for numbers — the model often fills the wrong one.
- **`enum` arrays for known string sets** (statuses, categories, engines). Spread from a constants module (`enum: [...TASK_STATUSES]`) so the manifest stays in sync.
### Optional manifest fields
```ts
{
/* Where this tool can run.
'client' → Agent Gateway dispatches to the desktop client (filesystem, Electron only)
'server' → ToolExecutionService runs it on the server
omitted → server only */
executors: ['client', 'server'],
/* Default human intervention policy for all APIs that don't specify one.
Pair with an Intervention component (see ui.md). */
humanIntervention: 'never' | 'always' | { /* extended config */ },
}
```
Per-API `humanIntervention` and `renderDisplayControl` go inside each `api[]` entry.
---
## 4. `systemRole.ts` — Operator Instructions for the Model
This is appended to the agent system prompt whenever the tool is enabled. Treat it as a **how-to-use guide for the LLM**, not marketing copy.
```ts
export const systemPrompt = `You have access to Task management tools. Use them to:
- **createTask**: Create a new task. Use parentIdentifier to make it a subtask.
- **createTasks**: Prefer this over multiple createTask calls when planning a batch
(e.g. all subtasks under one parent, or all chapters of an outline).
- **runTask**: Actually START a task — kicks off the agent in a new (or continued)
topic. Do NOT use updateTaskStatus(running) to start a task; that only flips a
flag without executing. The task must have an assigneeAgentId.
- **updateTaskStatus**: Change a task's status (completed/cancelled/paused/failed).
If you mark a task as failed, include an error message explaining why.
- ...
When planning work:
1. Create tasks for each major piece (use parentIdentifier to organize as subtasks).
2. Use editTask with addDependencies to control execution order.
3. Use updateTaskStatus to mark the current task completed when done.`;
```
### Patterns that work well
- **Bulleted list, bold the API name, one line per API.** The model picks tools by skimming.
- **Disambiguate confusable APIs explicitly** (`runTask` vs `updateTaskStatus`).
- **Push toward batched APIs** ("Prefer this when…").
- **End with a numbered workflow** if the tool has a typical sequence.
- **For tools with multiple environments** (e.g. desktop vs cloud), keep variants in `systemRole.ts` and `systemRole.desktop.ts` and pick at the manifest level. See `builtin-tool-local-system`.
### Dynamic system prompts
If the prompt depends on runtime state (current date, available models), export a function and call it in the manifest:
```ts
// systemRole.ts
export const systemPrompt = (today: string) => `Today is ${today}. You have web search tools…`;
// manifest.ts
import dayjs from 'dayjs';
systemRole: systemPrompt(dayjs(new Date()).format('YYYY-MM-DD')),
```
---
## 5. `ExecutionRuntime/index.ts` — Pure Runtime
This is **the default home for new tool logic** going forward. The runtime is a class that:
- Has no React, no Zustand, no `@/services/...` direct imports.
- Receives services as **constructor injection** (or as method args).
- Returns `BuiltinServerRuntimeOutput` from each method.
- Is unit-testable by passing in mocks.
### Pattern A: Inject a service interface
Use when the runtime calls out to IPC, network, or DB.
```ts
// ExecutionRuntime/index.ts
import type { BuiltinServerRuntimeOutput } from '@lobechat/types';
export interface IWebBrowsingService {
search: (q: SearchQuery) => Promise<UniformSearchResponse>;
crawlPages: (urls: string[]) => Promise<CrawlResults>;
}
export interface WebBrowsingRuntimeOptions {
searchService: IWebBrowsingService;
documentService?: WebBrowsingDocumentService;
agentId?: string;
topicId?: string;
}
export class WebBrowsingExecutionRuntime {
constructor(private opts: WebBrowsingRuntimeOptions) {}
async search(
args: SearchQuery,
options?: { signal?: AbortSignal },
): Promise<BuiltinServerRuntimeOutput> {
try {
const data = await this.opts.searchService.search(args, options);
if (data.errorDetail) {
return {
success: false,
content: data.errorDetail,
error: { message: data.errorDetail },
state: data,
};
}
return {
success: true,
content: searchResultsPrompt(data.results.slice(0, 10)),
state: data,
};
} catch (e) {
return { success: false, content: (e as Error).message, error: e };
}
}
}
```
### Pattern B: Reuse the executor
Use when the same logic runs in browser and Node (e.g. mathjs, nerdamer). The runtime is a thin wrapper that imports the executor and re-types the state per API. See `builtin-tool-calculator/src/ExecutionRuntime/index.ts` for the canonical example.
### Pattern C: Extend a shared base
When you're implementing a domain that already has a base runtime (file ops via `ComputerRuntime`), extend and only override `callService` + result normalization. See `builtin-tool-local-system/src/ExecutionRuntime/index.ts`.
### Runtime contract
Every method returns:
```ts
{
content: string; // LLM-facing — never undefined; default to error message
state?: any; // result-domain — what the UI's pluginState becomes
success: boolean; // mandatory
error?: any; // raw error object; the executor will repackage
}
```
Use `@lobechat/prompts` formatters (`searchResultsPrompt`, `crawlResultsPrompt`, `formatTaskCreated`, etc.) to produce structured `content`. They emit XML/markdown that's already tuned for token efficiency.
---
## 6. `client/executor/index.ts` — Frontend Wiring
The executor's job is to **resolve frontend defaults** (current agent, current task, scope) and **call the runtime**. It then funnels through `toResult()` into the `BuiltinToolResult` shape.
```ts
import { BaseExecutor, type BuiltinToolContext, type BuiltinToolResult } from '@lobechat/types';
import debug from 'debug';
import { taskService } from '@/services/task';
import { getTaskStoreState } from '@/store/task';
import { TaskIdentifier } from '../../manifest';
import { TaskApiName, type CreateTaskParams } from '../../types';
const log = debug('lobe-task:executor');
class TaskExecutor extends BaseExecutor<typeof TaskApiName> {
readonly identifier = TaskIdentifier;
protected readonly apiEnum = TaskApiName;
// ⚠ class FIELD, not a method — preserves `this` when invoked via registry
createTask = async (
params: CreateTaskParams,
ctx?: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
try {
log('createTask params=%o', params);
const task = await getTaskStoreState().createTask({
name: params.name,
instruction: params.instruction,
// Default assignee from context — never silently override an explicit value
assigneeAgentId:
params.assigneeAgentId ?? (ctx?.scope === 'task' ? undefined : ctx?.agentId),
parentTaskId: params.parentIdentifier?.trim() || undefined,
priority: params.priority,
});
if (!task) return this.errorResult('Failed to create task', 'CreateFailed');
return {
success: true,
content: formatTaskCreated({ identifier: task.identifier, name: task.name /* … */ }),
state: { identifier: task.identifier, success: true },
};
} catch (error) {
return this.errorResult(error, 'CreateTaskFailed');
}
};
private errorResult(err: unknown, type: string): BuiltinToolResult {
const message = err instanceof Error ? err.message : String(err) || 'Unknown error';
return { success: false, content: `Failed: ${message}`, error: { type, message } };
}
}
export const taskExecutor = new TaskExecutor();
```
### Hard rules
1. **Methods are class fields** (`name = async (…) => {…}`), not class methods. The registry calls `(executor as any)[apiName](params, ctx)`; arrow-function fields keep `this` bound.
2. **`identifier` and `apiEnum` are `readonly` instance fields**, not getters — `BaseExecutor.hasApi/getApiNames` reads them synchronously at registration time.
3. **Default missing params from `ctx`**, but never silently override explicit values. Use `params.foo ?? ctx?.foo`, not `ctx?.foo ?? params.foo`.
4. **One funnel for all returns.** Either always return through `toResult(runtime.x())` (when delegating) or through `errorResult(…)` for the catch arm. Never inline `{ success: false, content: '' }``content: ''` collapses the Debug pane to blank.
5. **`debug('lobe-<name>:executor')`.** Match the namespace to the identifier minus `lobe-` when convenient.
6. **Singleton export.** `export const <name>Executor = new <Name>Executor()` — the registry imports the instance, not the class.
### When the executor delegates to ExecutionRuntime
```ts
class LocalSystemExecutor extends BaseExecutor<typeof LocalSystemApiEnum> {
readonly identifier = LocalSystemIdentifier;
protected readonly apiEnum = LocalSystemApiEnum;
private runtime = new LocalSystemExecutionRuntime(localFileService);
readLocalFile = async (params: LocalReadFileParams): Promise<BuiltinToolResult> => {
try {
const result = await this.runtime.readFile({
path: params.path,
startLine: params.loc?.[0],
endLine: params.loc?.[1],
});
return this.toResult(result);
} catch (error) {
return this.errorResult(error);
}
};
private toResult(out: BuiltinServerRuntimeOutput): BuiltinToolResult {
const errMsg = typeof out.error?.message === 'string' ? out.error.message : undefined;
const safe = out.content || errMsg || 'Tool execution failed';
if (!out.success) {
return {
success: false,
content: safe,
state: out.state, // ← preserve partial state on failure
error: out.error
? { type: 'PluginServerError', message: errMsg ?? safe, body: out.error }
: undefined,
};
}
return { success: true, content: safe, state: out.state };
}
}
```
The `toResult` funnel is **mandatory**: it enforces never-undefined `content` and partial-state preservation. Both invariants caught real production bugs (`globLocalFiles` Response empty, `editLocalFile` partial state lost).
---
## 7. `index.ts` — Package Entry Point
Keep it pure data + the manifest. **No React, no stores, no Node-only imports.**
```ts
export { TaskIdentifier, TaskManifest } from './manifest';
export { systemPrompt } from './systemRole';
export {
TaskApiName,
type TaskApiNameType,
type CreateTaskParams,
type CreateTaskState,
/* …all Params/State types */
} from './types';
// Optional helpers used by both the runtime and the UI
export { TASK_STATUSES, UNFINISHED_TASK_STATUSES } from './constants';
```
This entry is what `packages/builtin-tools/src/index.ts` and `identifiers.ts` import — it must be importable from server bundles.
---
## 8. `package.json`
```json
{
"dependencies": {
"@lobechat/prompts": "workspace:*"
},
"devDependencies": {
"@lobechat/types": "workspace:*"
},
"exports": {
".": "./src/index.ts",
"./client": "./src/client/index.ts",
"./executor": "./src/client/executor/index.ts",
"./executionRuntime": "./src/ExecutionRuntime/index.ts"
},
"main": "./src/index.ts",
"name": "@lobechat/builtin-tool-<name>",
"peerDependencies": {
"@lobehub/ui": "^5",
"antd": "^6",
"antd-style": "*",
"lucide-react": "*",
"react": "*",
"react-i18next": "*"
},
"private": true,
"version": "1.0.0"
}
```
**Why peer not direct deps for client libs:** the `./` and `./executionRuntime` entry points must be importable from server code. Listing React etc. as peer deps prevents bundlers from following them when only the runtime is consumed.
**Skip `./executor`** if the package has no frontend executor (server-only tools like `builtin-tool-web-browsing`).
---
## 9. Common Pitfalls
| Symptom | Likely cause |
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| "ApiNotFound" at runtime | Method name in executor doesn't match `ApiName` value (typo, wrong case) |
| Method works once, then "this is undefined" | Method declared as `async fn() {}` instead of `fn = async () => {}``this` lost when registry invokes |
| Debug "Response" pane blank but `pluginState` populated | Returning `content: ''` or letting `output.content` be undefined — use the `toResult` funnel |
| Partial result vanishes on failure | `toResult` discarded `state` when `success: false`; preserve it |
| Tool shows up but doesn't run on desktop | `executors` in manifest doesn't include `'client'` (or vice versa for server-only) |
| Same tool registered twice / legacy identifier ghost | Identifier collision; check `@deprecated` aliases in `inspectors.ts`/`renders.ts` |
| Manifest test fails after adding API | Forgot to add the corresponding i18n `apiName.<api>` key |
| TypeScript error on `BaseExecutor<typeof X>` | `X` declared with `enum` instead of `as const` object — must be the const-object form |
+721
View File
@@ -0,0 +1,721 @@
# Tool UI Surfaces
A builtin tool can ship up to **six client-side surfaces**, each with a different role in the chat UI. Only `Inspector` is required; the other five are added on demand and registered in their own central files.
| Surface | Required? | When the chat shows it | Registered in |
| ------------ | --------- | --------------------------------------------------------------------- | --------------------------------------------- |
| Inspector | ✅ Always | Header strip of every tool call (one-line chip) | `packages/builtin-tools/src/inspectors.ts` |
| Render | Optional | Rich result card below the header, after the call returns | `packages/builtin-tools/src/renders.ts` |
| Placeholder | Optional | Skeleton between "args streaming complete" and "result arrives" | `packages/builtin-tools/src/placeholders.ts` |
| Streaming | Optional | Live output during execution (e.g. command stdout) | `packages/builtin-tools/src/streamings.ts` |
| Intervention | Optional | Approval / edit-before-run dialog (when `humanIntervention` triggers) | `packages/builtin-tools/src/interventions.ts` |
| Portal | Optional | Full-screen detail view (right-side or modal) | `packages/builtin-tools/src/portals.ts` |
The two reference tools to read end-to-end:
- **`builtin-tool-web-browsing/src/client/`** — Inspector + Render + Placeholder + Portal (no Intervention/Streaming).
- **`builtin-tool-local-system/src/client/`** — all six surfaces, including `components/` for shared building blocks.
---
## 0. Shared Style Rules
These apply across every surface.
### 0.1 Use `'use client'` at the top of every component file
Tool surfaces are leaves in the chat tree and must not block server rendering.
### 0.2 Prefer `createStaticStyles + cssVar.*`
Zero-runtime CSS-in-JS — the styles compile once and read CSS variables at runtime.
```tsx
import { createStaticStyles, cssVar } from 'antd-style';
const styles = createStaticStyles(({ css, cssVar }) => ({
chip: css`
padding-block: 2px;
padding-inline: 8px;
border-radius: 999px;
color: ${cssVar.colorText};
background: ${cssVar.colorFillTertiary};
`,
}));
```
Fall back to `createStyles + token` only when you need runtime token computation (rare). Inline `style={{ color: cssVar.colorTextSecondary }}` is fine for one-off dynamic values.
### 0.3 Use `@lobehub/ui`, not raw `antd`
`Block`, `Text`, `Flexbox`, `Highlighter`, `Alert`, `Tooltip`, `Skeleton` all come from `@lobehub/ui`. Modals come from `@lobehub/ui/base-ui` (`createModal`, `useModalContext`, `confirmModal`) — see the **modal** skill.
Memory note: `@lobehub/ui`'s `<Text type='secondary'>` is a lighter shade than `colorTextSecondary`. If you need that exact token color, write `<Text style={{ color: cssVar.colorTextSecondary }}>`.
### 0.4 Always `memo` and set `displayName`
```tsx
export const SearchInspector = memo<BuiltinInspectorProps<SearchQuery, UniformSearchResponse>>(
({ args /* … */ }) => {
/* … */
},
);
SearchInspector.displayName = 'SearchInspector';
export default SearchInspector;
```
### 0.5 Always type with `BuiltinXProps<Args, State>` generics
Don't widen to `any`. The Args generic is the JSON Schema params, the State generic is the executor's `state` field. The two should match `<Name>Params` and `<Name>State` from `types.ts`.
### 0.6 Pull strings from `t('plugin')`
```tsx
const { t } = useTranslation('plugin');
t('builtins.<identifier>.apiName.<api>');
```
Every Inspector should default to `t('builtins.<identifier>.apiName.<api>')` so it shows something while args stream in.
### 0.7 Read store state from `@/store/chat`, not props
Tool surfaces sometimes need cross-cutting state (loading, streaming buffer). Read it inside the component via Zustand selectors, not from props — props only carry args/state/messageId.
---
## 1. Inspector — Header Chip (required)
**Lifecycle:** Inspector renders for **every phase** of a tool call: while args are streaming in, while the executor is running, and after results come back. It's the only surface that's always visible.
**Goal:** keep it to a single line. Show what's happening with as much context as is currently available.
### Props (`BuiltinInspectorProps<Args, State>`)
```ts
interface BuiltinInspectorProps<Arguments = any, State = any> {
apiName: string;
args: Arguments; // final args (only after the assistant stops streaming)
identifier: string;
isArgumentsStreaming?: boolean; // args still arriving
isLoading?: boolean; // args complete, executor running
partialArgs?: Arguments; // partial JSON during streaming
pluginState?: State; // executor's `state` after success
result?: { content: string | null; error?: any };
}
```
### State machine
| Phase | What's available | What to show |
| ----------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------- |
| Args streaming, no useful field yet | `isArgumentsStreaming === true`, `partialArgs.X` undefined | Just the API title with `shinyTextStyles.shinyText` |
| Args streaming, key field arrived | `partialArgs.X` populated | Title + key field chip, still pulse-animated |
| Args complete, executor running | `args` populated, `isLoading === true` | Same as above, still pulse-animated |
| Result arrived | `pluginState` populated, `isLoading === false` | Title + chips + result summary (count, identifier, status) |
### Canonical example — Search
`packages/builtin-tool-web-browsing/src/client/Inspector/Search/index.tsx`:
```tsx
'use client';
import type { BuiltinInspectorProps, SearchQuery, UniformSearchResponse } from '@lobechat/types';
import { Text } from '@lobehub/ui';
import { cssVar, cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
export const SearchInspector = memo<BuiltinInspectorProps<SearchQuery, UniformSearchResponse>>(
({ args, partialArgs, isArgumentsStreaming, isLoading, pluginState }) => {
const { t } = useTranslation('plugin');
const query = args?.query || partialArgs?.query || '';
const resultCount = pluginState?.results?.length ?? 0;
const hasResults = resultCount > 0;
if (isArgumentsStreaming && !query) {
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-web-browsing.apiName.search')}</span>
</div>
);
}
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>{t('builtins.lobe-web-browsing.apiName.search')}:&nbsp;</span>
{query && <span className={highlightTextStyles.primary}>{query}</span>}
{!isLoading &&
!isArgumentsStreaming &&
pluginState?.results &&
(hasResults ? (
<span style={{ marginInlineStart: 4 }}>({resultCount})</span>
) : (
<Text as="span" color={cssVar.colorTextDescription} fontSize={12}>
({t('builtins.lobe-web-browsing.inspector.noResults')})
</Text>
))}
</div>
);
},
);
SearchInspector.displayName = 'SearchInspector';
export default SearchInspector;
```
### Inspector rules
- Wrap the whole row with `inspectorTextStyles.root` (provides correct flex / line-height baseline).
- Pulse with `shinyTextStyles.shinyText` whenever `isArgumentsStreaming || isLoading`.
- Show the i18n title first so the row is non-empty during the earliest streaming phase.
- 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.
### Inspector registry — `client/Inspector/index.ts`
```ts
import type { BuiltinInspector } from '@lobechat/types';
import { TaskApiName } from '../../types';
import { CreateTaskInspector } from './CreateTask';
import { ListTasksInspector } from './ListTasks';
/* … */
export const TaskInspectors: Record<string, BuiltinInspector> = {
[TaskApiName.createTask]: CreateTaskInspector as BuiltinInspector,
[TaskApiName.listTasks]: ListTasksInspector as BuiltinInspector,
/* one entry per ApiName */
};
export { CreateTaskInspector } from './CreateTask';
export { ListTasksInspector } from './ListTasks';
/* re-export each */
```
---
## 2. Render — Rich Result Card (optional)
**Lifecycle:** rendered **once the result arrives** (after Placeholder/Streaming hand off). Sits below the Inspector header.
**Skip if** the API is read-only or the result is just text — the framework already shows the executor's `content` string. Add a Render only when there's a structured artifact worth seeing: a card, a chart, a diff, a list of files.
### Props (`BuiltinRenderProps<Args, State, Content>`)
```ts
interface BuiltinRenderProps<Arguments = any, State = any, Content = any> {
apiName?: string;
args: Arguments; // final params from the LLM
content: Content; // executor's content string (or parsed)
identifier?: string;
messageId: string; // for store lookups
pluginError?: any; // from BuiltinToolResult.error
pluginState?: State; // executor's state
toolCallId?: string;
}
```
### Two patterns
**Pattern A — Single-file Render** (web-browsing CrawlSinglePage):
```tsx
// client/Render/CrawlSinglePage.tsx
import type { BuiltinRenderProps, CrawlPluginState, CrawlSinglePageQuery } from '@lobechat/types';
import { memo } from 'react';
import PageContent from './PageContent';
const CrawlSinglePage = memo<BuiltinRenderProps<CrawlSinglePageQuery, CrawlPluginState>>(
({ messageId, pluginState, args }) => (
<PageContent messageId={messageId} results={pluginState?.results} urls={[args?.url]} />
),
);
export default CrawlSinglePage;
```
**Pattern B — Folder with subcomponents** (web-browsing Search):
```
client/Render/Search/
├── index.tsx # composes the subcomponents, handles error states
├── ConfigForm.tsx # appears when pluginError.type === 'PluginSettingsInvalid'
├── SearchQuery.tsx # editable query header
└── SearchResult.tsx # result list
```
Use Pattern B when the Render has internal state (editing mode, expanded items), error variants, or is large enough to benefit from splitting.
### Error handling in Render
Renders are the canonical place to surface `pluginError` because the chat doesn't auto-render typed errors:
```tsx
if (pluginError) {
if (pluginError?.type === 'PluginSettingsInvalid') {
return <ConfigForm id={messageId} provider={pluginError.body?.provider} />;
}
return (
<Alert
title={pluginError?.message}
type="error"
extra={<Highlighter language="json">{JSON.stringify(pluginError.body, null, 2)}</Highlighter>}
/>
);
}
```
### Render rules
- **Return `null`** if there's nothing useful to draw yet (avoids empty cards during stream).
- Use `pluginState` for server-truth (ids, counts, server-assigned status) and `args` for what the LLM asked. **Combine — neither alone is enough.**
- For lists, summarize with a header line and show top N items with a "+N more" tail rather than rendering everything.
- For modals from a Render, use `@lobehub/ui/base-ui` (`createModal`, `useModalContext`, `confirmModal`) — see the **modal** skill.
### Render registry — `client/Render/index.ts`
```ts
import type { BuiltinRender } from '@lobechat/types';
import { TaskApiName } from '../../types';
import CreateTaskRender from './CreateTask';
import RunTasksRender from './RunTasks';
export const TaskRenders: Record<string, BuiltinRender> = {
[TaskApiName.createTask]: CreateTaskRender as BuiltinRender,
[TaskApiName.runTasks]: RunTasksRender as BuiltinRender,
/* only the APIs with rich result UI — others fall back to text content */
};
export { default as CreateTaskRender } from './CreateTask';
export { default as RunTasksRender } from './RunTasks';
```
### Render display control (rare)
If the Render should hide for certain results (e.g. ClaudeCode's TodoWrite hides when the agent is mid-stream), add a `RenderDisplayControl` to `packages/builtin-tools/src/displayControls.ts`. See `ClaudeCodeRenderDisplayControls` for the pattern.
---
## 3. Placeholder — Skeleton Between Args and Result (optional)
**Lifecycle:** rendered when the args have finished streaming but the executor hasn't returned yet. Disappears when `pluginState` arrives. Bridges the moment of perceived lag.
**Add for** APIs with noticeable execution time: web search, network crawl, file list, large grep. **Skip for** instant ops (status flips, calculator).
### Props (`BuiltinPlaceholderProps<Args>`)
```ts
interface BuiltinPlaceholderProps<T extends Record<string, any> = any> {
apiName: string;
args?: T;
identifier: string;
}
```
No `pluginState` — Placeholder lives entirely in the "executing" gap.
### Canonical example — Search Placeholder
`packages/builtin-tool-web-browsing/src/client/Placeholder/Search.tsx`:
```tsx
import type { BuiltinPlaceholderProps, SearchQuery } from '@lobechat/types';
import { Flexbox, Icon, Skeleton } from '@lobehub/ui';
import { createStaticStyles, cx } from 'antd-style';
import { SearchIcon } from 'lucide-react';
import { memo } from 'react';
import { useIsMobile } from '@/hooks/useIsMobile';
import { shinyTextStyles } from '@/styles';
const styles = createStaticStyles(({ css, cssVar }) => ({
query: cx(
css`
padding: 4px 8px;
border-radius: 8px;
font-size: 12px;
color: ${cssVar.colorTextSecondary};
&:hover {
background: ${cssVar.colorFillTertiary};
}
`,
shinyTextStyles.shinyText,
),
}));
export const Search = memo<BuiltinPlaceholderProps<SearchQuery>>(({ args }) => {
const { query } = args || {};
const isMobile = useIsMobile();
return (
<Flexbox gap={8}>
<Flexbox horizontal={!isMobile} gap={isMobile ? 8 : 40}>
<Flexbox horizontal align="center" className={styles.query} gap={8}>
<Icon icon={SearchIcon} />
{query ? query : <Skeleton.Block active style={{ height: 20, width: 40 }} />}
</Flexbox>
<Skeleton.Block active style={{ height: 20, width: 40 }} />
</Flexbox>
<Flexbox horizontal gap={12}>
{[1, 2, 3, 4, 5].map((id) => (
<Skeleton.Button active key={id} style={{ borderRadius: 8, height: 80, width: 160 }} />
))}
</Flexbox>
</Flexbox>
);
});
```
### Placeholder rules
- **Mirror the eventual Render's layout.** When the result arrives the Placeholder unmounts and the Render mounts; if they share dimensions, the chat doesn't jump.
- Use `Skeleton.Block` / `Skeleton.Button` from `@lobehub/ui` for placeholder shapes.
- Embed any args you have (e.g. the query text) — context helps the user know what's loading.
- Pulse with `shinyTextStyles.shinyText` if the Placeholder includes literal text.
### Placeholder registry — `client/Placeholder/index.ts`
```ts
import { WebBrowsingApiName } from '../../types';
import CrawlMultiPages from './CrawlMultiPages';
import CrawlSinglePage from './CrawlSinglePage';
import { Search } from './Search';
export const WebBrowsingPlaceholders = {
[WebBrowsingApiName.crawlMultiPages]: CrawlMultiPages,
[WebBrowsingApiName.crawlSinglePage]: CrawlSinglePage,
[WebBrowsingApiName.search]: Search,
};
export { CrawlMultiPages, CrawlSinglePage, Search };
```
---
## 4. Streaming — Live Output During Execution (optional)
**Lifecycle:** rendered **while the executor is still running** for APIs that emit incremental output. The component is responsible for fetching the in-flight stream from the chat store and rendering it.
**Add for** long-running ops with continuous output: shell command execution (stdout/stderr), file write progress, code interpreter cells.
### Props (`BuiltinStreamingProps<Args>`)
```ts
interface BuiltinStreamingProps<Arguments = any> {
apiName: string;
args: Arguments;
identifier: string;
messageId: string; // use to fetch the streaming buffer from store
toolCallId: string;
}
```
Note there's **no `state` or `result` prop** — the Streaming component is for the in-flight phase. It pulls the live buffer from the store itself (typically via `chatToolSelectors.streamingContent(messageId)` or similar).
### Canonical example — RunCommandStreaming
`packages/builtin-tool-local-system/src/client/Streaming/RunCommand/index.tsx`:
```tsx
'use client';
import type { BuiltinStreamingProps } from '@lobechat/types';
import { Highlighter } from '@lobehub/ui';
import { memo } from 'react';
interface RunCommandParams {
command?: string;
description?: string;
timeout?: number;
}
export const RunCommandStreaming = memo<BuiltinStreamingProps<RunCommandParams>>(({ args }) => {
const { command } = args || {};
if (!command) return null;
return (
<Highlighter
animated
wrap
language="sh"
showLanguage={false}
style={{ padding: '4px 8px' }}
variant="outlined"
>
{command}
</Highlighter>
);
});
RunCommandStreaming.displayName = 'RunCommandStreaming';
```
For real-time output beyond just the command (stderr/stdout streaming), pull from the chat store:
```tsx
const buffer = useChatStore((state) =>
chatToolSelectors.streamingBuffer(messageId, toolCallId)(state),
);
```
### Streaming rules
- Render `null` until you have something to display (avoids flash).
- For terminal-style output, use `Highlighter` with `animated` to show typing-like effect.
- The Streaming component must **unmount cleanly** when execution ends — typically the framework swaps it out for the Render automatically.
### Streaming registry — `client/Streaming/index.ts`
```ts
import { LocalSystemApiName } from '../..';
import { RunCommandStreaming } from './RunCommand';
import { WriteFileStreaming } from './WriteFile';
export const LocalSystemStreamings = {
[LocalSystemApiName.runCommand]: RunCommandStreaming,
[LocalSystemApiName.writeLocalFile]: WriteFileStreaming,
};
```
---
## 5. Intervention — Approval / Edit-Before-Run (optional)
**Lifecycle:** rendered **before the executor runs** for APIs whose manifest sets `humanIntervention`. The user sees a preview of the args, can edit them, then approves or skips/cancels.
**Add for** destructive or sensitive ops: shell commands, file writes, file moves, payments, message broadcasts.
### Props (`BuiltinInterventionProps<Args>`)
```ts
interface BuiltinInterventionProps<Arguments = any> {
apiName?: string;
args: Arguments;
identifier?: string;
interactionMode?: 'approval' | 'custom';
messageId: string;
/** Called when the user edits the args; the approve action awaits this. */
onArgsChange?: (args: Arguments) => void | Promise<void>;
/** Called on approve / skip / cancel. */
onInteractionAction?: (
action:
| { type: 'submit'; payload: Record<string, unknown> }
| { type: 'skip'; payload?: Record<string, unknown>; reason?: string }
| { type: 'cancel'; payload?: Record<string, unknown> },
) => Promise<void>;
/** Register a callback to flush pending saves before approval. Returns cleanup. */
registerBeforeApprove?: (id: string, callback: () => void | Promise<void>) => () => void;
}
```
### Canonical example — RunCommand Intervention
`packages/builtin-tool-local-system/src/client/Intervention/RunCommand/index.tsx`:
```tsx
import type { RunCommandParams } from '@lobechat/electron-client-ipc';
import type { BuiltinInterventionProps } from '@lobechat/types';
import { Flexbox, Highlighter, Text } from '@lobehub/ui';
import { memo } from 'react';
const RunCommand = memo<BuiltinInterventionProps<RunCommandParams>>(({ args }) => {
const { description, command, timeout } = args;
return (
<Flexbox gap={8}>
<Flexbox horizontal justify="space-between">
{description && <Text>{description}</Text>}
{timeout && (
<Text style={{ fontSize: 12 }} type="secondary">
timeout: {formatTimeout(timeout)}
</Text>
)}
</Flexbox>
{command && (
<Highlighter wrap language="sh" showLanguage={false} variant="outlined">
{command}
</Highlighter>
)}
</Flexbox>
);
});
export default RunCommand;
```
### Intervention rules
- **Show a preview, not a form by default.** Editing UI is opt-in via `onArgsChange` and is usually inline (click to edit a code block, etc.).
- For args with debounced edit state (text fields), use `registerBeforeApprove(id, flushFn)` so the approve action waits for the debounce to flush. Always return the cleanup function.
- Call `onInteractionAction({ type: 'submit', payload })` when the user approves; `'skip'` if they skip with a reason; `'cancel'` if they cancel the whole turn.
- Add a corresponding `interventionAudit.ts` in the package root if the tool needs scope/path validation before approval (see `local-system/src/interventionAudit.ts`).
### Intervention registry — `client/Intervention/index.ts`
```ts
import { LocalSystemApiName } from '../..';
import EditLocalFile from './EditLocalFile';
import RunCommand from './RunCommand';
import WriteFile from './WriteFile';
/* … */
export const LocalSystemInterventions = {
[LocalSystemApiName.editLocalFile]: EditLocalFile,
[LocalSystemApiName.runCommand]: RunCommand,
[LocalSystemApiName.writeLocalFile]: WriteFile,
/* one entry per API that needs approval */
};
```
---
## 6. Portal — Full-Screen Detail View (optional)
**Lifecycle:** rendered when the user opens the tool message in a side panel or full-screen modal. One Portal per **tool**, not per API — the Portal switches on `apiName` internally.
**Add for** tools whose results deserve a deep-dive view: search results with editable filters, page content with reader mode, code interpreter sessions.
### Props (`BuiltinPortalProps<Args, State>`)
```ts
interface BuiltinPortalProps<Arguments = Record<string, any>, State = any> {
apiName?: string;
arguments: Arguments;
identifier: string;
messageId: string;
state: State;
}
```
### Canonical example — Web-Browsing Portal
`packages/builtin-tool-web-browsing/src/client/Portal/index.tsx`:
```tsx
import type { BuiltinPortalProps, CrawlPluginState, SearchQuery } from '@lobechat/types';
import { memo } from 'react';
import { WebBrowsingApiName } from '../../types';
import PageContent from './PageContent';
import PageContents from './PageContents';
import Search from './Search';
const Portal = memo<BuiltinPortalProps>(({ arguments: args, messageId, state, apiName }) => {
switch (apiName) {
case WebBrowsingApiName.search:
return <Search messageId={messageId} query={args as SearchQuery} response={state} />;
case WebBrowsingApiName.crawlSinglePage: {
const result = (state as CrawlPluginState).results.find((r) => r.originalUrl === args.url);
return <PageContent messageId={messageId} result={result} />;
}
case WebBrowsingApiName.crawlMultiPages:
return (
<PageContents
messageId={messageId}
results={(state as CrawlPluginState).results}
urls={args.urls}
/>
);
}
return null;
});
export default Portal;
```
### Portal rules
- One Portal per tool — the file is the routing layer, subcomponents implement each API's view.
- Portals can read the chat store directly to detect "still streaming" and render a Skeleton internally (see `Search/index.tsx:20-46`).
- Layout assumes more space than the Render — use `Flexbox` with `height={'100%'}` and structure for a side panel viewport.
### Portal registry — `packages/builtin-tools/src/portals.ts`
```ts
import { WebBrowsingManifest, WebBrowsingPortal } from '@lobechat/builtin-tool-web-browsing/client';
import { type BuiltinPortal } from '@lobechat/types';
export const BuiltinToolsPortals: Record<string, BuiltinPortal> = {
[WebBrowsingManifest.identifier]: WebBrowsingPortal as BuiltinPortal,
};
```
---
## 7. `client/components/` — Shared Subcomponents
Cross-cutting building blocks used by multiple surfaces live here, not duplicated in each surface folder.
Examples from `web-browsing/src/client/components/`:
- `CategoryAvatar.tsx` — search category icon
- `EngineAvatar.tsx` — search engine logo (used in Inspector chip + Render list + Portal header)
- `SearchBar.tsx` — editable query bar (used in Render and Portal)
Examples from `local-system/src/client/components/`:
- `FileItem.tsx` — single file row (used in ListFiles Render, SearchFiles Render, MoveLocalFiles Render)
- `FilePathDisplay.tsx` — path with truncation (used everywhere)
### Rules
- Live under `client/components/`, exported via `client/components/index.ts`.
- Re-export from `client/index.ts` only if other packages need them; otherwise keep internal.
- Keep them dumb — props in, JSX out, no store reads. The store reads belong in the surface that composes them.
---
## 8. `client/index.ts` — Package Public API
Re-exports everything the registries need plus useful types/manifest:
```ts
// Inspector — required
export { TaskInspectors } from './Inspector';
// Render — only if any API has one
export { TaskRenders, CreateTaskRender, RunTasksRender } from './Render';
// Placeholder / Streaming / Intervention — only if used
export { LocalSystemListFilesPlaceholder, LocalSystemSearchFilesPlaceholder } from './Placeholder';
export { LocalSystemStreamings } from './Streaming';
export { LocalSystemInterventions } from './Intervention';
// Portal — single export per tool
export { default as WebBrowsingPortal } from './Portal';
// Reusable components if other packages need them
export { CategoryAvatar, EngineAvatar, SearchBar } from './components';
// Re-export manifest, identifier, types for convenience
export { TaskManifest, TaskIdentifier } from '../manifest';
export * from '../types';
```
---
## 9. Diagnostic Quick-Lookup
| Symptom | Surface to check | | |
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | --- | ------------------------- |
| No header at all on the tool call | Inspector missing from `client/Inspector/index.ts` registry | | |
| Header shows the API name but no chips | Inspector missing \`args?.X | | partialArgs?.X\` fallback |
| Header doesn't pulse during loading | Missing `shinyTextStyles.shinyText` on `isArgumentsStreaming \|\| isLoading` | | |
| Empty result card under header | Render returned `<div />` instead of `null` when no data | | |
| Layout jump when result arrives | Placeholder dimensions don't match Render dimensions | | |
| Approval dialog never appears | Manifest missing `humanIntervention`, or Intervention not in registry | | |
| Approval click doesn't wait for inline edit | Missing `registerBeforeApprove(id, flushFn)` | | |
| Portal opens but blank | Switch in `Portal/index.tsx` doesn't cover the apiName | | |
| Strings show as `builtins.lobe-foo.apiName.bar` | Missing i18n key in `src/locales/default/plugin.ts` (or not seeded in dev locale files) | | |
| Wrong color shade on `<Text type="secondary">` | `type='secondary'` is lighter than `colorTextSecondary` — pass via `style={{ color: cssVar.colorTextSecondary }}` | | |
@@ -11,86 +11,167 @@
# Environment variables:
# CDP_PORT — Chrome DevTools Protocol port (default: 9222)
# ELECTRON_LOG — Log file path (default: /tmp/electron-dev.log)
# ELECTRON_WAIT_S — Max seconds to wait for Electron process (default: 60)
# RENDERER_WAIT_S — Max seconds to wait for renderer/SPA (default: 60)
# ELECTRON_WAIT_S — Max seconds to wait for CDP to become reachable (default: 90)
# RENDERER_WAIT_S — Max seconds to wait for SPA after CDP is up (default: 60)
# FORCE_KILL_USER — When set to 1, silently kill the user's `bun run dev`
# Electron without confirmation (default: always confirm-by-action)
#
set -euo pipefail
CDP_PORT="${CDP_PORT:-9222}"
ELECTRON_LOG="${ELECTRON_LOG:-/tmp/electron-dev.log}"
ELECTRON_WAIT_S="${ELECTRON_WAIT_S:-60}"
ELECTRON_WAIT_S="${ELECTRON_WAIT_S:-90}"
RENDERER_WAIT_S="${RENDERER_WAIT_S:-60}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
PIDFILE="/tmp/electron-dev-cdp-${CDP_PORT}.pid"
# Project-scoped electron path prefix used for pgrep matching. Any Electron
# binary from this project (main + helpers, with or without --remote-debugging-port)
# starts with this string in its argv[0], so a single substring match catches all.
PROJECT_ELECTRON_PATH="${PROJECT_ROOT}/apps/desktop/node_modules/.pnpm/electron@"
# ── Helpers ──────────────────────────────────────────────────────────
# Get the Electron binary path used by this project
electron_bin_pattern() {
echo "${PROJECT_ROOT}/apps/desktop/node_modules/.pnpm/electron@*/node_modules/electron/dist/Electron.app"
# Print pid + every descendant pid (DFS via pgrep -P).
expand_descendants() {
local pid="$1"
echo "$pid"
local children
children=$(pgrep -P "$pid" 2>/dev/null || true)
for c in $children; do
expand_descendants "$c"
done
}
# Find all PIDs related to the project's Electron dev session
find_electron_pids() {
# Find seed PIDs related to this project's Electron dev session.
# Matches REGARDLESS of whether --remote-debugging-port was passed, so it also
# catches a plain `bun run dev` session the user started outside this script.
find_project_pids() {
local pids=""
# 1. Main Electron process (launched with --remote-debugging-port)
local main_pids
main_pids=$(pgrep -f "Electron\.app.*--remote-debugging-port=${CDP_PORT}" 2>/dev/null || true)
[ -n "$main_pids" ] && pids="$pids $main_pids"
# 1. Any process whose command line mentions this project's electron path
# (covers the main Electron binary AND every Helper subprocess)
local electron_pids
electron_pids=$(pgrep -f "$PROJECT_ELECTRON_PATH" 2>/dev/null || true)
pids="$pids $electron_pids"
# 2. Electron Helper processes (gpu, renderer, utility) spawned from the project's electron binary
local helper_pids
helper_pids=$(pgrep -f "${PROJECT_ROOT}/apps/desktop/node_modules/.*Electron Helper" 2>/dev/null || true)
[ -n "$helper_pids" ] && pids="$pids $helper_pids"
# 3. electron-vite dev server
# 2. electron-vite dev server (narrow match to avoid catching unrelated Vite invocations)
local vite_pids
vite_pids=$(pgrep -f "electron-vite.*dev" 2>/dev/null || true)
[ -n "$vite_pids" ] && pids="$pids $vite_pids"
vite_pids=$(pgrep -f "electron-vite[/.].*\\bdev\\b" 2>/dev/null || true)
pids="$pids $vite_pids"
# 4. PID from pidfile (fallback)
# 3. The launcher subshell from a previous `start` (saved to pidfile)
if [ -f "$PIDFILE" ]; then
local saved_pid
saved_pid=$(cat "$PIDFILE")
if kill -0 "$saved_pid" 2>/dev/null; then
saved_pid=$(cat "$PIDFILE" 2>/dev/null || true)
if [ -n "$saved_pid" ] && kill -0 "$saved_pid" 2>/dev/null; then
pids="$pids $saved_pid"
fi
fi
# Deduplicate
echo "$pids" | tr ' ' '\n' | sort -u | grep -v '^$' | tr '\n' ' ' || true
# 4. Whatever is currently bound to the CDP port — catches strays whose
# binary path doesn't match (e.g. orphaned from a crashed restart)
local port_pid
port_pid=$(lsof -ti tcp:"$CDP_PORT" -sTCP:LISTEN 2>/dev/null || true)
pids="$pids $port_pid"
echo "$pids" | tr ' ' '\n' | sort -u | grep -v '^$' | tr '\n' ' '
}
# Wait for the CDP HTTP endpoint to respond, with a deadline + early bail-out
# if the launcher process died (no point waiting if Electron crashed).
wait_for_cdp() {
local deadline=$(( $(date +%s) + ELECTRON_WAIT_S ))
echo "[electron-dev] Waiting for CDP on port ${CDP_PORT} (up to ${ELECTRON_WAIT_S}s)..."
while [ "$(date +%s)" -lt "$deadline" ]; do
if curl -sf --max-time 2 "http://localhost:${CDP_PORT}/json/version" >/dev/null 2>&1; then
echo "[electron-dev] CDP is reachable."
return 0
fi
# If our launcher subshell died, abort early so we don't hang the full timeout
if [ -f "$PIDFILE" ]; then
local saved_pid
saved_pid=$(cat "$PIDFILE" 2>/dev/null || true)
if [ -n "$saved_pid" ] && ! kill -0 "$saved_pid" 2>/dev/null; then
echo "[electron-dev] Launcher PID $saved_pid is gone before CDP came up."
echo "[electron-dev] Last 30 lines of $ELECTRON_LOG:"
tail -30 "$ELECTRON_LOG" 2>/dev/null || true
return 1
fi
fi
sleep 2
done
echo "[electron-dev] ERROR: CDP did not respond within ${ELECTRON_WAIT_S}s"
echo "[electron-dev] Last 30 lines of $ELECTRON_LOG:"
tail -30 "$ELECTRON_LOG" 2>/dev/null || true
return 1
}
# After CDP is up, wait until the SPA renders interactive elements.
wait_for_renderer() {
local deadline=$(( $(date +%s) + RENDERER_WAIT_S ))
echo "[electron-dev] Waiting for SPA to load (up to ${RENDERER_WAIT_S}s)..."
while [ "$(date +%s)" -lt "$deadline" ]; do
local snap
snap=$(agent-browser --cdp "$CDP_PORT" snapshot -i 2>&1 || true)
if echo "$snap" | grep -qE '\b(link|button)\b'; then
echo "[electron-dev] Renderer ready."
return 0
fi
sleep 2
done
echo "[electron-dev] WARNING: Renderer not interactive within ${RENDERER_WAIT_S}s — proceeding anyway."
return 0
}
# ── Commands ─────────────────────────────────────────────────────────
do_stop() {
echo "[electron-dev] Stopping Electron dev environment..."
local pids
pids=$(find_electron_pids)
local seed_pids
seed_pids=$(find_project_pids)
if [ -z "$pids" ]; then
echo "[electron-dev] No Electron processes found."
# Expand to include all descendants — catches helpers spawned by the main
# process AFTER our pgrep snapshot, and the launcher's child node/electron-vite
# process tree.
local all_pids=""
for pid in $seed_pids; do
all_pids="$all_pids $(expand_descendants "$pid")"
done
all_pids=$(echo "$all_pids" | tr ' ' '\n' | sort -u | grep -v '^$' | tr '\n' ' ')
if [ -z "$all_pids" ]; then
echo "[electron-dev] No project Electron/vite processes found."
else
echo "[electron-dev] Killing PIDs: $pids"
for pid in $pids; do
local count
count=$(echo "$all_pids" | tr ' ' '\n' | grep -c .)
echo "[electron-dev] Sending SIGTERM to $count process(es): $all_pids"
for pid in $all_pids; do
kill "$pid" 2>/dev/null || true
done
# Wait up to 5s for graceful exit, then force-kill survivors
# Wait up to 5s for graceful exit
local waited=0
while [ $waited -lt 5 ]; do
local alive=""
for pid in $pids; do
kill -0 "$pid" 2>/dev/null && alive="$alive $pid"
local any_alive=0
for pid in $all_pids; do
if kill -0 "$pid" 2>/dev/null; then any_alive=1; break; fi
done
[ -z "$alive" ] && break
[ "$any_alive" = "0" ] && break
sleep 1
waited=$((waited + 1))
done
# Force-kill any remaining
for pid in $pids; do
# SIGKILL anyone still alive
for pid in $all_pids; do
if kill -0 "$pid" 2>/dev/null; then
echo "[electron-dev] Force-killing PID $pid"
kill -9 "$pid" 2>/dev/null || true
@@ -98,7 +179,27 @@ do_stop() {
done
fi
# Also close any agent-browser sessions connected to this port
# Belt-and-suspenders: anything still bound to the CDP port goes away
local port_pid
port_pid=$(lsof -ti tcp:"$CDP_PORT" -sTCP:LISTEN 2>/dev/null || true)
if [ -n "$port_pid" ]; then
echo "[electron-dev] Port $CDP_PORT still bound by PID $port_pid; force-killing"
# shellcheck disable=SC2086
kill -9 $port_pid 2>/dev/null || true
fi
# Also re-sweep the project's electron processes — sometimes the OS spawns
# new helpers during shutdown that didn't exist when we first enumerated.
local stragglers
stragglers=$(pgrep -f "$PROJECT_ELECTRON_PATH" 2>/dev/null || true)
if [ -n "$stragglers" ]; then
echo "[electron-dev] Cleaning up stragglers: $stragglers"
for pid in $stragglers; do
kill -9 "$pid" 2>/dev/null || true
done
fi
# Close any agent-browser sessions connected to this port
agent-browser --cdp "$CDP_PORT" close --all 2>/dev/null || true
rm -f "$PIDFILE"
@@ -107,113 +208,84 @@ do_stop() {
do_status() {
local pids
pids=$(find_electron_pids)
pids=$(find_project_pids)
if [ -z "$pids" ]; then
echo "[electron-dev] Electron is NOT running."
echo "[electron-dev] No project Electron processes found."
return 1
fi
echo "[electron-dev] Electron is running (PIDs: $pids)"
echo "[electron-dev] Project processes: $pids"
# Check CDP connectivity
if agent-browser --cdp "$CDP_PORT" get url >/dev/null 2>&1; then
if curl -sf --max-time 2 "http://localhost:${CDP_PORT}/json/version" >/dev/null 2>&1; then
local url
url=$(agent-browser --cdp "$CDP_PORT" get url 2>&1 | tail -1)
url=$(agent-browser --cdp "$CDP_PORT" get url 2>&1 | tail -1 || echo "?")
echo "[electron-dev] CDP port ${CDP_PORT} is reachable. URL: $url"
return 0
else
echo "[electron-dev] CDP port ${CDP_PORT} is NOT reachable (Electron may still be loading)."
echo "[electron-dev] CDP port ${CDP_PORT} is NOT reachable (no --remote-debugging-port, or still loading)."
return 2
fi
}
wait_for_electron() {
echo "[electron-dev] Waiting for Electron process (up to ${ELECTRON_WAIT_S}s)..."
local elapsed=0
local interval=3
while [ $elapsed -lt "$ELECTRON_WAIT_S" ]; do
if strings "$ELECTRON_LOG" 2>/dev/null | grep -q "starting electron"; then
echo "[electron-dev] Electron process started."
return 0
fi
sleep "$interval"
elapsed=$((elapsed + interval))
echo "[electron-dev] Still waiting... (${elapsed}/${ELECTRON_WAIT_S}s)"
done
echo "[electron-dev] ERROR: Electron did not start within ${ELECTRON_WAIT_S}s"
echo "[electron-dev] Last 20 lines of log:"
tail -20 "$ELECTRON_LOG" 2>/dev/null || true
return 1
}
wait_for_renderer() {
echo "[electron-dev] Waiting for renderer/SPA to load (up to ${RENDERER_WAIT_S}s)..."
# Initial delay — renderer needs time to bootstrap
sleep 10
local elapsed=10
local interval=5
while [ $elapsed -lt "$RENDERER_WAIT_S" ]; do
if agent-browser --cdp "$CDP_PORT" wait 2000 >/dev/null 2>&1; then
# Check if interactive elements are present (SPA loaded)
local snap
snap=$(agent-browser --cdp "$CDP_PORT" snapshot -i 2>&1 || true)
if echo "$snap" | grep -qE 'link |button '; then
echo "[electron-dev] Renderer ready (interactive elements found)."
return 0
fi
fi
sleep "$interval"
elapsed=$((elapsed + interval))
echo "[electron-dev] SPA still loading... (${elapsed}/${RENDERER_WAIT_S}s)"
done
echo "[electron-dev] WARNING: Timed out waiting for renderer, proceeding anyway."
return 0
}
do_start() {
# If already running and healthy, skip
local status_ok=0
do_status >/dev/null 2>&1 || status_ok=$?
if [ "$status_ok" -eq 0 ]; then
echo "[electron-dev] Electron is already running and CDP is reachable. Skipping start."
echo "[electron-dev] Use 'restart' to force a fresh session, or 'stop' to tear down."
# Already up and CDP is reachable → nothing to do
if curl -sf --max-time 2 "http://localhost:${CDP_PORT}/json/version" >/dev/null 2>&1; then
echo "[electron-dev] CDP already reachable on port $CDP_PORT. Skipping start."
echo "[electron-dev] Use 'restart' to force a fresh session."
return 0
fi
# Clean up any stale processes
# Detect the user's existing dev session (or stale processes) BEFORE killing
local existing
existing=$(find_project_pids)
if [ -n "$existing" ]; then
echo "[electron-dev] Existing project Electron/vite processes detected:"
echo "$existing" | tr ' ' '\n' | sed 's/^/[electron-dev] PID /'
echo "[electron-dev] Tearing them down so we can start a CDP-enabled session..."
fi
do_stop
# Start fresh
# Wait for port + user-data-dir locks to release. Without this, the new
# Electron may fail with "user data directory in use" or fail to bind CDP.
local waited=0
while [ $waited -lt 10 ]; do
if ! lsof -i tcp:"$CDP_PORT" >/dev/null 2>&1 \
&& ! pgrep -f "$PROJECT_ELECTRON_PATH" >/dev/null 2>&1; then
break
fi
[ $waited -eq 0 ] && echo "[electron-dev] Waiting for port + Electron locks to release..."
sleep 1
waited=$((waited + 1))
done
echo "[electron-dev] Starting Electron dev server..."
echo "[electron-dev] Project: $PROJECT_ROOT"
echo "[electron-dev] Project: $PROJECT_ROOT"
echo "[electron-dev] CDP port: $CDP_PORT"
echo "[electron-dev] Log: $ELECTRON_LOG"
echo "[electron-dev] Log: $ELECTRON_LOG"
: > "$ELECTRON_LOG" # Truncate log
(
cd "$PROJECT_ROOT/apps/desktop" && \
ELECTRON_ENABLE_LOGGING=1 npx electron-vite dev -- --remote-debugging-port="$CDP_PORT" \
>> "$ELECTRON_LOG" 2>&1
) &
local bg_pid=$!
echo "$bg_pid" > "$PIDFILE"
echo "[electron-dev] Background PID: $bg_pid"
# Launch in a new session (setsid) so the whole process tree shares a PGID
# we can later signal in one shot. `setsid bash -c '... exec ...' &` keeps
# the bash shell as the session leader; its PID is what we save.
setsid bash -c "
cd '$PROJECT_ROOT/apps/desktop'
exec npx electron-vite dev -- --remote-debugging-port=$CDP_PORT
" >> "$ELECTRON_LOG" 2>&1 < /dev/null &
local launcher_pid=$!
echo "$launcher_pid" > "$PIDFILE"
echo "[electron-dev] Launcher PID (session leader): $launcher_pid"
# Wait for Electron process to start
if ! wait_for_electron; then
echo "[electron-dev] Failed to start. Cleaning up..."
if ! wait_for_cdp; then
echo "[electron-dev] Failed to bring up CDP. Cleaning up..."
do_stop
return 1
fi
# Wait for renderer to be interactive
if ! wait_for_renderer; then
echo "[electron-dev] Renderer not ready, but Electron is running. You may need to wait more."
echo "[electron-dev] Renderer not interactive — you may need to wait more."
fi
echo "[electron-dev] Ready! Use: agent-browser --cdp $CDP_PORT snapshot -i"
@@ -221,7 +293,7 @@ do_start() {
do_restart() {
do_stop
sleep 2
sleep 1
do_start
}
@@ -235,10 +307,12 @@ case "${1:-help}" in
*)
echo "Usage: $0 {start|stop|status|restart}"
echo ""
echo " start — Start Electron dev with CDP (idempotent, skips if already running)"
echo " stop — Kill all Electron dev processes (main + helpers + vite)"
echo " status — Check if Electron is running and CDP is reachable"
echo " restart — Stop then start"
echo " start — Start Electron dev with CDP. Detects + tears down any"
echo " existing project Electron (e.g. \`bun run dev\`) first."
echo " stop — Kill all project Electron/vite processes (main + helpers"
echo " + descendants), with SIGTERM → 5s wait → SIGKILL fallback."
echo " status — Check if Electron is running and CDP is reachable."
echo " restart — Stop then start."
exit 1
;;
esac
@@ -0,0 +1,44 @@
---
name: 'source-command-dedupe'
description: 'Find duplicate GitHub issues'
---
# source-command-dedupe
Use this skill when the user asks to run the migrated source command `dedupe`.
## Command Template
Find up to 3 likely duplicate issues for a given GitHub issue.
To do this, follow these steps precisely:
1. Use an agent to check if the Github issue (a) is closed, (b) does not need to be deduped (eg. because it is broad product feedback without a specific solution, or positive feedback), or (c) already has a duplicates comment that you made earlier. If so, do not proceed.
2. Use an agent to view a Github issue, and ask the agent to return a summary of the issue
3. Then, launch 5 parallel agents to search Github for duplicates of this issue, using diverse keywords and search approaches, using the summary from #1
4. Next, feed the results from #1 and #2 into another agent, so that it can filter out false positives, that are likely not actually duplicates of the original issue. If there are no duplicates remaining, do not proceed.
5. Finally, comment back on the issue with a list of up to three duplicate issues (or zero, if there are no likely duplicates)
Notes (be sure to tell this to your agents, too):
- Use `gh` to interact with Github, rather than web fetch
- Do not use other tools, beyond `gh` (eg. don't use other MCP servers, file edit, etc.)
- Make a todo list first
- For your comment, follow the following format precisely (assuming for this example that you found 3 suspected duplicates):
---
Found 3 possible duplicate issues:
1. <link to issue>
2. <link to issue>
3. <link to issue>
This issue will be automatically closed as a duplicate in 3 days.
- If your issue is a duplicate, please close it and 👍 the existing issue instead
- To prevent auto-closure, add a comment or 👎 this comment
> 🤖 Generated with Codex
---
@@ -117,7 +117,7 @@ it('should handle tool calls', async () => {
toolCalls: [
{
id: 'call_123',
name: 'lobe-web-browsing____search____builtin',
name: 'lobe-web-browsing____search',
arguments: JSON.stringify({ query: 'weather' }),
},
],
+19 -259
View File
@@ -5,6 +5,8 @@ description: "Version release workflow. Use when the user mentions 'release', 'h
# Version Release Workflow
This skill is a router. The detailed steps live in `reference/`.
## Scope Boundary (Important)
This skill is only for:
@@ -28,68 +30,12 @@ The primary development branch is **canary**. All day-to-day development happens
Only two release types are used in practice (major releases are extremely rare and can be ignored):
| Type | Use Case | Frequency | Source Branch | PR Title Format | Version |
| ----- | ---------------------------------------------- | --------------------- | -------------- | ------------------------------------ | ------------- |
| Minor | Feature iteration release | \~Every 4 weeks | canary | `🚀 release: v{x.y.0}` | Manually set |
| Patch | Weekly release / hotfix / model / DB migration | \~Weekly or as needed | canary or main | Custom (e.g. `🚀 release: 20260222`) | Auto patch +1 |
| Type | Use Case | Frequency | Source Branch | PR Title Format | Version | Reference |
| ----- | ---------------------------------------------- | --------------------- | -------------- | ------------------------------------ | ------------- | -------------------------------------- |
| Minor | Feature iteration release | \~Every 4 weeks | canary | `🚀 release: v{x.y.0}` | Manually set | `reference/minor-release.md` |
| Patch | Weekly release / hotfix / model / DB migration | \~Weekly or as needed | canary or main | Custom (e.g. `🚀 release: 20260222`) | Auto patch +1 | `reference/patch-release-scenarios.md` |
## Minor Release Workflow
Used to publish a new minor version (e.g. `v2.2.0`), roughly every 4 weeks.
### Steps
1. **Create a release branch from canary**
```bash
git checkout canary
git pull origin canary
git checkout -b release/v{version}
git push -u origin release/v{version}
```
2. **Determine the version number** — Read the current version from `package.json` and compute the next minor version (e.g. 2.1.x -> 2.2.0)
3. **Create a PR to main**
```bash
gh pr create \
--title "🚀 release: v{version}" \
--base main \
--head release/v{version} \
--body "## 📦 Release v{version} ..."
```
> \[!IMPORTANT]
> The PR title must strictly match the `🚀 release: v{x.y.z}` format. CI uses a regex on this title to determine the exact version number.
4. **Automatic trigger after merge**: `auto-tag-release` detects the title format and uses the version number from the title to complete the release.
### Scripts
```bash
bun run release:branch # Interactive
bun run release:branch --minor # Directly specify minor
```
## Patch Release Workflow
Version number is automatically bumped by patch +1. There are 4 common scenarios:
| Scenario | Source Branch | Branch Naming | Description |
| ------------------- | ------------- | ----------------------------- | ------------------------------------------------ |
| Weekly Release | canary | `release/weekly-{YYYYMMDD}` | Weekly release train, canary -> main |
| Bug Hotfix | main | `hotfix/v{version}-{hash}` | Emergency bug fix |
| New Model Launch | canary | Community PR merged directly | New model launch, triggered by PR title prefix |
| DB Schema Migration | main | `release/db-migration-{name}` | Database migration, requires dedicated changelog |
All scenarios auto-bump patch +1. Patch PR titles do not need a version number. See `reference/patch-release-scenarios.md` for detailed steps per scenario.
### Scripts
```bash
bun run hotfix:branch # Hotfix scenario
```
For writing the release-note body (any release type), see `reference/release-notes-style.md`.
## Auto-Release Trigger Rules (`auto-tag-release.yml`)
@@ -127,7 +73,7 @@ PRs that don't match any conditions above (e.g. `docs`, `chore`, `ci`, `test`) w
When the user requests a release:
### Precheck
### Precheck (applies to all release types)
Before creating the release branch, verify the source branch:
@@ -135,204 +81,18 @@ Before creating the release branch, verify the source branch:
- **All other release/hotfix branches**: must branch from `main`; run `git merge-base --is-ancestor main <branch> && echo OK`
- If the branch is based on the wrong source, recreate from the correct base
### Minor Release
### Routing
1. Read `package.json` to get the current version and compute the next minor version
2. Create a `release/v{version}` branch from canary
3. Push and create PR — **title must be `🚀 release: v{version}`**
4. Inform the user that merge will auto-trigger release
Pick the right reference and follow it end-to-end:
### Patch Release
- **Minor release** → `reference/minor-release.md`
- **Patch release** (weekly / hotfix / model launch / DB migration) → `reference/patch-release-scenarios.md`
- **Writing the PR body / release notes** (any release type) → `reference/release-notes-style.md`
Choose workflow by scenario (see `reference/patch-release-scenarios.md`):
### Hard Rules (apply to every release type)
- **Weekly Release**: create `release/weekly-{YYYYMMDD}` from canary; use `git log main..canary` for release note inputs; title like `🚀 release: 20260222`
- **Bug Hotfix**: create `hotfix/` from main; use gitmoji prefix title (e.g. `🐛 fix: ...`)
- **New Model Launch**: community PRs trigger automatically via title prefix (`feat` / `style`)
- **DB Migration**: create `release/db-migration-{name}` from main; cherry-pick migration commits; include dedicated migration notes
### Hard Rules
- **Do NOT** manually modify `package.json` version
- **Do NOT** manually create tags
- Minor PR title format is strict
- Patch PRs do not need explicit version number
- Keep release facts accurate; do not invent metrics or availability statements
## GitHub Release Changelog Standard (Long-Form Style)
Use this section for writing **GitHub Release notes** (or release PR body when the PR body is intended to become release notes).\
Do not use this as `docs/changelog` page guidance.
### Positioning
This release-note style is:
1. **Data-backed at the top** (date, range, key metrics)
2. **Narrative first, then structured detail**
3. **Deep but scannable** (clear sectioning + compact bullets)
4. **Contributor-forward** (credits are part of the release story)
### Required Inputs Before Writing
Collect these inputs first:
1. Compare range (`<prev_tag>...<current_tag>`)
2. Release metrics (commits, merged PRs, resolved issues, contributors, optional files/insertions/deletions)
3. High-impact changes by domain (core loop, platform/gateway, UX, tooling, security, reliability)
4. Contributor list (with standout contributions if known)
5. Known risks / migrations / rollout notes (if any)
If metrics cannot be reliably computed, omit unknown numbers instead of guessing.
### Canonical Structure
Follow this section order unless the user asks otherwise:
1. `# 🚀 LobeHub v<x.y.z> (<YYYYMMDD>)`
2. Metadata lines:
- `Release Date`
- `Since <Previous Version>` metrics
3. One quoted release thesis (single paragraph, 1-2 lines)
4. `## ✨ Highlights` (6-12 bullets for major releases; 3-8 for weekly)
5. Domain blocks with optional `###` subsections:
- `## 🏗️ Core Agent & Architecture` (or equivalent product core)
- `## 📱 Platforms / Integrations`
- `## 🖥️ CLI & User Experience`
- `## 🔧 Tooling`
- `## 🔒 Security & Reliability`
- `## 📚 Documentation` (optional if meaningful)
6. `## 👥 Contributors`
7. `**Full Changelog**: <prev>...<current>`
Use `---` separators between major blocks for long releases.
### Writing Rules (Hard)
1. **No fabricated metrics**: all numbers must be traceable.
2. **No vague headline bullets**: each bullet must include capability + impact.
3. **No internal-only framing**: phrase from user/operator perspective.
4. **Security must be explicit** when security-sensitive fixes are present.
5. **PR/issue linkage**: use `(#1234)` when IDs are available.
6. **Terminology consistency**: same feature/provider name across sections.
7. **Do not bury migration or breaking changes**: elevate to dedicated section or callout.
### Style Rules (Long-Form)
1. Start with an "everyday use" framing, not implementation internals.
2. Mix narrative sentence + evidence bullets.
3. Keep bullets compact but informative:
- Good: `**Fast Mode (`/fast`)** — Priority routing for OpenAI and Anthropic, reducing latency on supported models. (#6875, #6960)`
4. Use bold only for capability names, not for whole sentences.
5. Keep heading depth <= 3 levels.
### Release Size Heuristics
- **Minor / major milestone release**
- Include full structure with multiple domain blocks.
- `Highlights` usually 8-12 bullets.
- **Weekly patch release**
- Keep full skeleton but reduce subsection count.
- `Highlights` usually 4-8 bullets.
- **DB migration release**
- Keep concise.
- Must include `Migration overview`, operator impact, and rollback/backup note.
### Contributor Ordering
Render contributors as a **single flat list** (no separate "Community" / "Core Team" subsections). Order: **community contributors first, team members after**. Within each group, sort by PR count desc. Bots (`@lobehubbot`, `renovate[bot]`) go on a separate "maintenance" line.
**LobeHub team roster** — anyone in this list is a team member; anyone not in this list is a community contributor:
- @arvinxx
- @Innei
- @tjx666 (commit author name: YuTengjing)
- @LiJian
- @Neko
- @Rdmclin2
- @AmAzing129
- @sudongyuer
- @rivertwilight
- @CanisMinor
> **Resolving handles** — git author names (e.g. `YuTengjing`) are not always the GitHub handle. Verify via `gh pr view <PR> --json author` or `gh api search/users -f q='<email>'` before listing.
If a new contributor appears who is not on this list, treat them as community by default and ask the user whether to add them to the roster.
### GitHub Release Changelog Template
```md
# 🚀 LobeHub v<x.y.z> (<YYYYMMDD>)
**Release Date:** <Month DD, YYYY>
**Since <Previous Version>:** <N merged PRs> · <N resolved issues> · <N contributors>
> <One release thesis sentence: what this release unlocks in practice.>
---
## ✨ Highlights
- **<Capability A>** — <What changed and why it matters>. (#1234)
- **<Capability B>** — <What changed and why it matters>. (#2345)
- **<Capability C>** — <What changed and why it matters>. (#3456)
---
## 🏗️ Core Product & Architecture
### <Subdomain>
- <Concrete change + impact>. (#...)
- <Concrete change + impact>. (#...)
---
## 📱 Platforms / Integrations
- <Platform update + impact>. (#...)
- <Compatibility/reliability fix + impact>. (#...)
---
## 🖥️ CLI & User Experience
- <User-facing workflow improvement>. (#...)
- <Quality-of-life fix>. (#...)
---
## 🔧 Tooling
- <Tool/runtime improvement>. (#...)
---
## 🔒 Security & Reliability
- **Security:** <hardening or vulnerability fix>. (#...)
- **Reliability:** <stability/performance behavior improvement>. (#...)
---
## 👥 Contributors
Huge thanks to **<N contributors>** who shipped **<N merged PRs>** this cycle.
@<community-handle> · @<community-handle> · @<team-handle> · @<team-handle>
Plus @lobehubbot and renovate[bot] for maintenance.
---
**Full Changelog**: <previous_tag>...<current_tag>
```
### Quick Checklist
- [ ] Uses top metadata and a clear release thesis
- [ ] Includes `Highlights` plus domain-grouped sections
- [ ] Every major bullet states both change and user/operator impact
- [ ] Security and reliability updates are explicitly surfaced (when present)
- [ ] Contributor credits and compare range are included
- [ ] All numbers and claims are verifiable
- **Do NOT** manually modify `package.json` version — CI handles it.
- **Do NOT** manually create tags — CI handles them.
- Minor PR title format is strict (`🚀 release: v{x.y.z}`).
- Patch PRs do not need an explicit version number.
- Keep release facts accurate; do not invent metrics or availability statements. Release-note inputs (compare base, PR refs, contributor list) **must be derived from `git`** per `reference/release-notes-style.md` § Computing Inputs — never from memory or descriptions.
@@ -1,4 +1,4 @@
# 🚀 LobeHub v2.1.50 (20260416)
# 🚀 LobeHub Release (20260416)
**Release Date:** April 20, 2026\
**Migration Scope:** Agent benchmark data model bootstrap (5 new tables, 2 new indexes)
@@ -7,14 +7,6 @@
---
## ✨ Highlights
- **Benchmark Lifecycle Schema** — Added a relational model that tracks benchmark setup, runs, per-topic execution, and record outputs end-to-end.
- **Queryability Upgrade** — Added indexes for run status and benchmark-topic joins, improving operational queries in dashboard and debugging workflows.
- **Safer Operator Rollout** — Migration is startup-driven and backward-compatible with existing non-benchmark chat workflows.
---
## 🗄️ Migration Overview
Added tables:
@@ -1,4 +1,4 @@
# 🚀 LobeHub v2.1.54 (20260427)
# 🚀 LobeHub Release (20260427)
**Hotfix Scope:** Agent topic-switching regression — stale chat state on agent change
@@ -1,7 +1,7 @@
# 🚀 LobeHub v2.1.50 (20260420)
# 🚀 LobeHub Release (20260420)
**Release Date:** April 20, 2026\
**Since v2026.04.13:** 96 commits · 58 merged PRs · 31 resolved issues · 17 contributors
**Since previous release:** 96 commits · 58 merged PRs · 31 resolved issues · 17 contributors
> This weekly release focuses on reducing friction in everyday agent work: faster model routing, smoother gateway behavior, stronger task continuity, and clearer operator diagnostics when something goes wrong.
@@ -77,4 +77,4 @@
---
**Full Changelog**: v2026.04.13...v2026.04.20
**Full Changelog**: <previous-tag>...<current-tag>
@@ -0,0 +1,47 @@
# Minor Release Workflow
Used to publish a new minor version (e.g. `v2.2.0`), roughly every 4 weeks. The PR title carries the exact version number; CI parses it to drive the rest of the release.
## Steps
1. **Create a release branch from canary**
```bash
git checkout canary
git pull origin canary
git checkout -b release/v{version}
git push -u origin release/v{version}
```
2. **Determine the version number** — Read the current version from `package.json` and compute the next minor version (e.g. `2.1.x` → `2.2.0`).
3. **Create a PR to main**
```bash
gh pr create \
--title "🚀 release: v{version}" \
--base main \
--head release/v{version} \
--body-file release_body.md
```
> \[!IMPORTANT]
> The PR title must strictly match the `🚀 release: v{x.y.z}` format. CI uses a regex on this title to determine the exact version number.
4. **Write the PR body as release notes** — Follow `release-notes-style.md`. Compare base is the latest semver tag on main (`git describe --tags --abbrev=0 origin/main`).
5. **Automatic trigger after merge** — `auto-tag-release` detects the title format, uses the version number from the title, bumps `package.json`, tags `v{x.y.z}`, creates the GitHub Release, and dispatches `sync-main-to-canary`.
## Scripts
```bash
bun run release:branch # Interactive
bun run release:branch --minor # Directly specify minor
```
## Hard Rules (specific to Minor)
- PR title format is **strict**: `🚀 release: v{x.y.z}`. Any deviation falls through to patch detection.
- Do **NOT** manually modify `package.json` version — CI will bump it.
- Do **NOT** manually create the tag — CI will tag.
- Highlights bullet count is usually 812 (see `release-notes-style.md` size heuristics).
@@ -21,12 +21,16 @@ git push -u origin release/weekly-{YYYYMMDD}
2. **Scan changes and write changelog**
Compute the previous tag from main first — never reuse the last weekly's tag, since hotfixes published in between will be missed:
```bash
git log main..canary --oneline
git diff main...canary --stat
git fetch origin main canary --tags
PREV_TAG=$(git describe --tags --abbrev=0 origin/main --match 'v*.*.*' --exclude '*-canary*' --exclude '*-nightly*')
git log "$PREV_TAG..origin/release/weekly-{YYYYMMDD}" --oneline --no-merges
git diff "$PREV_TAG...origin/release/weekly-{YYYYMMDD}" --stat
```
Write a user-facing changelog following the format in `patch-release-changelog-example.md`.
Then follow `./release-notes-style.md` § **Computing Inputs (Hard Rules)** to derive PR refs, metrics, and contributors. Every `(#XXXX)` in the body must come from actual commit subjects in this range — never inferred from descriptions.
3. **Create PR to main** with the changelog as the PR body
@@ -0,0 +1,316 @@
# GitHub Release Changelog Standard (Long-Form Style)
Use this guide for **GitHub Release notes** — the body of a release PR that becomes the GitHub Release after merge. Do **not** use it for `docs/changelog/*.mdx` website pages (load `../../docs-changelog/SKILL.md` instead).
## Positioning
This release-note style is:
1. **Data-backed at the top** (date, range, key metrics)
2. **Narrative first, then structured detail**
3. **Deep but scannable** (clear sectioning + compact bullets)
4. **Contributor-forward** (credits are part of the release story)
## Required Inputs Before Writing
Collect these inputs first:
1. Compare range (`<prev_tag>...<current_tag>`)
2. Release metrics (commits, merged PRs, resolved issues, contributors, optional files/insertions/deletions)
3. High-impact changes by domain (core loop, platform/gateway, UX, tooling, security, reliability)
4. Contributor list (with standout contributions if known)
5. Known risks / migrations / rollout notes (if any)
If metrics cannot be reliably computed, omit unknown numbers instead of guessing.
## Computing Inputs (Hard Rules — Verify, Never Guess)
> Hallucinated PR numbers and wrong "Since v..." bases are the #1 failure mode of this skill. Every number and every `(#XXXX)` must come from `git`, never from memory or inference.
### 1. Compare base = latest semver tag on `main`
Do **not** eyeball the tag list or pick the "last weekly" PR. Compute it:
```bash
git fetch origin main canary --tags
PREV_TAG=$(git describe --tags --abbrev=0 origin/main --match 'v*.*.*' --exclude '*-canary*' --exclude '*-nightly*')
echo "$PREV_TAG"
```
Sanity check that the tag is reachable from the release branch:
```bash
git merge-base --is-ancestor "$PREV_TAG" origin/release/weekly-{YYYYMMDD} && echo OK
```
If the check fails, stop and ask the user — the release branch is based on the wrong source.
> **Why not "the last weekly release PR"?** Hotfixes (`v2.1.54`, `v2.1.55`, …) merge directly into main between weeklies. They get back-merged via `sync-main-to-canary`, so the latest semver tag on main _is_ the correct previous release for both weekly and minor flows. Picking the previous weekly's tag will silently undercount and put a stale version in "Since v…".
### 2. PR refs must come from commit subjects — never from descriptions
Compute the canonical set:
```bash
git log "$PREV_TAG..origin/release/weekly-{YYYYMMDD}" \
--pretty=format:'%s' --no-merges \
| grep -oE '\(#[0-9]+\)$' \
| sort -u > /tmp/release_prs.txt
```
Hard rules:
- Every `(#XXXX)` you write in the body **must** appear in `/tmp/release_prs.txt`. No exceptions.
- Never infer a PR number from a feature description. If you remember "the KB BM25 PR was around #14501", that memory is wrong about half the time. Look up the commit hash by feature keyword and read its actual subject.
- If your terminal truncates long subjects (any wrapper that compresses output, e.g. `rtk`), bypass it. With `rtk` use `rtk proxy git log …`. Verify with `wc -l /tmp/release_prs.txt` — the count must match `git log $PREV_TAG..HEAD --no-merges --pretty=format:'%h' | wc -l` minus the few commits without a PR ref. A mismatch of >5% means subjects are being silently truncated.
### 3. Metrics must come from git counts
```bash
PR_COUNT=$(wc -l < /tmp/release_prs.txt | tr -d ' ')
COMMIT_COUNT=$(git log "$PREV_TAG..origin/release/weekly-{YYYYMMDD}" --no-merges --pretty=format:'%h' | wc -l | tr -d ' ')
CONTRIBUTOR_COUNT=$(git log "$PREV_TAG..origin/release/weekly-{YYYYMMDD}" --no-merges --pretty=format:'%an' \
| sort -u \
| grep -viE '^(lobehubbot|LobeHub Bot|renovate\[bot\])$' \
| wc -l | tr -d ' ')
```
If a number cannot be confidently derived, omit it — never guess.
### 4. Author-to-handle resolution
Git `%an` is the commit author display name, not the GitHub handle. For each author you mention, confirm the handle:
```bash
gh pr view "$PR_NUMBER" --repo lobehub/lobe-chat --json author --jq '.author.login'
```
Use the result for `@handle`. Then classify each author per the `LobeHub team roster` below; community first, team after.
### 5. Pre-publish verification (mandatory)
Before `gh pr create` / `gh pr edit --body-file`, diff body PR refs against the canonical set:
```bash
grep -oE '#[0-9]+' release_body.md | sort -u > /tmp/body_prs.txt
sed 's/[()]//g' /tmp/release_prs.txt > /tmp/release_prs_clean.txt
echo "=== In body but NOT in actual range (must be EMPTY) ==="
comm -23 /tmp/body_prs.txt /tmp/release_prs_clean.txt
```
Empty diff = OK. Any output = the body cites a PR that wasn't merged in this range. Stop and fix before publishing.
Also verify the metrics line in the body matches the computed values (`PR_COUNT`, `CONTRIBUTOR_COUNT`) and that `**Full Changelog**` uses `$PREV_TAG`, not some older tag.
## Canonical Structure (Long-Form: Minor / Weekly)
Follow this section order for **Minor** and **Weekly** releases unless the user asks otherwise. For **Hotfix** and **DB Migration**, see § Variants for Shorter Releases below — the canonical structure does not apply.
1. `# 🚀 LobeHub Release (<YYYYMMDD>)`
2. Metadata lines:
- `Release Date`
- `Since <Previous Version>` metrics
3. One quoted release thesis (single paragraph, 1-2 lines)
4. `## ✨ Highlights` (6-12 bullets for major releases; 3-8 for weekly)
5. Domain blocks with optional `###` subsections:
- `## 🏗️ Core Agent & Architecture` (or equivalent product core)
- `## 📱 Platforms / Integrations`
- `## 🖥️ CLI & User Experience`
- `## 🔧 Tooling`
- `## 🔒 Security & Reliability`
- `## 📚 Documentation` (optional if meaningful)
6. `## 👥 Contributors`
7. `**Full Changelog**: <prev>...<current>`
Use `---` separators between major blocks for long releases.
## Variants for Shorter Releases
The Canonical Structure above is for **long-form** (Minor / Weekly). Two short-form variants override it.
### Hotfix Variant
A hotfix targets one regression and ships fast. The body is short and operator-focused — no Highlights, no domain blocks, no Contributors line.
Required sections, in order:
1. `# 🚀 LobeHub Release (<YYYYMMDD>)`
2. `**Hotfix Scope:**` — one line summarizing the regression scope (e.g. `Agent topic-switching regression — stale chat state on agent change`). Replaces the long-form `Release Date` / `Since vX.Y.Z` metrics.
3. One quoted thesis (single paragraph, 1-2 lines) describing what is now restored.
4. `## 🐛 What's Fixed` — 1-3 bullets, each `**<symptom>** — <fix in one sentence>. (#PR)`. No root-cause prose; that lives in the commit message.
5. `## ⚙️ Upgrade` — short notes for self-hosted (pull image / restart, schema or env changes) and cloud (usually "applied automatically").
6. `## 👥 Owner` — single `@handle` for the PR author, resolved via `gh pr view "$PR" --json author --jq '.author.login'`. Never hardcoded.
Hard rules specific to hotfix:
- **No Highlights / domain blocks / Contributors / Full Changelog** — these add noise to a one-shot fix.
- **No metric line** — `Since vX.Y.Z` doesn't apply; the body cites the single PR (or 1-3 PRs) directly.
- **Owner ≠ Contributors** — one author, listed under § Owner. Not a flat handle list.
- See `changelog-example/hotfix.md` for the canonical template.
### DB Migration Variant
Database schema changes that need to be released independently. Operator impact is the headline.
Required sections, in order:
1. `# 🚀 LobeHub Release (<YYYYMMDD>)` + scope line
2. **Migration overview** — what tables / columns are added, modified, or removed
3. **Operator impact** — backwards-compatible? required actions for self-hosted?
4. **Rollback / backup note** — how to recover
5. `## 👥 Owner` — single PR author, resolved via `gh pr view`
See `changelog-example/db-migration.md` for the canonical template.
## Writing Rules (Hard)
1. **No fabricated metrics**: all numbers must be traceable.
2. **No vague headline bullets**: each bullet must include capability + impact.
3. **No internal-only framing**: phrase from user/operator perspective.
4. **Security must be explicit** when security-sensitive fixes are present.
5. **PR/issue linkage**: use `(#1234)` when IDs are available.
6. **Terminology consistency**: same feature/provider name across sections.
7. **Do not bury migration or breaking changes**: elevate to dedicated section or callout.
## Style Rules (Long-Form)
1. Start with an "everyday use" framing, not implementation internals.
2. Mix narrative sentence + evidence bullets.
3. Keep bullets compact but informative:
- Good: `**Fast Mode (`/fast`)** — Priority routing for OpenAI and Anthropic, reducing latency on supported models. (#6875, #6960)`
4. Use bold only for capability names, not for whole sentences.
5. Keep heading depth ≤ 3 levels.
## Release Size Heuristics
- **Minor / major milestone release**
- Long-form structure with multiple domain blocks.
- `Highlights` usually 8-12 bullets.
- **Weekly patch release**
- Long-form skeleton with reduced subsection count.
- `Highlights` usually 4-8 bullets.
- **Hotfix release**
- Short-form (see § Variants → Hotfix). No Highlights, no domain blocks, no Contributors.
- 1-3 fix bullets. Body should fit on one screen.
- **DB migration release**
- Short-form (see § Variants → DB Migration).
- Must include `Migration overview`, operator impact, and rollback/backup note.
## Contributor Ordering
Render contributors as a **single flat list** (no separate "Community" / "Core Team" subsections). Order: **community contributors first, team members after**. Within each group, sort by PR count desc. Bots (`@lobehubbot`, `renovate[bot]`) go on a separate "maintenance" line.
**LobeHub team roster** — anyone in this list is a team member; anyone not in this list is a community contributor:
- @arvinxx
- @Innei
- @tjx666 (commit author name: YuTengjing)
- @LiJian
- @Neko
- @Rdmclin2
- @AmAzing129
- @sudongyuer (commit author name: Tsuki)
- @rivertwilight (commit author name: René Wang)
- @CanisMinor
- @cy948 (commit author name: Rylan Cai)
> **Resolving handles** — git author names (e.g. `YuTengjing`) are not always the GitHub handle. Verify via `gh pr view "$PR" --json author` or `gh api search/users -f q='<email>'` before listing.
If a new contributor appears who is not on this list, treat them as community by default and ask the user whether to add them to the roster.
## Template
```md
# 🚀 LobeHub Release (<YYYYMMDD>)
**Release Date:** <Month DD, YYYY>
**Since <Previous Version>:** <N merged PRs> · <N resolved issues> · <N contributors>
> <One release thesis sentence: what this release unlocks in practice.>
---
## ✨ Highlights
- **<Capability A>** — <What changed and why it matters>. (#1234)
- **<Capability B>** — <What changed and why it matters>. (#2345)
- **<Capability C>** — <What changed and why it matters>. (#3456)
---
## 🏗️ Core Product & Architecture
### <Subdomain>
- <Concrete change + impact>. (#...)
- <Concrete change + impact>. (#...)
---
## 📱 Platforms / Integrations
- <Platform update + impact>. (#...)
- <Compatibility/reliability fix + impact>. (#...)
---
## 🖥️ CLI & User Experience
- <User-facing workflow improvement>. (#...)
- <Quality-of-life fix>. (#...)
---
## 🔧 Tooling
- <Tool/runtime improvement>. (#...)
---
## 🔒 Security & Reliability
- **Security:** <hardening or vulnerability fix>. (#...)
- **Reliability:** <stability/performance behavior improvement>. (#...)
---
## 👥 Contributors
Huge thanks to **<N contributors>** who shipped **<N merged PRs>** this cycle.
@<community-handle> · @<community-handle> · @<team-handle> · @<team-handle>
Plus @lobehubbot and renovate[bot] for maintenance.
---
**Full Changelog**: <previous_tag>...<current_tag>
```
## Quick Checklist
### Long-Form (Minor / Weekly)
- [ ] `PREV_TAG` is `git describe --tags --abbrev=0 origin/main` (latest semver), not the last weekly's tag
- [ ] Every `(#XXXX)` in the body appears in `/tmp/release_prs.txt` (verified via `comm -23`)
- [ ] `Since v…` line uses `$PREV_TAG`; PR / contributor counts match `wc -l` on the computed sets
- [ ] `**Full Changelog**` uses `$PREV_TAG...release/weekly-<YYYYMMDD>` (or `…v{x.y.z}` for minor)
- [ ] Author handles resolved via `gh pr view --json author`, not assumed from `%an`
- [ ] Uses top metadata and a clear release thesis
- [ ] Includes `Highlights` plus domain-grouped sections
- [ ] Every major bullet states both change and user/operator impact
- [ ] Security and reliability updates are explicitly surfaced (when present)
- [ ] Contributor credits and compare range are included
- [ ] All numbers and claims are verifiable
### Hotfix
- [ ] `**Hotfix Scope:**` line replaces metrics line
- [ ] Single quoted thesis describes what is restored (operator-facing, not internal)
- [ ] `## 🐛 What's Fixed` has 1-3 bullets, each `**<symptom>** — <fix>. (#PR)` with PR ref verified to exist and be merged
- [ ] `## ⚙️ Upgrade` notes self-hosted action and cloud auto-apply
- [ ] `## 👥 Owner` is a single `@handle` resolved via `gh pr view "$PR" --json author`
- [ ] No Highlights / domain blocks / Contributors / Full Changelog included
+55
View File
@@ -174,9 +174,64 @@ export const chatGroupAction: StateCreator<
- `ChatGroupStoreWithRefresh` for member refresh
- `ChatGroupStoreWithInternal` for curd `internal_dispatchChatGroup`
### Slices That Don't Currently Need `set`
When a slice doesn't write local state at the moment — e.g. it reads context
from `#get()` and forwards calls to another store, or just runs hooks — drop
the `#set` field. Otherwise ESLint's `no-unused-vars` flags the unused private
field.
Mark the constructor's `set` param as `_set` and `void _set` it to keep the
`(set, get, api)` shape aligned with `StateCreator`. This is **a snapshot of
the current need, not a permanent contract** — if a later change needs `set`,
restore the `#set` field and use it; do not invent a workaround to keep the
"unused" form.
```ts
type Setter = StoreSetter<ConversationStore>;
export const toolSlice = (set: Setter, get: () => ConversationStore, _api?: unknown) =>
new ToolActionImpl(set, get, _api);
export class ToolActionImpl {
readonly #get: () => ConversationStore;
// Mark unused params with `_` prefix and `void _x` so the constructor still
// matches StateCreator's `(set, get, api)` shape without triggering unused
// diagnostics.
constructor(_set: Setter, get: () => ConversationStore, _api?: unknown) {
void _set;
void _api;
this.#get = get;
}
approveToolCall = async (id: string) => {
const { context, hooks } = this.#get();
await useChatStore.getState().approveToolCalling(id, '', context);
hooks.onToolCallComplete?.(id, undefined);
};
}
export type ToolAction = Pick<ToolActionImpl, keyof ToolActionImpl>;
```
Rules of thumb:
- If a slice doesn't currently call `set`, drop `#set` (use `_set` + `void _set`
in the constructor). When a later edit needs `set`, restore `#set` and use it.
- Don't add `setNamespace` for slices that don't write state. Add it when the
slice starts writing state.
- Never leave `#set` declared but unused "for future use" — lint will fail and
re-adding it later costs nothing.
### Do / Don't
- **Do**: keep constructor signature aligned with `StateCreator` params `(set, get, api)`.
- **Do**: use `#private` to avoid `set/get` being exposed.
- **Do**: use `flattenActions` instead of spreading class instances.
- **Do**: drop `#set` (and use `_set` + `void _set` in the constructor) for
delegate-only slices that never write state — keeps lint green without
breaking the `(set, get, api)` shape.
- **Don't**: keep both old slice objects and class actions active at the same time.
- **Don't**: keep an unused `#set` field "for future use" — it fails ESLint and
re-adding it later costs nothing.
+20 -11
View File
@@ -56,7 +56,6 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# add your custom model name, multi model separate by comma. for example gpt-3.5-1106,gpt-4-1106
# OPENAI_MODEL_LIST=gpt-3.5-turbo
# ## Azure OpenAI ###
# you can learn azure OpenAI Service on https://learn.microsoft.com/en-us/azure/ai-services/openai/overview
@@ -71,7 +70,6 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# Azure's API version, follows the YYYY-MM-DD format
# AZURE_API_VERSION=2024-10-21
# ## Anthropic Service ####
# ANTHROPIC_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
@@ -79,19 +77,16 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# use a proxy to connect to the Anthropic API
# ANTHROPIC_PROXY_URL=https://api.anthropic.com
# ## Google AI ####
# GOOGLE_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# ## AWS Bedrock ###
# AWS_REGION=us-east-1
# AWS_ACCESS_KEY_ID=xxxxxxxxxxxxxxxxxxx
# AWS_SECRET_ACCESS_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# ## Ollama AI ####
# You can use ollama to get and run LLM locally, learn more about it via https://github.com/ollama/ollama
@@ -101,13 +96,11 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# OLLAMA_MODEL_LIST=your_ollama_model_names
# ## OpenRouter Service ###
# OPENROUTER_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# OPENROUTER_MODEL_LIST=model1,model2,model3
# ## Mistral AI ###
# MISTRAL_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
@@ -168,7 +161,6 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# SILICONCLOUD_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# ## TencentCloud AI ####
# TENCENT_CLOUD_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
@@ -181,7 +173,6 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# INFINIAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# ## 302.AI ###
# AI302_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
@@ -222,7 +213,6 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# VERCELAIGATEWAY_API_KEY=your_vercel_ai_gateway_api_key
# #######################################
# ########### Market Service ############
# #######################################
@@ -283,7 +273,6 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# but some service providers may require configuration
# S3_REGION=us-west-1
# #######################################
# ########### Auth Service ##############
# #######################################
@@ -424,3 +413,23 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# MESSAGE_GATEWAY_ENABLED=1
# MESSAGE_GATEWAY_URL=https://message-gateway.lobehub.com
# MESSAGE_GATEWAY_SERVICE_TOKEN=your_service_token_here
# #######################################
# ########### Messenger Bot #############
# #######################################
# LobeHub-operated bots that users link their account to once and then chat
# with any of their agents from. Credentials (Telegram / Slack / Discord) are
# now managed in dc-center → Agent → System Bots and stored in the
# `system_bot_providers` table. See docs/development/messenger/managed-by-dc-center.md.
#
# Webhook URLs are registered against APP_URL:
# Telegram: <APP_URL>/api/agent/messenger/webhooks/telegram
# Slack: <APP_URL>/api/agent/messenger/webhooks/slack
# Discord: <APP_URL>/api/agent/messenger/webhooks/discord
#
# For local dev with bot platforms, point APP_URL at your tunnel
# (ngrok / cloudflared) so platforms can reach your machine.
# Verify-im link token TTL in seconds (default 1800 = 30 min)
# LOBE_LINK_TOKEN_TTL_SECONDS=1800
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
runs-on: ubuntu-latest
name: Test Packages
env:
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 model-bank'
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
+3
View File
@@ -148,3 +148,6 @@ apps/desktop/resources/cli-package.json
.superpowers/
docs/superpowers/
.heerogeneous-tracing
# Kagura agent runtime
.kagura/
+1 -1
View File
@@ -27,7 +27,7 @@ module.exports = defineConfig({
],
temperature: 0,
saveImmediately: true,
modelName: 'gpt-5.1-chat-latest',
modelName: 'gpt-4o',
experimental: {
jsonMode: true,
},
+75
View File
@@ -2,6 +2,81 @@
# Changelog
### [Version 2.1.56](https://github.com/lobehub/lobe-chat/compare/v2.1.55...v2.1.56)
<sup>Released on **2026-05-01**</sup>
#### 👷 Build System
- **database**: add `metadata` and `trigger` to `briefs` table.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### Build System
- **database**: add `metadata` and `trigger` to `briefs` table, closes [#14354](https://github.com/lobehub/lobe-chat/issues/14354) ([86a23b5](https://github.com/lobehub/lobe-chat/commit/86a23b5))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 2.1.55](https://github.com/lobehub/lobe-chat/compare/v2.1.54...v2.1.55)
<sup>Released on **2026-04-29**</sup>
#### 🐛 Bug Fixes
- **chat**: preserve topics across cold route sends.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's fixed
- **chat**: preserve topics across cold route sends, closes [#14284](https://github.com/lobehub/lobe-chat/issues/14284) ([b8fe675](https://github.com/lobehub/lobe-chat/commit/b8fe675))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 2.1.54](https://github.com/lobehub/lobe-chat/compare/v2.1.53...v2.1.54)
<sup>Released on **2026-04-27**</sup>
#### 🐛 Bug Fixes
- **misc**: clear stale topic when switching agents from a topic route.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's fixed
- **misc**: clear stale topic when switching agents from a topic route, closes [#14231](https://github.com/lobehub/lobe-chat/issues/14231) ([deeb97a](https://github.com/lobehub/lobe-chat/commit/deeb97a))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 2.1.52](https://github.com/lobehub/lobe-chat/compare/v2.1.51...v2.1.52)
<sup>Released on **2026-04-20**</sup>
+1 -1
View File
@@ -89,7 +89,7 @@ RUN set -e && \
pnpm i && \
mkdir -p /deps && \
cd /deps && \
pnpm init && \
echo '{"name":"deps","private":true}' > package.json && \
pnpm add pg drizzle-orm
COPY . .
+4 -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.8" "User Commands"
.TH LH 1 "" "@lobehub/cli 0.0.14" "User Commands"
.SH NAME
lh \- LobeHub CLI \- manage and connect to LobeHub services
.SH SYNOPSIS
@@ -77,6 +77,9 @@ Generate content (text, image, video, speech) Alias: gen.
.B file
Manage files
.TP
.B hetero
Run heterogeneous agent CLIs (Claude Code / Codex) and stream their output
.TP
.B skill
Manage agent skills
.TP
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lobehub/cli",
"version": "0.0.8",
"version": "0.0.14",
"type": "module",
"bin": {
"lh": "./dist/index.js",
@@ -30,6 +30,7 @@
"devDependencies": {
"@lobechat/agent-gateway-client": "workspace:*",
"@lobechat/device-gateway-client": "workspace:*",
"@lobechat/heterogeneous-agents": "workspace:*",
"@lobechat/local-file-shell": "workspace:*",
"@trpc/client": "^11.8.1",
"@types/node": "^22.13.5",
+4
View File
@@ -1,6 +1,10 @@
packages:
- '../../packages/agent-gateway-client'
- '../../packages/device-gateway-client'
- '../../packages/heterogeneous-agents'
- '../../packages/local-file-shell'
- '../../packages/types'
- '../../packages/model-bank'
- '../../packages/business/const'
- '../../packages/file-loaders'
- '.'
+5 -8
View File
@@ -39,7 +39,6 @@ const { mockTrpcClient } = vi.hoisted(() => ({
agentSkills: {
createSkill: { mutate: vi.fn() },
deleteSkill: { mutate: vi.fn() },
promoteSkill: { mutate: vi.fn() },
updateSkill: { mutate: vi.fn() },
},
aiAgent: {
@@ -1036,12 +1035,12 @@ describe('agent command', () => {
.mockResolvedValueOnce([
{
mode: 8,
name: 'agent-topic',
path: './lobe/skills/agent-topic',
name: 'builtin',
path: './lobe/skills/builtin',
type: 'directory',
},
])
.mockRejectedValueOnce(new Error('Topic ID is required for the agent-topic namespace'));
.mockRejectedValueOnce(new Error('Failed to list builtin skills'));
const program = createProgram();
await program.parseAsync([
@@ -1063,12 +1062,10 @@ describe('agent command', () => {
});
expect(mockTrpcClient.agentDocument.listDocumentsByPath.query).toHaveBeenNthCalledWith(2, {
agentId: 'a1',
path: './lobe/skills/agent-topic',
path: './lobe/skills/builtin',
topicId: undefined,
});
expect(log.warn).toHaveBeenCalledWith(
'./lobe/skills/agent-topic: Topic ID is required for the agent-topic namespace',
);
expect(log.warn).toHaveBeenCalledWith('./lobe/skills/builtin: Failed to list builtin skills');
});
it('should read SKILL.md when cat targets a skill directory alias', async () => {
+2 -41
View File
@@ -14,7 +14,6 @@ const SKILL_FILE_NAME = 'SKILL.md';
const SKILL_NAMESPACE_PREFIXES = {
'agent': './lobe/skills/agent/skills',
'agent-topic': './lobe/skills/agent-topic/skills',
'builtin': './lobe/skills/builtin/skills',
'installed-active': './lobe/skills/installed/active/skills',
'installed-all': './lobe/skills/installed/all/skills',
@@ -26,8 +25,6 @@ const FS_PATH_ALIASES = {
'skills': 'agent',
'installed-active': 'installed-active',
'installed-all': 'installed-all',
'topic-skills': 'agent-topic',
'topic': 'agent-topic',
} as const;
type SkillFsNamespace = keyof typeof SKILL_NAMESPACE_PREFIXES;
@@ -94,7 +91,7 @@ function resolveAgentFsPath(input = 'agent:/'): AgentFsResolvedPath {
if (!target) {
exitWithError(
`Unknown fs namespace "${aliasMatch[1]}". Use agent, skills, topic-skills, builtin, installed-all, or installed-active.`,
`Unknown fs namespace "${aliasMatch[1]}". Use agent, skills, builtin, installed-all, or installed-active.`,
);
}
@@ -156,12 +153,6 @@ function resolveAgentFsPath(input = 'agent:/'): AgentFsResolvedPath {
};
}
function requireTopicId(namespace: SkillFsNamespace | undefined, topicId?: string) {
if (namespace === 'agent-topic' && !topicId) {
exitWithError('--topic-id is required for agent-topic fs paths.');
}
}
function requireSkillNamespace(resolved: AgentFsResolvedPath): SkillFsNamespace {
if (!resolved.namespace) {
exitWithError(`Expected a skill namespace path, but received "${resolved.path}".`);
@@ -191,8 +182,7 @@ function toDisplayPath(path: string) {
for (const [namespace, prefix] of Object.entries(SKILL_NAMESPACE_PREFIXES) as Array<
[SkillFsNamespace, string]
>) {
const alias =
namespace === 'agent' ? 'skills' : namespace === 'agent-topic' ? 'topic-skills' : namespace;
const alias = namespace === 'agent' ? 'skills' : namespace;
if (path === prefix) return `${alias}:/`;
if (path.startsWith(`${prefix}/`)) return `${alias}:/${path.slice(prefix.length + 1)}`;
}
@@ -316,7 +306,6 @@ async function getFsNode(client: AgentFsClient, context: AgentFsContext, path: s
async function readFsFile(client: AgentFsClient, context: AgentFsContext, inputPath: string) {
const resolved = resolveAgentFsPath(inputPath);
requireTopicId(resolved.namespace, context.topicId);
const readPath =
resolved.skillName && !resolved.filePath
@@ -349,7 +338,6 @@ async function writeFsFile(
content: string,
) {
const resolved = resolveAgentFsPath(inputPath);
requireTopicId(resolved.namespace, context.topicId);
const existing = await getFsNode(
client,
context,
@@ -376,7 +364,6 @@ async function mkdirFsPath(
options?: { recursive?: boolean },
) {
const resolved = resolveAgentFsPath(inputPath);
requireTopicId(resolved.namespace, context.topicId);
return client.agentDocument.mkdirDocumentByPath.mutate({
agentId: context.agentId,
@@ -393,7 +380,6 @@ async function deleteFsPath(
options?: { force?: boolean; recursive?: boolean },
) {
const resolved = resolveAgentFsPath(inputPath);
requireTopicId(resolved.namespace, context.topicId);
return client.agentDocument.deleteDocumentByPath.mutate({
agentId: context.agentId,
@@ -414,9 +400,6 @@ async function copyFsPath(
const sourceResolved = resolveAgentFsPath(source);
const destinationResolved = resolveAgentFsPath(destination);
requireTopicId(sourceResolved.namespace, context.topicId);
requireTopicId(destinationResolved.namespace, context.topicId);
return client.agentDocument.copyDocumentByPath.mutate({
agentId: context.agentId,
force,
@@ -436,9 +419,6 @@ async function renameFsPath(
const sourceResolved = resolveAgentFsPath(source);
const destinationResolved = resolveAgentFsPath(destination);
requireTopicId(sourceResolved.namespace, context.topicId);
requireTopicId(destinationResolved.namespace, context.topicId);
return client.agentDocument.renameDocumentByPath.mutate({
agentId: context.agentId,
force,
@@ -450,7 +430,6 @@ async function renameFsPath(
async function listTrashFsPath(client: AgentFsClient, context: AgentFsContext, inputPath?: string) {
const resolved = resolveAgentFsPath(inputPath || 'agent:/');
requireTopicId(resolved.namespace, context.topicId);
return (await client.agentDocument.listTrashDocumentsByPath.query({
agentId: context.agentId,
@@ -465,7 +444,6 @@ async function restoreTrashFsPath(
inputPath: string,
) {
const resolved = resolveAgentFsPath(inputPath);
requireTopicId(resolved.namespace, context.topicId);
return client.agentDocument.restoreDocumentFromTrashByPath.mutate({
agentId: context.agentId,
@@ -481,7 +459,6 @@ async function deleteTrashFsPath(
options?: { force?: boolean; recursive?: boolean },
) {
const resolved = resolveAgentFsPath(inputPath);
requireTopicId(resolved.namespace, context.topicId);
return client.agentDocument.deleteDocumentPermanentlyByPath.mutate({
agentId: context.agentId,
@@ -531,7 +508,6 @@ function registerFsCommands(fsCommand: Command) {
.option('-l, --long', 'Use long listing format')
.option('-A, --agent-id <id>', 'Agent ID')
.option('-s, --slug <slug>', 'Agent slug')
.option('-t, --topic-id <id>', 'Topic ID for agent-topic paths')
.option('--cursor <cursor>', 'Directory pagination cursor')
.option('-L, --limit <n>', 'Maximum number of entries')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
@@ -552,7 +528,6 @@ function registerFsCommands(fsCommand: Command) {
const client = await getTrpcClient();
const context = await resolveAgentFsContext(client, options);
const resolved = resolveAgentFsPath(inputPath || 'agent:/');
requireTopicId(resolved.namespace, context.topicId);
const nodes = ((await client.agentDocument.listDocumentsByPath.query({
agentId: context.agentId,
@@ -590,7 +565,6 @@ function registerFsCommands(fsCommand: Command) {
.description('Print a tree view of the VFS')
.option('-A, --agent-id <id>', 'Agent ID')
.option('-s, --slug <slug>', 'Agent slug')
.option('-t, --topic-id <id>', 'Topic ID for agent-topic paths')
.action(
async (
inputPath: string | undefined,
@@ -599,7 +573,6 @@ function registerFsCommands(fsCommand: Command) {
const client = await getTrpcClient();
const context = await resolveAgentFsContext(client, options);
const resolved = resolveAgentFsPath(inputPath || 'agent:/');
requireTopicId(resolved.namespace, context.topicId);
console.log(pc.bold(toDisplayPath(resolved.path)));
const warnings: string[] = [];
@@ -616,7 +589,6 @@ function registerFsCommands(fsCommand: Command) {
.description('Read a VFS file')
.option('-A, --agent-id <id>', 'Agent ID')
.option('-s, --slug <slug>', 'Agent slug')
.option('-t, --topic-id <id>', 'Topic ID for agent-topic paths')
.action(
async (inputPath: string, options: { agentId?: string; slug?: string; topicId?: string }) => {
const client = await getTrpcClient();
@@ -631,7 +603,6 @@ function registerFsCommands(fsCommand: Command) {
.description('Show VFS node metadata')
.option('-A, --agent-id <id>', 'Agent ID')
.option('-s, --slug <slug>', 'Agent slug')
.option('-t, --topic-id <id>', 'Topic ID for agent-topic paths')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(
async (
@@ -646,7 +617,6 @@ function registerFsCommands(fsCommand: Command) {
const client = await getTrpcClient();
const context = await resolveAgentFsContext(client, options);
const resolved = resolveAgentFsPath(inputPath);
requireTopicId(resolved.namespace, context.topicId);
const node = await getFsNode(client, context, resolved.path);
@@ -669,7 +639,6 @@ function registerFsCommands(fsCommand: Command) {
.description('Create or update a VFS file')
.option('-A, --agent-id <id>', 'Agent ID')
.option('-s, --slug <slug>', 'Agent slug')
.option('-t, --topic-id <id>', 'Topic ID for agent-topic paths')
.option('-c, --content <content>', 'File content')
.option('-F, --content-file <path>', 'Read content from a local file')
.action(
@@ -696,7 +665,6 @@ function registerFsCommands(fsCommand: Command) {
.description('Write content to a VFS file')
.option('-A, --agent-id <id>', 'Agent ID')
.option('-s, --slug <slug>', 'Agent slug')
.option('-t, --topic-id <id>', 'Topic ID for agent-topic paths')
.option('-c, --content <content>', 'File content')
.option('-F, --content-file <path>', 'Read content from a local file')
.action(
@@ -723,7 +691,6 @@ function registerFsCommands(fsCommand: Command) {
.description('Create a VFS directory')
.option('-A, --agent-id <id>', 'Agent ID')
.option('-s, --slug <slug>', 'Agent slug')
.option('-t, --topic-id <id>', 'Topic ID for agent-topic paths')
.option('-p, --parents', 'Create parent directories as needed')
.action(
async (
@@ -746,7 +713,6 @@ function registerFsCommands(fsCommand: Command) {
.description('Delete a VFS node into trash')
.option('-A, --agent-id <id>', 'Agent ID')
.option('-s, --slug <slug>', 'Agent slug')
.option('-t, --topic-id <id>', 'Topic ID for agent-topic paths')
.option('-r, --recursive', 'Recursively delete a directory subtree')
.option('-f, --force', 'Forward force semantics to the VFS delete primitive')
.option('--yes', 'Skip confirmation prompt')
@@ -785,7 +751,6 @@ function registerFsCommands(fsCommand: Command) {
.description('Copy a VFS node')
.option('-A, --agent-id <id>', 'Agent ID')
.option('-s, --slug <slug>', 'Agent slug')
.option('-t, --topic-id <id>', 'Topic ID for agent-topic source or destination paths')
.option('-f, --force', 'Overwrite the destination if it exists')
.action(
async (
@@ -807,7 +772,6 @@ function registerFsCommands(fsCommand: Command) {
.description('Move or rename a VFS node')
.option('-A, --agent-id <id>', 'Agent ID')
.option('-s, --slug <slug>', 'Agent slug')
.option('-t, --topic-id <id>', 'Topic ID for agent-topic source or destination paths')
.option('-f, --force', 'Overwrite the destination if it exists')
.action(
async (
@@ -839,7 +803,6 @@ function registerFsCommands(fsCommand: Command) {
.description('List trashed VFS nodes')
.option('-A, --agent-id <id>', 'Agent ID')
.option('-s, --slug <slug>', 'Agent slug')
.option('-t, --topic-id <id>', 'Topic ID for agent-topic paths')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(
async (
@@ -875,7 +838,6 @@ function registerFsCommands(fsCommand: Command) {
.description('Restore a soft-deleted VFS node')
.option('-A, --agent-id <id>', 'Agent ID')
.option('-s, --slug <slug>', 'Agent slug')
.option('-t, --topic-id <id>', 'Topic ID for agent-topic paths')
.action(
async (inputPath: string, options: { agentId?: string; slug?: string; topicId?: string }) => {
const client = await getTrpcClient();
@@ -892,7 +854,6 @@ function registerFsCommands(fsCommand: Command) {
.description('Permanently delete a trashed VFS node')
.option('-A, --agent-id <id>', 'Agent ID')
.option('-s, --slug <slug>', 'Agent slug')
.option('-t, --topic-id <id>', 'Topic ID for agent-topic paths')
.option('-r, --recursive', 'Recursively delete a directory subtree')
.option('-f, --force', 'Forward force semantics to the permanent delete primitive')
.option('--yes', 'Skip confirmation prompt')
+344
View File
@@ -0,0 +1,344 @@
import { PassThrough } from 'node:stream';
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { registerHeteroCommand } from './hetero';
const { mockSpawnAgent } = vi.hoisted(() => ({
mockSpawnAgent: vi.fn(),
}));
vi.mock('@lobechat/heterogeneous-agents/spawn', () => ({
spawnAgent: mockSpawnAgent,
}));
vi.mock('../utils/logger', () => ({
log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() },
setVerbose: vi.fn(),
}));
/**
* Build a Promise resolving to a fake `SpawnAgentHandle`. `spawnAgent` itself
* is async, so test mocks return the handle wrapped — same iterable contract,
* just behind one microtask. The async iterable yields `events` synchronously
* and ends, so the command's `for await (const event of ...)` loop terminates
* without hanging the test.
*/
const createFakeHandle = ({
events = [] as any[],
exitCode = 0,
signal = null as NodeJS.Signals | null,
stderrChunks = [] as string[],
}: {
events?: any[];
exitCode?: number | null;
signal?: NodeJS.Signals | null;
stderrChunks?: string[];
} = {}) => {
const stderr = new PassThrough();
setImmediate(() => {
for (const c of stderrChunks) stderr.write(c);
stderr.end();
});
const eventsIter: AsyncIterable<any> = {
[Symbol.asyncIterator]() {
let i = 0;
return {
async next() {
if (i < events.length) return { done: false, value: events[i++] };
return { done: true, value: undefined };
},
};
},
};
return Promise.resolve({
events: eventsIter,
exit: Promise.resolve({ code: exitCode, signal }),
kill: vi.fn(),
pid: 12_345,
stderr,
});
};
describe('hetero exec command', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
let stdoutSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
// Stub `process.exit` so the test runner doesn't tear down — but THROW a
// sentinel rather than return, mirroring `process.exit`'s `never` return
// type in production. Without throwing, the command's code after an
// `exit(2)` keeps running and crashes on `handle.stderr` (no spawn mock).
exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`__exit__${code}`);
}) as any);
stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
mockSpawnAgent.mockReset();
});
afterEach(() => {
exitSpy.mockRestore();
stdoutSpy.mockRestore();
vi.restoreAllMocks();
});
/** Build a fresh program with the hetero command registered. */
const buildProgram = () => {
const program = new Command();
program.exitOverride();
registerHeteroCommand(program);
return program;
};
/**
* Run the parsed command. Swallows our `__exit__<code>` sentinel so tests
* can inspect `exitSpy.mock.calls` afterwards instead of having to wrap
* every `parseAsync` in `expect(...).rejects`. Real production exits stay
* `process.exit` so this only affects the test path.
*/
const runCmd = async (argv: string[]) => {
try {
await buildProgram().parseAsync(argv, { from: 'user' });
} catch (err) {
if (err instanceof Error && err.message.startsWith('__exit__')) return;
throw err;
}
};
it('rejects unsupported agent types via process.exit(2)', async () => {
await runCmd(['hetero', 'exec', '--type', 'kimi-cli', '--prompt', 'hi']);
expect(exitSpy).toHaveBeenCalledWith(2);
expect(mockSpawnAgent).not.toHaveBeenCalled();
});
it('rejects empty prompts via process.exit(2)', async () => {
await runCmd(['hetero', 'exec', '--type', 'claude-code', '--prompt', ' ']);
expect(exitSpy).toHaveBeenCalledWith(2);
expect(mockSpawnAgent).not.toHaveBeenCalled();
});
it('passes --type / --prompt / --resume / --cwd / --command through to spawnAgent', async () => {
mockSpawnAgent.mockReturnValue(createFakeHandle());
await runCmd([
'hetero',
'exec',
'--type',
'codex',
'--prompt',
'do thing',
'--resume',
'thread_abc',
'--cwd',
'/tmp/work',
'--command',
'/usr/local/bin/codex',
]);
expect(mockSpawnAgent).toHaveBeenCalledTimes(1);
const call = mockSpawnAgent.mock.calls[0][0];
expect(call).toMatchObject({
agentType: 'codex',
command: '/usr/local/bin/codex',
cwd: '/tmp/work',
prompt: 'do thing',
resumeSessionId: 'thread_abc',
});
// operationId auto-generated when omitted (uuid v4 shape)
expect(call.operationId).toMatch(/^[0-9a-f-]{36}$/i);
});
it('uses the provided --operation-id verbatim', async () => {
mockSpawnAgent.mockReturnValue(createFakeHandle());
await runCmd([
'hetero',
'exec',
'--type',
'claude-code',
'--prompt',
'hi',
'--operation-id',
'op-server-allocated',
]);
const call = mockSpawnAgent.mock.calls[0][0];
expect(call.operationId).toBe('op-server-allocated');
});
it('streams events to stdout as JSONL, one line per event', async () => {
const events = [
{ data: { foo: 1 }, operationId: 'op-1', stepIndex: 0, timestamp: 1, type: 'stream_start' },
{
data: { chunkType: 'text', content: 'hi' },
operationId: 'op-1',
stepIndex: 0,
timestamp: 2,
type: 'stream_chunk',
},
];
mockSpawnAgent.mockReturnValue(createFakeHandle({ events }));
await runCmd([
'hetero',
'exec',
'--type',
'claude-code',
'--prompt',
'hi',
'--operation-id',
'op-1',
]);
// Each event is one JSON line with a trailing \n.
const lines = stdoutSpy.mock.calls.map((c) => c[0]).filter((s) => typeof s === 'string');
expect(lines).toHaveLength(2);
for (const line of lines as string[]) {
expect(line.endsWith('\n')).toBe(true);
const parsed = JSON.parse(line);
expect(parsed.operationId).toBe('op-1');
}
});
it('passes the child exit code straight through', async () => {
mockSpawnAgent.mockReturnValue(createFakeHandle({ exitCode: 7 }));
await runCmd(['hetero', 'exec', '--type', 'claude-code', '--prompt', 'hi']);
expect(exitSpy).toHaveBeenCalledWith(7);
});
it('maps SIGINT (code === null) to POSIX exit code 130', async () => {
mockSpawnAgent.mockReturnValue(createFakeHandle({ exitCode: null, signal: 'SIGINT' }));
await runCmd(['hetero', 'exec', '--type', 'claude-code', '--prompt', 'hi']);
expect(exitSpy).toHaveBeenCalledWith(130);
});
it('combines --prompt + --image into mixed content blocks', async () => {
mockSpawnAgent.mockReturnValue(createFakeHandle());
await runCmd([
'hetero',
'exec',
'--type',
'claude-code',
'--prompt',
'describe',
'--image',
'./fixture-a.png',
'--image',
'https://cdn.example/fixture-b.png',
]);
const call = mockSpawnAgent.mock.calls[0][0];
expect(Array.isArray(call.prompt)).toBe(true);
expect(call.prompt).toEqual([
{ text: 'describe', type: 'text' },
// Path is resolved against process.cwd() — match by suffix to be CI-portable.
{
source: expect.objectContaining({ type: 'path' }),
type: 'image',
},
{
source: { type: 'url', url: 'https://cdn.example/fixture-b.png' },
type: 'image',
},
]);
expect(call.prompt[1].source.path).toMatch(/fixture-a\.png$/);
});
it('parses a data: URL --image into a base64 source', async () => {
mockSpawnAgent.mockReturnValue(createFakeHandle());
const dataUrl = `data:image/png;base64,${Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString('base64')}`;
await runCmd([
'hetero',
'exec',
'--type',
'claude-code',
'--prompt',
'see',
'--image',
dataUrl,
]);
const call = mockSpawnAgent.mock.calls[0][0];
expect(call.prompt[1]).toEqual({
source: {
data: Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString('base64'),
mediaType: 'image/png',
type: 'base64',
},
type: 'image',
});
});
it('reads multimodal content from --input-json <file>', async () => {
const { mkdtemp, writeFile, rm } = await import('node:fs/promises');
const { tmpdir } = await import('node:os');
const path = await import('node:path');
const dir = await mkdtemp(`${tmpdir()}/hetero-input-json-`);
const file = path.join(dir, 'input.json');
await writeFile(
file,
JSON.stringify([
{ text: 'analyze', type: 'text' },
{ source: { type: 'url', url: 'https://x/y.png' }, type: 'image' },
]),
);
mockSpawnAgent.mockReturnValue(createFakeHandle());
try {
await runCmd(['hetero', 'exec', '--type', 'claude-code', '--input-json', file]);
} finally {
await rm(dir, { force: true, recursive: true });
}
const call = mockSpawnAgent.mock.calls[0][0];
expect(call.prompt).toEqual([
{ text: 'analyze', type: 'text' },
{ source: { type: 'url', url: 'https://x/y.png' }, type: 'image' },
]);
});
it('reports spawnAgent rejections (e.g. missing --image path) as a clean error + exit(1)', async () => {
// spawnAgent is now async and can reject during image normalization —
// missing local --image paths, fetch failures, etc. The CLI must catch
// these and exit with a friendly message instead of crashing on an
// unhandled rejection.
mockSpawnAgent.mockReturnValue(
Promise.reject(new Error('ENOENT: no such file or directory, open /missing.png')),
);
await runCmd([
'hetero',
'exec',
'--type',
'claude-code',
'--prompt',
'see',
'--image',
'/missing.png',
]);
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('rejects --prompt + --input-json (mutually exclusive)', async () => {
await runCmd([
'hetero',
'exec',
'--type',
'claude-code',
'--prompt',
'hi',
'--input-json',
'/tmp/bogus.json',
]);
expect(exitSpy).toHaveBeenCalledWith(2);
expect(mockSpawnAgent).not.toHaveBeenCalled();
});
});
+380
View File
@@ -0,0 +1,380 @@
import { randomUUID } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import type {
AgentContentBlock,
AgentImageSource,
AgentPromptInput,
} from '@lobechat/heterogeneous-agents/spawn';
import { spawnAgent } from '@lobechat/heterogeneous-agents/spawn';
import type { Command } from 'commander';
import { getTrpcClient } from '../api/client';
import { BatchIngester, NoopIngestSink } from '../utils/BatchIngester';
import { log } from '../utils/logger';
import { TrpcIngestSink } from '../utils/TrpcIngestSink';
const SUPPORTED_AGENT_TYPES = new Set(['claude-code', 'codex']);
interface ExecOptions {
command?: string;
cwd?: string;
image?: string[];
inputJson?: string;
operationId?: string;
prompt?: string;
/**
* Output rendering mode.
* jsonl — emit each `AgentStreamEvent` as a JSONL line on stdout (default
* when no --topic is set, or when explicitly requested).
* none — suppress JSONL stdout; only server-ingest mode is active.
* Default when --topic is set and running non-interactively.
*/
render?: 'jsonl' | 'none';
resume?: string;
/**
* Server topic id. When set, enables server-ingest mode: events are
* batch-POSTed to `aiAgent.heteroIngest` in addition to (or instead of)
* being written to stdout. Requires `--operation-id` to be a valid
* server-allocated operation id.
*/
topic?: string;
type: string;
}
const collectImage = (value: string, previous: string[] = []): string[] => [...previous, value];
const readStdin = async (): Promise<string> => {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : (chunk as Buffer));
}
return Buffer.concat(chunks).toString('utf8');
};
/**
* Resolve a raw `--input-json` argument: `'-'` (or empty) reads stdin, anything
* else is treated as a filesystem path.
*/
const readInputJson = async (location: string): Promise<string> => {
if (location === '-' || location === '') return readStdin();
return readFile(location, 'utf8');
};
const looksLikeJsonInput = (value: string): boolean => {
const trimmed = value.trimStart();
return trimmed.startsWith('{') || trimmed.startsWith('[');
};
/**
* Convert an `--image <value>` argument into an image source. Recognized
* shapes: `https?://...` URL, `data:` URL, otherwise a filesystem path
* resolved relative to the CLI's cwd.
*/
const parseImageArg = (value: string): AgentImageSource => {
if (/^https?:\/\//i.test(value)) return { type: 'url', url: value };
if (value.startsWith('data:')) {
const match = value.match(/^data:([^;,]+);base64,(.+)$/);
if (!match) {
throw new Error(`Invalid data URL for --image: ${value.slice(0, 40)}`);
}
return { data: match[2]!, mediaType: match[1]!, type: 'base64' };
}
return { path: path.resolve(process.cwd(), value), type: 'path' };
};
/**
* Best-effort coercion of a JSON-decoded value into an `AgentPromptInput`.
* Accepts:
* - `'plain text'` → single text block
* - `[{ type: 'text', text }, { type: 'image', source }]` → content blocks
* - `{ content: [...] }` (Anthropic message shape) → unwraps `content`
* - `{ type: 'text', ... } | { type: 'image', ... }` → single block
*/
const coerceJsonPrompt = (parsed: unknown): AgentPromptInput => {
if (typeof parsed === 'string') return parsed;
if (Array.isArray(parsed)) return parsed as AgentContentBlock[];
if (parsed && typeof parsed === 'object') {
const obj = parsed as Record<string, unknown>;
if (Array.isArray(obj.content)) return obj.content as AgentContentBlock[];
if (obj.type === 'text' || obj.type === 'image') return [obj as AgentContentBlock];
}
throw new Error(
'Invalid --input-json shape: expected a string, array of content blocks, ' +
'or `{ content: [...] }` envelope.',
);
};
interface ResolvedPrompt {
/** Human-readable description for the empty-input check. */
describe: () => string;
prompt: AgentPromptInput;
}
const buildPromptFromText = (text: string, images: string[]): ResolvedPrompt => {
if (images.length === 0) {
return { describe: () => text.trim(), prompt: text };
}
const blocks: AgentContentBlock[] = [];
if (text.length > 0) blocks.push({ text, type: 'text' });
for (const image of images) {
blocks.push({ source: parseImageArg(image), type: 'image' });
}
return {
describe: () =>
blocks
.map((b) => (b.type === 'text' ? b.text.trim() : '[image]'))
.filter(Boolean)
.join(' ')
.trim(),
prompt: blocks,
};
};
/**
* Decide which input mode the user requested and produce a unified prompt.
*
* Mode resolution (mutually exclusive):
* 1. `--input-json` → read JSON file or stdin, parse to content blocks
* 2. `--prompt` (with optional `--image` flags) → text + images
* 3. (default) read stdin: auto-detect JSON vs plain text by first char
*/
const resolvePrompt = async (options: ExecOptions): Promise<ResolvedPrompt> => {
const images = options.image ?? [];
if (options.inputJson !== undefined) {
if (options.prompt !== undefined) {
throw new Error('--prompt and --input-json are mutually exclusive.');
}
if (images.length > 0) {
throw new Error('--image cannot be combined with --input-json (put images in the JSON).');
}
const raw = await readInputJson(options.inputJson);
return { describe: () => raw.trim(), prompt: coerceJsonPrompt(JSON.parse(raw)) };
}
if (options.prompt !== undefined && options.prompt !== '-') {
return buildPromptFromText(options.prompt, images);
}
// No --prompt or --prompt -: read stdin and auto-detect.
const raw = await readStdin();
if (looksLikeJsonInput(raw)) {
return { describe: () => raw.trim(), prompt: coerceJsonPrompt(JSON.parse(raw)) };
}
return buildPromptFromText(raw, images);
};
const exec = async (options: ExecOptions): Promise<void> => {
if (!SUPPORTED_AGENT_TYPES.has(options.type)) {
log.error(
`Unsupported --type "${options.type}". Supported: ${[...SUPPORTED_AGENT_TYPES].join(', ')}`,
);
process.exit(2);
}
let resolved: ResolvedPrompt;
try {
resolved = await resolvePrompt(options);
} catch (err) {
log.error(err instanceof Error ? err.message : String(err));
process.exit(2);
}
if (!resolved.describe()) {
log.error(
'Empty prompt. Pass --prompt <text>, --image <path>, --input-json <file|->, or pipe content via stdin.',
);
process.exit(2);
}
// Server-ingest mode is active when --topic is provided.
// --operation-id must be a server-allocated id in this mode (the server
// generates it before spawning the process and passes it via CLI args).
const serverIngest = !!options.topic;
if (serverIngest && !options.operationId) {
log.error('--operation-id is required when --topic is set (server-ingest mode).');
process.exit(2);
}
const operationId = options.operationId || randomUUID();
// Determine JSONL output mode.
// Explicit --render flag always wins. Otherwise: emit JSONL in standalone
// mode; suppress in server-ingest mode (sink handles the data path).
const emitJsonl = options.render === 'jsonl' || (options.render === undefined && !serverIngest);
// Build the ingest sink — no-op for standalone mode, real tRPC sink for
// server-ingest mode. The tRPC client reads LOBEHUB_JWT (operation-scoped
// JWT injected by the server) for authentication.
const agentType = options.type as 'claude-code' | 'codex';
let sink: InstanceType<typeof TrpcIngestSink> | InstanceType<typeof NoopIngestSink>;
if (serverIngest) {
const client = await getTrpcClient();
sink = new TrpcIngestSink(client, agentType, operationId, options.topic!);
} else {
sink = new NoopIngestSink();
}
const ingester = new BatchIngester(sink);
// `spawnAgent` is async and can reject DURING image normalization — fetch
// failures, missing local --image paths, decode errors. Surface those as a
// clean error + exit code instead of an unhandled promise rejection / stack
// trace, mirroring the validation try/catch above.
let handle: Awaited<ReturnType<typeof spawnAgent>>;
try {
handle = await spawnAgent({
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);
}
// 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);
// 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;
if (serverIngest) {
try {
await ingester.drain();
} catch (err) {
log.error(
'Failed to flush events to server:',
err instanceof Error ? err.message : String(err),
);
ingestError = true;
}
const exitedClean = !ingestError && (code === 0 || signal === 'SIGTERM');
try {
await sink.finish({
result: exitedClean ? 'success' : 'error',
sessionId: handle.sessionId,
});
} catch (err) {
log.error('Failed to send heteroFinish:', err instanceof Error ? err.message : String(err));
}
}
if (code !== null) process.exit(ingestError ? 1 : code);
if (signal === 'SIGINT') process.exit(130);
if (signal === 'SIGTERM') process.exit(143);
if (signal === 'SIGKILL') process.exit(137);
process.exit(1);
};
export function registerHeteroCommand(program: Command) {
const hetero = program
.command('hetero')
.description('Run heterogeneous agent CLIs (Claude Code / Codex) and stream their output');
hetero
.command('exec')
.description(
'Spawn a heterogeneous agent CLI and stream its events as JSONL on stdout. Standalone mode (no server ingest).',
)
.requiredOption('-t, --type <type>', `Agent type: ${[...SUPPORTED_AGENT_TYPES].join(' | ')}`)
.option('-p, --prompt [text]', 'Prompt text. Pass `-` (or omit the value) to read from stdin.')
.option(
'-i, --image <path|url>',
'Attach an image (repeatable). Accepts a local path, http(s) URL, or data: URL.',
collectImage,
)
.option(
'--input-json <path>',
'Read full multimodal prompt as JSON content blocks from a file. Use `-` for stdin.',
)
.option('-r, --resume <sessionId>', 'Resume an existing agent session by its native id')
.option('-d, --cwd <path>', 'Working directory for the spawned agent (default: process.cwd())')
.option(
'-c, --command <bin>',
'Override the agent CLI binary name (default: `claude` or `codex`)',
)
.option(
'--operation-id <id>',
'Operation id stamped onto every emitted event. Required in server-ingest mode (--topic). Generated as a UUID if omitted (standalone).',
)
.option(
'--topic <topicId>',
'Server topic id. Enables server-ingest mode: events are batch-POSTed to aiAgent.heteroIngest. Requires --operation-id.',
)
.option(
'--render <mode>',
'Output mode: jsonl (emit events as JSONL on stdout) | none (suppress stdout). Defaults to jsonl in standalone, none in server-ingest mode.',
)
.action(exec);
}
+17
View File
@@ -83,6 +83,23 @@ describe('model command', () => {
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(models, null, 2));
});
it('should filter hidden runtime-only models from JSON output', async () => {
const visibleModels = [{ displayName: 'DeepSeek V4 Pro', id: 'deepseek-v4-pro' }];
mockTrpcClient.aiModel.getAiProviderModelList.query.mockResolvedValue([
...visibleModels,
{
displayName: 'LobeHub Onboarding',
id: 'lobehub-onboarding-v1',
visible: false,
},
]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'model', 'list', 'lobehub', '--json']);
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(visibleModels, null, 2));
});
});
describe('view', () => {
+5 -1
View File
@@ -5,6 +5,8 @@ import { getTrpcClient } from '../api/client';
import { confirm, outputJson, printTable, truncate } from '../utils/format';
import { log } from '../utils/logger';
const isVisibleModel = (model: { visible?: boolean }) => model.visible !== false;
export function registerModelCommand(program: Command) {
const model = program.command('model').description('Manage AI models');
@@ -33,7 +35,9 @@ export function registerModelCommand(program: Command) {
if (options.type) input.type = options.type;
const result = await client.aiModel.getAiProviderModelList.query(input as any);
let items = Array.isArray(result) ? result : ((result as any).items ?? []);
let items = (Array.isArray(result) ? result : ((result as any).items ?? [])).filter(
isVisibleModel,
);
if (options.type) {
items = items.filter((m: any) => m.type === options.type);
+2
View File
@@ -14,6 +14,7 @@ import { registerDocCommand } from './commands/doc';
import { registerEvalCommand } from './commands/eval';
import { registerFileCommand } from './commands/file';
import { registerGenerateCommand } from './commands/generate';
import { registerHeteroCommand } from './commands/hetero';
import { registerKbCommand } from './commands/kb';
import { registerLoginCommand } from './commands/login';
import { registerLogoutCommand } from './commands/logout';
@@ -62,6 +63,7 @@ export function createProgram() {
registerCronCommand(program);
registerGenerateCommand(program);
registerFileCommand(program);
registerHeteroCommand(program);
registerSkillCommand(program);
registerSessionGroupCommand(program);
registerTaskCommand(program);
+25 -14
View File
@@ -27,22 +27,22 @@ describe('executeToolCall', () => {
fs.rmSync(tmpDir, { force: true, recursive: true });
});
it('should dispatch readLocalFile', async () => {
it('should dispatch readFile', async () => {
const filePath = path.join(tmpDir, 'test.txt');
await writeFile(filePath, 'hello world');
const result = await executeToolCall('readLocalFile', JSON.stringify({ path: filePath }));
const result = await executeToolCall('readFile', JSON.stringify({ path: filePath }));
expect(result.success).toBe(true);
const parsed = JSON.parse(result.content);
expect(parsed.content).toContain('hello world');
});
it('should dispatch writeLocalFile', async () => {
it('should dispatch writeFile', async () => {
const filePath = path.join(tmpDir, 'new.txt');
const result = await executeToolCall(
'writeLocalFile',
'writeFile',
JSON.stringify({ content: 'written', path: filePath }),
);
@@ -50,6 +50,17 @@ describe('executeToolCall', () => {
expect(fs.readFileSync(filePath, 'utf8')).toBe('written');
});
it('should dispatch legacy alias readLocalFile', async () => {
const filePath = path.join(tmpDir, 'legacy.txt');
await writeFile(filePath, 'legacy hello');
const result = await executeToolCall('readLocalFile', JSON.stringify({ path: filePath }));
expect(result.success).toBe(true);
const parsed = JSON.parse(result.content);
expect(parsed.content).toContain('legacy hello');
});
it('should dispatch runCommand', async () => {
const result = await executeToolCall(
'runCommand',
@@ -61,21 +72,21 @@ describe('executeToolCall', () => {
expect(parsed.stdout).toContain('dispatched');
});
it('should dispatch listLocalFiles', async () => {
it('should dispatch listFiles', async () => {
await writeFile(path.join(tmpDir, 'a.txt'), 'a');
const result = await executeToolCall('listLocalFiles', JSON.stringify({ path: tmpDir }));
const result = await executeToolCall('listFiles', JSON.stringify({ path: tmpDir }));
expect(result.success).toBe(true);
const parsed = JSON.parse(result.content);
expect(parsed.totalCount).toBeGreaterThan(0);
});
it('should dispatch globLocalFiles', async () => {
it('should dispatch globFiles', async () => {
await writeFile(path.join(tmpDir, 'test.ts'), 'code');
const result = await executeToolCall(
'globLocalFiles',
'globFiles',
JSON.stringify({ cwd: tmpDir, pattern: '*.ts' }),
);
@@ -84,12 +95,12 @@ describe('executeToolCall', () => {
expect(parsed.files).toContain('test.ts');
});
it('should dispatch editLocalFile', async () => {
it('should dispatch editFile', async () => {
const filePath = path.join(tmpDir, 'edit.txt');
await writeFile(filePath, 'old content');
const result = await executeToolCall(
'editLocalFile',
'editFile',
JSON.stringify({
file_path: filePath,
new_string: 'new content',
@@ -116,7 +127,7 @@ describe('executeToolCall', () => {
const filePath = path.join(tmpDir, 'str.txt');
await writeFile(filePath, 'content');
const result = await executeToolCall('readLocalFile', JSON.stringify({ path: filePath }));
const result = await executeToolCall('readFile', JSON.stringify({ path: filePath }));
expect(result.success).toBe(true);
// Result should be valid JSON
@@ -124,7 +135,7 @@ describe('executeToolCall', () => {
});
it('should return error for invalid JSON arguments', async () => {
const result = await executeToolCall('readLocalFile', 'not-json');
const result = await executeToolCall('readFile', 'not-json');
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
@@ -141,11 +152,11 @@ describe('executeToolCall', () => {
expect(result.success).toBe(true);
});
it('should dispatch searchLocalFiles', async () => {
it('should dispatch searchFiles', async () => {
await writeFile(path.join(tmpDir, 'search_target.txt'), 'found');
const result = await executeToolCall(
'searchLocalFiles',
'searchFiles',
JSON.stringify({ directory: tmpDir, keywords: 'search_target' }),
);
+11 -3
View File
@@ -11,14 +11,22 @@ import {
import { getCommandOutput, killCommand, runCommand } from './shell';
const methodMap: Record<string, (args: any) => Promise<unknown>> = {
editLocalFile,
editFile: editLocalFile,
getCommandOutput,
globLocalFiles,
globFiles: globLocalFiles,
grepContent,
killCommand,
listFiles: listLocalFiles,
readFile: readLocalFile,
runCommand,
searchFiles: searchLocalFiles,
writeFile: writeLocalFile,
// Legacy aliases — older Gateway versions may still send the long form
editLocalFile,
globLocalFiles,
listLocalFiles,
readLocalFile,
runCommand,
searchLocalFiles,
writeLocalFile,
};
+99
View File
@@ -0,0 +1,99 @@
import type { AgentStreamEvent } from '@lobechat/heterogeneous-agents/spawn';
export interface IngestSink {
finish: (params: {
error?: { message: string; type: string };
result: 'cancelled' | 'error' | 'success';
sessionId?: string;
}) => Promise<void>;
ingest: (events: AgentStreamEvent[]) => Promise<void>;
}
export class NoopIngestSink implements IngestSink {
async finish(_params: Parameters<IngestSink['finish']>[0]): Promise<void> {}
async ingest(_events: AgentStreamEvent[]): Promise<void> {}
}
const MAX_BATCH = 50;
const FLUSH_INTERVAL_MS = 250;
const MAX_RETRIES = 5;
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
/**
* Buffers `AgentStreamEvent`s and flushes them in batches to an `IngestSink`.
*
* Flush triggers:
* - Buffer reaches MAX_BATCH (50) → immediate flush
* - FLUSH_INTERVAL_MS (250ms) timer fires → flush whatever is buffered
*
* Each batch is retried up to MAX_RETRIES (5) times with exponential back-off
* starting at 500ms, doubling up to 8s. After the final retry the error is
* stored and re-thrown by `drain()`, allowing the caller to call
* `sink.finish({ result: 'error' })` and exit(1).
*
* Call order: push() repeatedly → drain() once (before finish()).
*/
export class BatchIngester {
private buffer: AgentStreamEvent[] = [];
private fatalError: Error | null = null;
private inflightFlush: Promise<void> = Promise.resolve();
private timer: ReturnType<typeof setTimeout> | null = null;
constructor(private readonly sink: IngestSink) {}
push(event: AgentStreamEvent): void {
if (this.fatalError) return;
this.buffer.push(event);
if (this.buffer.length >= MAX_BATCH) {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
this.triggerFlush();
} else if (!this.timer) {
this.timer = setTimeout(() => {
this.timer = null;
this.triggerFlush();
}, FLUSH_INTERVAL_MS);
}
}
/** Flush remaining buffer and wait for all in-flight sends to settle. */
async drain(): Promise<void> {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
this.triggerFlush();
await this.inflightFlush;
if (this.fatalError) throw this.fatalError;
}
private async sendWithRetry(batch: AgentStreamEvent[]): Promise<void> {
let delay = 500;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
await this.sink.ingest(batch);
return;
} catch (err) {
if (attempt === MAX_RETRIES) {
this.fatalError = err instanceof Error ? err : new Error(String(err));
throw this.fatalError;
}
await sleep(delay);
delay = Math.min(delay * 2, 8_000);
}
}
}
private triggerFlush(): void {
if (this.fatalError || this.buffer.length === 0) return;
const batch = this.buffer.splice(0);
this.inflightFlush = this.inflightFlush
.then(() => this.sendWithRetry(batch))
.catch(() => {
// fatalError is already set; drain() re-throws it
});
}
}
+38
View File
@@ -0,0 +1,38 @@
import type { AgentStreamEvent } from '@lobechat/heterogeneous-agents/spawn';
import type { TrpcClient } from '../api/client';
import type { IngestSink } from './BatchIngester';
/**
* `IngestSink` implementation that forwards batches to the server via tRPC
* (`aiAgent.heteroIngest` / `aiAgent.heteroFinish`).
*
* The CLI authenticates using the `LOBEHUB_JWT` env var (operation-scoped JWT
* injected by the server before spawning the sandbox / desktop process).
*/
export class TrpcIngestSink implements IngestSink {
constructor(
private readonly client: TrpcClient,
private readonly agentType: 'claude-code' | 'codex',
private readonly operationId: string,
private readonly topicId: string,
) {}
async finish(params: Parameters<IngestSink['finish']>[0]): Promise<void> {
await this.client.aiAgent.heteroFinish.mutate({
agentType: this.agentType,
operationId: this.operationId,
topicId: this.topicId,
...params,
});
}
async ingest(events: AgentStreamEvent[]): Promise<void> {
await this.client.aiAgent.heteroIngest.mutate({
agentType: this.agentType,
events: events as any,
operationId: this.operationId,
topicId: this.topicId,
});
}
}
+1
View File
@@ -58,6 +58,7 @@
"@lobechat/electron-client-ipc": "workspace:*",
"@lobechat/electron-server-ipc": "workspace:*",
"@lobechat/file-loaders": "workspace:*",
"@lobechat/heterogeneous-agents": "workspace:*",
"@lobechat/local-file-shell": "workspace:*",
"@lobehub/i18n-cli": "^1.25.1",
"@modelcontextprotocol/sdk": "^1.24.3",
+1
View File
@@ -1,6 +1,7 @@
packages:
- '../cli'
- '../../packages/agent-gateway-client'
- '../../packages/heterogeneous-agents'
- '../../packages/const'
- '../../packages/electron-server-ipc'
- '../../packages/electron-client-ipc'
+1
View File
@@ -26,6 +26,7 @@ export const defaultProxySettings: NetworkProxySettings = {
* Storage default values
*/
export const STORE_DEFAULTS: ElectronMainStore = {
appTrayVisible: true,
dataSyncConfig: { storageMode: 'cloud' },
encryptedTokens: {},
gatewayDeviceDescription: '',
@@ -1,7 +1,9 @@
import type { AgentRunRequestMessage } from '@lobechat/device-gateway-client';
import type { GatewayConnectionStatus } from '@lobechat/electron-client-ipc';
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
import HeterogeneousAgentCtr from './HeterogeneousAgentCtr';
import { ControllerModule, IpcMethod } from './index';
import LocalFileCtr from './LocalFileCtr';
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
@@ -33,6 +35,10 @@ export default class GatewayConnectionCtr extends ControllerModule {
return this.app.getController(ShellCommandCtr);
}
private get heterogeneousAgentCtr() {
return this.app.getController(HeterogeneousAgentCtr);
}
// ─── Lifecycle ───
afterAppReady() {
@@ -47,6 +53,9 @@ export default class GatewayConnectionCtr extends ControllerModule {
// Wire up tool call handler
srv.setToolCallHandler((apiName, args) => this.executeToolCall(apiName, args));
// Wire up agent run handler
srv.setAgentRunHandler((request) => this.executeAgentRun(request));
// Auto-connect if already logged in
this.tryAutoConnect();
}
@@ -108,23 +117,81 @@ export default class GatewayConnectionCtr extends ControllerModule {
await this.service.connect();
}
// ─── Agent Run Routing ───
private async executeAgentRun(
request: AgentRunRequestMessage,
): Promise<{ reason?: string; status: 'accepted' | 'rejected' }> {
try {
const ctr = this.heterogeneousAgentCtr;
// Create a session for the hetero agent.
const { sessionId } = await ctr.startSession({
agentType: request.agentType,
args: [],
command: request.agentType === 'codex' ? 'codex' : 'claude',
cwd: request.cwd,
// Inject LOBEHUB_JWT so the CLI authenticates against heteroIngest.
env: { LOBEHUB_JWT: request.jwt },
resumeSessionId: request.resumeSessionId,
});
// Fire-and-forget: sendPrompt runs the CLI until completion.
ctr
.sendPrompt({
operationId: request.operationId,
prompt: request.prompt,
sessionId,
})
.catch((err: Error) => {
// Errors are surfaced via heteroFinish on the server side.
// Log locally for desktop debugging only.
console.error('[GatewayConnectionCtr] agent run failed:', err.message);
});
return { status: 'accepted' };
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
return { reason, status: 'rejected' };
}
}
// ─── Tool Call Routing ───
private async executeToolCall(apiName: string, args: any): Promise<unknown> {
const editFile = () => this.localFileCtr.handleEditFile(args);
const globFiles = () => this.localFileCtr.handleGlobFiles(args);
const listFiles = () => this.localFileCtr.listLocalFiles(args);
const moveFiles = () => this.localFileCtr.handleMoveFiles(args);
const readFile = () => this.localFileCtr.readFile(args);
const searchFiles = () => this.localFileCtr.handleLocalFilesSearch(args);
const writeFile = () => this.localFileCtr.handleWriteFile(args);
const methodMap: Record<string, () => Promise<unknown>> = {
editLocalFile: () => this.localFileCtr.handleEditFile(args),
globLocalFiles: () => this.localFileCtr.handleGlobFiles(args),
editFile,
globFiles,
grepContent: () => this.localFileCtr.handleGrepContent(args),
listLocalFiles: () => this.localFileCtr.listLocalFiles(args),
moveLocalFiles: () => this.localFileCtr.handleMoveFiles(args),
readLocalFile: () => this.localFileCtr.readFile(args),
renameLocalFile: () => this.localFileCtr.handleRenameFile(args),
searchLocalFiles: () => this.localFileCtr.handleLocalFilesSearch(args),
writeLocalFile: () => this.localFileCtr.handleWriteFile(args),
listFiles,
moveFiles,
readFile,
searchFiles,
writeFile,
getCommandOutput: () => this.shellCommandCtr.handleGetCommandOutput(args),
killCommand: () => this.shellCommandCtr.handleKillCommand(args),
runCommand: () => this.shellCommandCtr.handleRunCommand(args),
// Legacy aliases — keep these so older Gateway versions sending the long
// names continue to route correctly. `renameLocalFile` is also kept even
// though the new surface drops rename (it's now handled by `moveFiles`).
editLocalFile: editFile,
globLocalFiles: globFiles,
listLocalFiles: listFiles,
moveLocalFiles: moveFiles,
readLocalFile: readFile,
renameLocalFile: () => this.localFileCtr.handleRenameFile(args),
searchLocalFiles: searchFiles,
writeLocalFile: writeFile,
};
const handler = methodMap[apiName];
+691 -2
View File
@@ -1,17 +1,23 @@
import { execFile } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { execFile, spawn } from 'node:child_process';
import { readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import { promisify } from 'node:util';
import type {
GetGitBranchDiffPayload,
GitAheadBehind,
GitBranchDiffPatches,
GitBranchInfo,
GitBranchListItem,
GitCheckoutResult,
GitFileDiffStatus,
GitLinkedPullRequestResult,
GitPullResult,
GitPushResult,
GitRemoteBranchListItem,
GitWorkingTreeFiles,
GitWorkingTreePatch,
GitWorkingTreePatches,
GitWorkingTreeStatus,
} from '@lobechat/electron-client-ipc';
@@ -22,6 +28,412 @@ import { ControllerModule, IpcMethod } from './index';
const logger = createLogger('controllers:GitCtr');
interface DirtyEntry {
filePath: string;
status: GitFileDiffStatus;
}
interface DiffBlock {
isBinary: boolean;
patch: string;
/** Destination path (or source path for deleted files). */
path: string;
}
/**
* Split the output of `git diff HEAD --` into one block per file. Each block
* starts at a `^diff --git ` line and runs to just before the next one (or
* EOF). Path comes from the `+++ b/<path>` line, falling back to `--- a/<path>`
* when the destination is `/dev/null` (deletion). Quoted paths (spaces /
* non-ASCII when `core.quotepath` is on) are minimally de-escaped.
*/
const splitBulkDiff = (diffText: string): DiffBlock[] => {
if (!diffText) return [];
const blocks: DiffBlock[] = [];
const headerRe = /^diff --git /gm;
const starts: number[] = [];
let m: RegExpExecArray | null;
while ((m = headerRe.exec(diffText)) !== null) starts.push(m.index);
for (let i = 0; i < starts.length; i++) {
const start = starts[i];
const end = i + 1 < starts.length ? starts[i + 1] : diffText.length;
const block = diffText.slice(start, end);
const filePath = extractPathFromDiffBlock(block);
if (!filePath) continue;
blocks.push({
isBinary: /^Binary files .* differ$/m.test(block),
path: filePath,
patch: block,
});
}
return blocks;
};
/**
* Pull the file path out of a per-file diff block. Looks at the `+++ b/<path>`
* line first (covers add/modify); falls back to `--- a/<path>` for deletes
* where `+++` is `/dev/null`; final fallback is the `diff --git a/x b/y`
* header line.
*/
const extractPathFromDiffBlock = (block: string): string | null => {
let plusPath: string | null = null;
let minusPath: string | null = null;
for (const line of block.split('\n')) {
if (line.startsWith('+++ ')) {
plusPath = parseDiffPathLine(line.slice(4), 'b/');
} else if (line.startsWith('--- ')) {
minusPath = parseDiffPathLine(line.slice(4), 'a/');
}
// The file headers always come before the first hunk / binary marker;
// bail once we hit either to avoid scanning huge diff bodies.
if (line.startsWith('@@') || line.startsWith('Binary files ')) break;
}
if (plusPath) return plusPath;
if (minusPath) return minusPath;
// Last-resort: parse the `diff --git a/x b/y` header itself.
const header = block.split('\n', 1)[0];
const match = /^diff --git a\/.+? b\/(.+)$/.exec(header);
return match ? match[1] : null;
};
/**
* Strip the `a/` or `b/` prefix off a `+++` / `---` line, drop the optional
* trailing tab+timestamp, and de-quote git's C-style escaping. Returns null
* for `/dev/null` (which means the other side of the diff is the real path).
*/
const parseDiffPathLine = (raw: string, prefix: 'a/' | 'b/'): string | null => {
const tabIdx = raw.indexOf('\t');
let p = tabIdx >= 0 ? raw.slice(0, tabIdx) : raw;
if (p === '/dev/null') return null;
// Quoted form: "b/path with spaces"
if (p.startsWith('"') && p.endsWith('"')) {
p = dequoteGitPath(p.slice(1, -1));
}
return p.startsWith(prefix) ? p.slice(prefix.length) : p;
};
export const dequoteGitPath = (s: string): string =>
s.replaceAll(/\\(["\\trn]|[0-7]{3})/g, (_, esc: string) => {
if (esc === '"') return '"';
if (esc === '\\') return '\\';
if (esc === 't') return '\t';
if (esc === 'r') return '\r';
if (esc === 'n') return '\n';
return String.fromCodePoint(Number.parseInt(esc, 8));
});
/**
* Inverse of {@link dequoteGitPath} — returns either `<prefix><path>` (when
* no escaping is needed) or git's C-style quoted form `"<prefix><escaped>"`
* (when the path contains TAB / LF / CR / quote / backslash / control bytes).
* The prefix lives *inside* the quotes so the output matches what real `git
* diff` would emit, e.g. `"a/file\twith tab.txt"` rather than `a/"file\twith
* tab.txt"`. Plain spaces are not quoted (git tolerates them; the trailing
* ` b/<path>` marker on the diff header is enough to delimit the source).
*/
// eslint-disable-next-line no-control-regex
const NEEDS_QUOTING = /["\\\x00-\x1F\x7F]/;
export const quoteGitPath = (prefix: 'a/' | 'b/', filePath: string): string => {
const combined = prefix + filePath;
if (!NEEDS_QUOTING.test(combined)) return combined;
let out = '"';
for (const ch of combined) {
if (ch === '\\') out += '\\\\';
else if (ch === '"') out += '\\"';
else if (ch === '\t') out += '\\t';
else if (ch === '\n') out += '\\n';
else if (ch === '\r') out += '\\r';
else {
const code = ch.codePointAt(0)!;
if (code < 0x20 || code === 0x7f) {
out += '\\' + code.toString(8).padStart(3, '0');
} else {
out += ch;
}
}
}
return out + '"';
};
/**
* Status from a single diff block's preamble: `new file mode` → added,
* `deleted file mode` → deleted, otherwise modified. Used by branch-diff mode
* where there's no `git status` to consult — the diff itself is the source.
*/
const detectDiffBlockStatus = (block: string): GitFileDiffStatus => {
// Only scan up to the first hunk / binary marker so huge bodies aren't walked.
for (const line of block.split('\n')) {
if (line.startsWith('new file mode ')) return 'added';
if (line.startsWith('deleted file mode ')) return 'deleted';
if (line.startsWith('@@') || line.startsWith('Binary files ')) break;
}
return 'modified';
};
/** Walk a patch counting `+`/`-` lines while skipping `+++`/`---` headers. */
const countAddDel = (patch: string): { additions: number; deletions: number } => {
let additions = 0;
let deletions = 0;
for (const line of patch.split('\n')) {
if (line.startsWith('+++') || line.startsWith('---')) continue;
if (line.startsWith('+')) additions++;
else if (line.startsWith('-')) deletions++;
}
return { additions, deletions };
};
const emptyPatch = (entry: DirtyEntry): GitWorkingTreePatch => ({
additions: 0,
deletions: 0,
filePath: entry.filePath,
isBinary: false,
patch: '',
status: entry.status,
truncated: false,
});
const buildTrackedPatch = (
entry: DirtyEntry,
block: DiffBlock,
maxBytes: number,
): GitWorkingTreePatch => {
if (block.isBinary) {
return { ...emptyPatch(entry), isBinary: true };
}
if (block.patch.length > maxBytes) {
return { ...emptyPatch(entry), truncated: true };
}
const { additions, deletions } = countAddDel(block.patch);
return {
additions,
deletions,
filePath: entry.filePath,
isBinary: false,
patch: block.patch,
status: entry.status,
truncated: false,
};
};
/**
* Build a synthetic add-only patch for an untracked file by reading it from
* disk — replaces the per-file `git diff --no-index /dev/null <file>` fork.
* Binary detection uses a NUL-byte sniff over the first 8 KB (matches what
* git itself does internally).
*/
const readUntrackedAsPatch = async (
cwd: string,
entry: DirtyEntry,
maxBytes: number,
): Promise<GitWorkingTreePatch> => {
const absolute = path.resolve(cwd, entry.filePath);
let size: number;
try {
const s = await stat(absolute);
if (!s.isFile()) return emptyPatch(entry);
size = s.size;
} catch (error: any) {
logger.debug('[readUntrackedAsPatch] stat failed', {
filePath: entry.filePath,
message: error?.message,
});
return emptyPatch(entry);
}
// Pre-quote so the path is C-style escaped wherever it lands in the synthetic
// patch — raw `entry.filePath` interpolation would emit malformed `diff --git`
// / `+++` lines for filenames containing TAB / LF / quote / backslash.
const aPath = quoteGitPath('a/', entry.filePath);
const bPath = quoteGitPath('b/', entry.filePath);
if (size === 0) {
return {
...emptyPatch(entry),
patch:
[
`diff --git ${aPath} ${bPath}`,
'new file mode 100644',
'--- /dev/null',
`+++ ${bPath}`,
].join('\n') + '\n',
};
}
// Cap the synthesized patch by *file* size, not patch size — a 200 KB file
// produces a ~200 KB patch (one `+` per line). Close enough.
if (size > maxBytes) {
return { ...emptyPatch(entry), truncated: true };
}
let buf: Buffer;
try {
buf = await readFile(absolute);
} catch (error: any) {
logger.debug('[readUntrackedAsPatch] read failed', {
filePath: entry.filePath,
message: error?.message,
});
return emptyPatch(entry);
}
const sniffEnd = Math.min(buf.length, 8192);
for (let i = 0; i < sniffEnd; i++) {
if (buf[i] === 0) return { ...emptyPatch(entry), isBinary: true };
}
const text = buf.toString('utf8');
// text.split('\n') leaves a trailing '' when the file ends with '\n';
// exclude it so the hunk header line count matches git's own output.
const rawLines = text.split('\n');
const trailingEmpty = rawLines.length > 0 && rawLines.at(-1) === '';
const lineCount = trailingEmpty ? rawLines.length - 1 : rawLines.length;
if (lineCount === 0) {
return { ...emptyPatch(entry), patch: '' };
}
const body = rawLines
.slice(0, lineCount)
.map((line) => '+' + line)
.join('\n');
// Mirror `git diff --no-index`'s "no newline at end of file" footer when the
// source had no trailing newline — keeps PatchDiff's hunk parser happy.
const noNewlineFooter = trailingEmpty ? '' : '\n\\ No newline at end of file';
const patch =
[
`diff --git ${aPath} ${bPath}`,
'new file mode 100644',
'--- /dev/null',
`+++ ${bPath}`,
`@@ -0,0 +1,${lineCount} @@`,
body,
].join('\n') +
noNewlineFooter +
'\n';
return {
additions: lineCount,
deletions: 0,
filePath: entry.filePath,
isBinary: false,
patch,
status: entry.status,
truncated: false,
};
};
/**
* Stream a git invocation's stdout via `spawn` instead of `execFile`'s
* fixed-size buffer. Replaces the bulk-diff caller's old 64 MB `maxBuffer`
* cap — pipe-buffer-sized chunks accumulate in memory until the process
* exits, with no hard ceiling. SIGTERM on timeout. Resolves with the full
* stdout string; rejects with an Error carrying `stderr` and `partialStdout`
* fields so callers can salvage partial output (or fall back) on failure.
*/
const runGitCaptureStream = (cwd: string, args: string[], timeoutMs: number): Promise<string> =>
new Promise((resolve, reject) => {
const child = spawn('git', args, { cwd });
const stdoutChunks: Buffer[] = [];
let stderrBuf = '';
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill('SIGTERM');
}, timeoutMs);
child.stdout.on('data', (chunk: Buffer) => stdoutChunks.push(chunk));
child.stderr.on('data', (chunk: Buffer) => {
stderrBuf += chunk.toString('utf8');
});
child.on('error', (err) => {
clearTimeout(timer);
reject(Object.assign(err, { stderr: stderrBuf }));
});
child.on('close', (code) => {
clearTimeout(timer);
const stdout = Buffer.concat(stdoutChunks).toString('utf8');
if (timedOut) {
const err: any = new Error('git command timed out');
err.stderr = stderrBuf;
err.partialStdout = stdout;
return reject(err);
}
// `git diff HEAD` (without --exit-code) exits 0 even when there are
// diffs; non-zero is therefore a real error.
if (code !== 0) {
const err: any = new Error(`git exited with code ${code}`);
err.code = code;
err.stderr = stderrBuf;
err.partialStdout = stdout;
return reject(err);
}
resolve(stdout);
});
});
/**
* Last-resort per-file diff for tracked entries the bulk diff didn't cover —
* either because the bulk command failed entirely or because git emitted no
* patch for a path the status step listed (rare race with concurrent writes).
* Mirrors the original per-file behavior so individual files keep their
* patches even when the bulk fast-path is unavailable.
*/
const fetchTrackedPatchPerFile = async (
cwd: string,
entry: DirtyEntry,
maxBytes: number,
): Promise<GitWorkingTreePatch> => {
const execFileAsync = promisify(execFile);
let text: string;
try {
const { stdout } = await execFileAsync(
'git',
['-c', 'core.quotepath=off', 'diff', '--no-color', 'HEAD', '--', entry.filePath],
{
cwd,
encoding: 'utf8',
maxBuffer: maxBytes * 4,
timeout: 10_000,
},
);
text = stdout as string;
} catch (error: any) {
logger.debug('[fetchTrackedPatchPerFile] diff failed', {
filePath: entry.filePath,
stderr: error?.stderr?.toString?.() ?? error?.stderr,
});
return emptyPatch(entry);
}
if (text.length > maxBytes) return { ...emptyPatch(entry), truncated: true };
if (/^Binary files .* differ$/m.test(text)) return { ...emptyPatch(entry), isBinary: true };
if (!text) return emptyPatch(entry);
const { additions, deletions } = countAddDel(text);
return {
additions,
deletions,
filePath: entry.filePath,
isBinary: false,
patch: text,
status: entry.status,
truncated: false,
};
};
/**
* Bounded `Promise.all` — runs at most `limit` async tasks at a time. Used
* for the per-file fallback so we cap fork pressure at a small constant
* instead of replaying the original 200-parallel `git diff` storm.
*/
const mapWithConcurrency = async <T, R>(
items: T[],
limit: number,
fn: (item: T) => Promise<R>,
): Promise<R[]> => {
const results: R[] = Array.from({ length: items.length });
let cursor = 0;
const workerCount = Math.min(limit, items.length);
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (true) {
const idx = cursor++;
if (idx >= items.length) return;
results[idx] = await fn(items[idx]);
}
}),
);
return results;
};
export default class GitController extends ControllerModule {
static override readonly groupName = 'git';
@@ -162,6 +574,54 @@ export default class GitController extends ControllerModule {
}
}
/**
* List remote branches under `refs/remotes/origin/*`, ordered by most
* recent commit. The `HEAD` symref is filtered out and the resolved
* default branch is flagged via `isDefault` so the UI can render it
* with a marker. Used by the Review panel's branch-compare picker.
*/
@IpcMethod()
async listGitRemoteBranches(dirPath: string): Promise<GitRemoteBranchListItem[]> {
const execFileAsync = promisify(execFile);
let defaultRef: string | undefined;
try {
const { stdout } = await execFileAsync(
'git',
['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'],
{ cwd: dirPath, timeout: 5000 },
);
defaultRef = stdout.trim() || undefined;
} catch {
defaultRef = undefined;
}
try {
const { stdout } = await execFileAsync(
'git',
[
'for-each-ref',
'--sort=-committerdate',
'--format=%(refname:short)',
'refs/remotes/origin',
],
{ cwd: dirPath, timeout: 5000 },
);
return stdout
.replaceAll('\r', '')
.split('\n')
.map((line) => line.trim())
.filter((name) => name.length > 0 && name !== 'origin/HEAD' && !name.endsWith('/HEAD'))
.map((name) => ({ isDefault: name === defaultRef, name }));
} catch (error: any) {
logger.warn('[listGitRemoteBranches] git command failed', {
code: error?.code,
cwd: dirPath,
message: error?.message,
stderr: error?.stderr?.toString?.() ?? error?.stderr,
});
return [];
}
}
/**
* Bucket dirty files into added / modified / deleted via `git status --porcelain -z`.
* Each file is counted once: untracked (`??`) and staged-add (`A`) → added,
@@ -261,6 +721,235 @@ export default class GitController extends ControllerModule {
}
}
/**
* Pull every dirty file's unified diff in one shot — one IPC call returns
* the patches the renderer needs to render `<PatchDiff />` per file.
*
* Tracked changes (modified / deleted / staged-A) all come from a *single*
* `git diff HEAD --` invocation that we split per-file in JS — fork-bombing
* the main process with N parallel `git diff` subprocesses was costing us
* ~510ms × N in fork overhead plus `.git/index` lock contention, and the
* libuv worker pool stayed busy while other IPC handlers queued. One
* subprocess instead of N keeps the freeze invisible.
*
* Untracked files are read directly with `fs.readFile` and a synthetic
* `--- /dev/null / +++ b/<path>` patch is built in Node — no `git diff`
* subprocess at all.
*
* 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.
*/
@IpcMethod()
async getGitWorkingTreePatches(dirPath: string): Promise<GitWorkingTreePatches> {
const MAX_PATCH_BYTES = 256 * 1024;
const execFileAsync = promisify(execFile);
interface Entry {
filePath: string;
isUntracked: boolean;
status: GitFileDiffStatus;
}
// 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.
const entries: Entry[] = [];
try {
const { stdout } = await execFileAsync('git', ['status', '--porcelain', '-z'], {
cwd: dirPath,
timeout: 5000,
});
const tokens = stdout.split('\0');
let i = 0;
while (i < tokens.length) {
const entry = tokens[i];
i++;
if (entry.length < 3) continue;
const x = entry[0];
const y = entry[1];
const filePath = entry.slice(3);
// R/C entries carry an extra source-path token we must consume.
if (x === 'R' || x === 'C') i++;
if (!filePath) continue;
if (x === '?' && y === '?') {
entries.push({ filePath, isUntracked: true, status: 'added' });
} else if (x === '!' && y === '!') {
// ignored
} else if (x === 'D' || y === 'D') {
entries.push({ filePath, isUntracked: false, status: 'deleted' });
} else if (x === 'A' || y === 'A') {
entries.push({ filePath, isUntracked: false, status: 'added' });
} else {
entries.push({ filePath, isUntracked: false, status: 'modified' });
}
}
} catch (error: any) {
logger.warn('[getGitWorkingTreePatches] status failed', {
cwd: dirPath,
stderr: error?.stderr?.toString?.() ?? error?.stderr,
});
return { patches: [] };
}
// Step 2a — single bulk `git diff HEAD` for every tracked dirty path,
// then split per-file in JS. We pass paths explicitly (not all) so a
// huge unrelated working tree doesn't pull extra patches into the
// stream. Output is streamed via spawn so there's no maxBuffer ceiling
// — even a multi-hundred-MB combined diff lands intact, and any partial
// output recovered from a failed run still feeds the per-file fallback.
const trackedEntries = entries.filter((e) => !e.isUntracked);
const trackedByPath = new Map(trackedEntries.map((e) => [e.filePath, e]));
const trackedPatches = new Map<string, GitWorkingTreePatch>();
if (trackedEntries.length > 0) {
let bulkDiff = '';
try {
bulkDiff = await runGitCaptureStream(
dirPath,
[
'-c',
'core.quotepath=off',
'diff',
'--no-color',
'HEAD',
'--',
...trackedEntries.map((e) => e.filePath),
],
30_000,
);
} catch (error: any) {
logger.warn('[getGitWorkingTreePatches] bulk diff failed; per-file fallback', {
cwd: dirPath,
stderr: error?.stderr?.toString?.() ?? error?.stderr,
});
// Salvage any patches that did stream through before the failure —
// the per-file fallback below only retries the stragglers.
if (typeof error?.partialStdout === 'string') bulkDiff = error.partialStdout;
}
for (const block of splitBulkDiff(bulkDiff)) {
const entry = trackedByPath.get(block.path);
if (!entry) continue;
trackedPatches.set(entry.filePath, buildTrackedPatch(entry, block, MAX_PATCH_BYTES));
}
// Anything the bulk diff didn't cover (bulk crashed, race-with-write,
// or git emitted no patch for a path status flagged dirty) gets a
// per-file retry. Concurrency-capped to avoid the original fork storm.
const stragglers = trackedEntries.filter((e) => !trackedPatches.has(e.filePath));
if (stragglers.length > 0) {
const recovered = await mapWithConcurrency(stragglers, 8, (entry) =>
fetchTrackedPatchPerFile(dirPath, entry, MAX_PATCH_BYTES),
);
for (const patch of recovered) trackedPatches.set(patch.filePath, patch);
}
}
// Step 2b — read untracked files directly in Node. fs.readFile is bounded
// by libuv's thread pool (4 by default) so unbounded Promise.all is fine.
const untrackedEntries = entries.filter((e) => e.isUntracked);
const untrackedPatches = await Promise.all(
untrackedEntries.map((entry) => readUntrackedAsPatch(dirPath, entry, MAX_PATCH_BYTES)),
);
// Step 3 — combine + sort to match the working-tree popover order.
const order: Record<GitFileDiffStatus, number> = { added: 0, modified: 1, deleted: 2 };
const allPatches: GitWorkingTreePatch[] = [...trackedPatches.values(), ...untrackedPatches];
allPatches.sort((a, b) => order[a.status] - order[b.status]);
return { patches: allPatches };
}
/**
* Diff every changed file between the current HEAD and the remote default
* branch (resolved via `refs/remotes/origin/HEAD` — typically `origin/main`
* or `origin/canary`). Uses `<base>...HEAD` so the result is "what this
* branch added since it forked", ignoring upstream-only commits.
*
* Best-effort `git fetch` first so the comparison reflects the latest
* remote state; fetch failures (offline / no creds / no `origin`) are
* swallowed and we fall back to whatever cached refs exist. Returns
* `baseRef: undefined` + empty patches when no remote default is set —
* the renderer surfaces a "noBaseRef" hint in that case.
*
* Patch parsing reuses the same bulk-split + size-cap path as the working
* tree variant; status comes from each diff block's preamble (no `git
* status` cross-reference needed since every block is from history).
*/
@IpcMethod()
async getGitBranchDiff(payload: GetGitBranchDiffPayload): Promise<GitBranchDiffPatches> {
const { path: dirPath, baseRef: baseRefOverride } = payload;
const MAX_PATCH_BYTES = 256 * 1024;
const execFileAsync = promisify(execFile);
// Step 1 — best-effort fetch so origin/<default> reflects remote HEAD.
try {
await execFileAsync('git', ['fetch', '--no-tags', '--quiet', 'origin'], {
cwd: dirPath,
timeout: 10_000,
});
} catch {
// swallow — fall through to cached refs
}
// Step 2 — pick the comparison base. When the caller passes an explicit
// override (e.g. user picked a non-default branch in the UI) we trust it;
// otherwise we resolve `refs/remotes/origin/HEAD`. The default may be
// missing on repos cloned with --no-checkout or after a remote rename —
// surface a "noBaseRef" empty state in that case so the user can run
// `git remote set-head origin --auto` themselves.
let baseRef: string | undefined = baseRefOverride;
if (!baseRef) {
try {
const { stdout } = await execFileAsync(
'git',
['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'],
{ cwd: dirPath, timeout: 5000 },
);
baseRef = stdout.trim() || undefined;
} catch {
baseRef = undefined;
}
}
// headRef populated even when baseRef is missing so the UI can still
// surface "fix/foo ← ?" instead of going completely blank.
const headRef = (await this.getGitBranch(dirPath)).branch;
if (!baseRef) {
return { headRef, patches: [] };
}
// Step 3 — single bulk diff against the merge base. Three-dot semantics
// (`base...HEAD`) ignore commits added to base after the branch forked,
// matching what users expect from "compare branch" UI on GitHub. Stream
// capture mirrors the working-tree path so multi-MB diffs land intact.
let bulkDiff = '';
try {
bulkDiff = await runGitCaptureStream(
dirPath,
['-c', 'core.quotepath=off', 'diff', '--no-color', `${baseRef}...HEAD`],
30_000,
);
} catch (error: any) {
logger.warn('[getGitBranchDiff] diff failed', {
baseRef,
cwd: dirPath,
stderr: error?.stderr?.toString?.() ?? error?.stderr,
});
if (typeof error?.partialStdout === 'string') bulkDiff = error.partialStdout;
}
// Step 4 — split + classify per-file from the diff preamble alone.
const patches: GitWorkingTreePatch[] = [];
for (const block of splitBulkDiff(bulkDiff)) {
const status = detectDiffBlockStatus(block.patch);
patches.push(buildTrackedPatch({ filePath: block.path, status }, block, MAX_PATCH_BYTES));
}
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 };
}
/**
* Count commits HEAD is ahead/behind its upstream tracking ref.
* Returns `hasUpstream: false` when the branch has no upstream configured
@@ -1,9 +1,10 @@
import type { ChildProcess } from 'node:child_process';
import { spawn } from 'node:child_process';
import { createHash, randomUUID } from 'node:crypto';
import { access, appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { access, appendFile, mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import type { Readable, Writable } from 'node:stream';
import { finished as streamFinished } from 'node:stream/promises';
import type { HeterogeneousAgentSessionError } from '@lobechat/electron-client-ipc';
import {
@@ -13,14 +14,17 @@ import {
CODEX_CLI_INSTALL_DOCS_URL,
HeterogeneousAgentSessionErrorCode,
} from '@lobechat/electron-client-ipc';
import type { AgentContentBlock } from '@lobechat/heterogeneous-agents/spawn';
import {
AgentStreamPipeline,
buildAgentInput,
materializeImageToPath,
normalizeImage,
} from '@lobechat/heterogeneous-agents/spawn';
import { app as electronApp, BrowserWindow } from 'electron';
import { getHeterogeneousAgentDriver } from '@/modules/heterogeneousAgent';
import { CodexFileChangeTracker } from '@/modules/heterogeneousAgent/codexFileChangeTracker';
import type {
HeterogeneousAgentImageAttachment,
HeterogeneousAgentParsedOutput,
} from '@/modules/heterogeneousAgent/types';
import type { HeterogeneousAgentImageAttachment } from '@/modules/heterogeneousAgent/types';
import { buildProxyEnv } from '@/modules/networkProxy/envBuilder';
import { detectHeterogeneousCliCommand } from '@/modules/toolDetectors';
import { createLogger } from '@/utils/logger';
@@ -52,16 +56,6 @@ const CODEX_RESUME_CWD_MISMATCH_PATTERNS = [
/** Directory under appStoragePath for caching downloaded files */
const FILE_CACHE_DIR = 'heteroAgent/files';
const CLI_TRACE_DIR = '.heerogeneous-tracing';
const IMAGE_EXTENSIONS_BY_MIME = {
'image/gif': '.gif',
'image/jpg': '.jpg',
'image/jpeg': '.jpg',
'image/pjpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
'image/x-png': '.png',
} as const satisfies Record<string, string>;
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const CODEX_STDERR_STATUS_LINE = 'Reading prompt from stdin...';
const CODEX_WARN_LOG_PATTERN = /^\d{4}-\d{2}-\d{2}T\S+\s+WARN\s+/;
const CODEX_LOG_PATTERN = /^\d{4}-\d{2}-\d{2}T\S+\s+(?:DEBUG|ERROR|INFO|TRACE|WARN)\s+/;
@@ -91,6 +85,12 @@ interface StartSessionResult {
interface SendPromptParams {
/** Image attachments to include in the prompt (downloaded from url, cached by id) */
imageList?: HeterogeneousAgentImageAttachment[];
/**
* Renderer-side operation id stamped onto every emitted `AgentStreamEvent`.
* Required: producer-side conversion is the V3 contract — by the time events
* reach the renderer they must already carry the operation they belong to.
*/
operationId: string;
prompt: string;
sessionId: string;
}
@@ -148,7 +148,7 @@ interface CliTraceSession {
* prompt transport, resume semantics, and raw stream shape without turning
* this controller into a giant `switch`.
*
* Lifecycle: startSession → sendPrompt → (heteroAgentRawLine broadcasts) → stopSession
* Lifecycle: startSession → sendPrompt → (heteroAgentEvent broadcasts) → stopSession
*/
export default class HeterogeneousAgentCtr extends ControllerModule {
static override readonly groupName = 'heterogeneousAgent';
@@ -574,125 +574,56 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
}
/**
* Derive a filesystem-safe cache key for attachments.
*
* Never use the raw image id as a path segment — upstream callers can persist
* arbitrary ids and path.join would treat traversal sequences as real
* directories. A stable hash preserves cache hits without trusting the id as a
* filename.
* Convert a desktop image attachment list into shared content blocks. Each
* attachment's id is preserved as the cache key so repeated prompts hit the
* same on-disk entries.
*/
private getImageCacheKey(imageId: string): string {
return createHash('sha256').update(imageId).digest('hex');
private toImageContentBlocks(
imageList: HeterogeneousAgentImageAttachment[],
): AgentContentBlock[] {
return imageList.map((image) => ({
source: { id: image.id, type: 'url', url: image.url },
type: 'image',
}));
}
/**
* Download an image by URL, with local disk cache keyed by id.
* Build a Claude Code stream-json user message with text + base64 images.
* Delegates to the shared `buildAgentInput`; the desktop wrapper exists only
* to preserve the helper signature consumed by existing drivers.
*/
private async resolveImage(
image: HeterogeneousAgentImageAttachment,
): Promise<{ buffer: Buffer; mimeType: string }> {
const cacheDir = this.fileCacheDir;
const cacheKey = this.getImageCacheKey(image.id);
const metaPath = path.join(cacheDir, `${cacheKey}.meta`);
const dataPath = path.join(cacheDir, cacheKey);
private async buildStreamJsonInput(
prompt: string,
imageList: HeterogeneousAgentImageAttachment[] = [],
): Promise<string> {
const blocks: AgentContentBlock[] = [];
if (prompt && prompt.length > 0) blocks.push({ text: prompt, type: 'text' });
blocks.push(...this.toImageContentBlocks(imageList));
// Check cache first
try {
const metaRaw = await readFile(metaPath, 'utf8');
const meta = JSON.parse(metaRaw);
const buffer = await readFile(dataPath);
logger.debug('Image cache hit:', image.id);
return { buffer, mimeType: meta.mimeType || 'image/png' };
} catch {
// Cache miss — download
}
logger.info('Downloading image:', image.id);
const res = await fetch(image.url);
if (!res.ok)
throw new Error(`Failed to download image ${image.id}: ${res.status} ${res.statusText}`);
const arrayBuffer = await res.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const mimeType = res.headers.get('content-type') || 'image/png';
// Write to cache
await mkdir(cacheDir, { recursive: true });
await writeFile(dataPath, buffer);
await writeFile(metaPath, JSON.stringify({ id: image.id, mimeType }));
logger.debug('Image cached:', image.id, `${buffer.length} bytes`);
return { buffer, mimeType };
}
private normalizeMimeType(mimeType: string): string {
return mimeType.split(';')[0]?.trim().toLowerCase() || '';
}
private guessImageExtensionByBuffer(buffer: Buffer): string | undefined {
if (buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) return '.png';
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return '.jpg';
const gifSignature = buffer.subarray(0, 6).toString('ascii');
if (gifSignature === 'GIF87a' || gifSignature === 'GIF89a') return '.gif';
if (
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
) {
return '.webp';
}
}
private guessImageExtension(
mimeType: string,
image: HeterogeneousAgentImageAttachment,
buffer: Buffer,
): string | undefined {
const knownByMime = IMAGE_EXTENSIONS_BY_MIME[this.normalizeMimeType(mimeType)];
if (knownByMime) return knownByMime;
try {
const pathname = new URL(image.url).pathname;
const ext = path.extname(pathname).toLowerCase();
if (ext) return ext === '.jpeg' ? '.jpg' : ext;
} catch {
// Fall through to byte sniffing below.
}
return this.guessImageExtensionByBuffer(buffer);
const plan = await buildAgentInput('claude-code', blocks, { cacheDir: this.fileCacheDir });
return plan.stdin;
}
/**
* Materialize an image attachment into a stable local file path so CLIs like
* Codex can consume it through `--image <file>`.
* Materialize image attachments into stable filesystem paths for path-mode
* agents (Codex `--image <file>`). Fails the prompt if any image cannot be
* fetched / decoded — partially-attached prompts confuse the agent more
* than they help.
*/
private async resolveCliImagePath(image: HeterogeneousAgentImageAttachment): Promise<string> {
const { buffer, mimeType } = await this.resolveImage(image);
const cacheKey = this.getImageCacheKey(image.id);
const ext = this.guessImageExtension(mimeType, image, buffer);
if (!ext) {
throw new Error(`Unsupported image type for ${image.id}`);
}
const filePath = path.join(this.fileCacheDir, `${cacheKey}${ext}`);
try {
await access(filePath);
} catch {
await mkdir(this.fileCacheDir, { recursive: true });
await writeFile(filePath, buffer);
}
return filePath;
}
private async resolveCliImagePaths(
imageList: HeterogeneousAgentImageAttachment[] = [],
): Promise<string[]> {
if (imageList.length === 0) return [];
const cacheDir = this.fileCacheDir;
const results = await Promise.allSettled(
imageList.map((image) => this.resolveCliImagePath(image)),
imageList.map(async (image) => {
const normalized = await normalizeImage(
{ id: image.id, type: 'url', url: image.url },
{ cacheDir },
);
return materializeImageToPath(normalized, cacheDir);
}),
);
const imagePaths: string[] = [];
@@ -718,37 +649,6 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
return imagePaths;
}
/**
* Build a stream-json user message with text + optional image content blocks.
*/
private async buildStreamJsonInput(
prompt: string,
imageList: HeterogeneousAgentImageAttachment[] = [],
): Promise<string> {
const content: any[] = [{ text: prompt, type: 'text' }];
for (const image of imageList) {
try {
const { buffer, mimeType } = await this.resolveImage(image);
content.push({
source: {
data: buffer.toString('base64'),
media_type: mimeType,
type: 'base64',
},
type: 'image',
});
} catch (err) {
logger.error(`Failed to resolve image ${image.id}:`, err);
}
}
return `${JSON.stringify({
message: { content, role: 'user' },
type: 'user',
})}\n`;
}
// ─── IPC methods ───
/**
@@ -779,8 +679,9 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
/**
* Send a prompt to an agent session.
*
* Spawns the CLI process with preset flags. Broadcasts each stdout line
* as an `heteroAgentRawLine` event — Renderer side parses and adapts.
* Spawns the CLI process with preset flags. Pipes each stdout chunk through
* the shared `AgentStreamPipeline` (JSONL → adapter → toStreamEvent) and
* broadcasts the resulting `AgentStreamEvent`s on `heteroAgentEvent`.
*/
@IpcMethod()
async sendPrompt(params: SendPromptParams): Promise<void> {
@@ -852,42 +753,49 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
}
session.process = proc;
const streamProcessor = driver.createStreamProcessor();
const codexFileChangeTracker =
session.agentType === 'codex' ? new CodexFileChangeTracker() : undefined;
// Producer-side conversion (V3 contract): JSONL framing + adapter +
// toStreamEvent all run inside the shared pipeline, so renderer + future
// server `heteroIngest` see the same `AgentStreamEvent` wire shape with
// no per-consumer adapter. The pipeline auto-wires the Codex
// file-change line-stat tracker when `agentType === 'codex'`, so this
// controller stays agent-agnostic.
const pipeline = new AgentStreamPipeline({
agentType: session.agentType,
operationId: params.operationId,
});
let stdoutBroadcastQueue: Promise<void> = Promise.resolve();
const broadcastParsedOutputs = (parsedOutputs: HeterogeneousAgentParsedOutput[]) => {
const broadcastPipelineBatch = (produce: () => ReturnType<AgentStreamPipeline['push']>) => {
stdoutBroadcastQueue = stdoutBroadcastQueue
.then(async () => {
for (const parsedOutput of parsedOutputs) {
if (parsedOutput.agentSessionId) {
session.agentSessionId = parsedOutput.agentSessionId;
}
const line = codexFileChangeTracker
? await codexFileChangeTracker.track(parsedOutput.payload)
: parsedOutput.payload;
this.broadcast('heteroAgentRawLine', {
line,
const events = await produce();
// Adapter-extracted CC/Codex session id powers `--resume` on the
// next prompt; surface it through the existing `getSessionInfo`
// IPC by mirroring the freshest value onto the session record.
if (pipeline.sessionId && pipeline.sessionId !== session.agentSessionId) {
session.agentSessionId = pipeline.sessionId;
}
for (const event of events) {
this.broadcast('heteroAgentEvent', {
event,
sessionId: session.sessionId,
});
}
})
.catch((error) => {
logger.error('Failed to broadcast parsed agent output:', error);
logger.error('Failed to broadcast agent stream batch:', error);
});
};
// Stream stdout events as raw provider payloads to Renderer.
// Stream stdout events through the producer pipeline.
const stdout = proc.stdout as Readable;
stdout.on('data', (chunk: Buffer) => {
void this.appendCliTraceFile(traceSession, 'stdout.jsonl', chunk);
broadcastParsedOutputs(streamProcessor.push(chunk));
broadcastPipelineBatch(() => pipeline.push(chunk));
});
stdout.on('end', () => {
broadcastParsedOutputs(streamProcessor.flush());
broadcastPipelineBatch(() => pipeline.flush());
});
// Capture stderr
@@ -914,44 +822,59 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
});
proc.on('exit', (code, signal) => {
void stdoutBroadcastQueue.finally(async () => {
void this.writeCliTraceJson(traceSession, 'exit.json', {
code,
finishedAt: new Date().toISOString(),
signal,
});
await this.flushCliTrace(traceSession);
logger.info('Agent process exited:', { code, sessionId: session.sessionId, signal });
session.process = undefined;
// If *we* killed it (cancel / stop / before-quit), treat the non-zero
// exit as a clean shutdown — surfacing it as an error would make a
// user-initiated cancel look like an agent failure, and an Electron
// shutdown affecting OTHER running CC sessions would pollute their
// topics with a misleading "Agent exited with code 143" message.
if (session.cancelledByUs) {
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
resolve();
return;
}
if (code === 0) {
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
resolve();
} else {
const stderrOutput = stderrChunks.join('').trim();
const errorMsg = this.getExitErrorMessage(code, session, stderrOutput);
const sessionError = this.getSessionErrorPayload(errorMsg, session);
this.broadcast('heteroAgentSessionError', {
error: sessionError,
sessionId: session.sessionId,
});
reject(
new Error(typeof sessionError === 'string' ? sessionError : sessionError.message),
);
}
// Node may emit `'exit'` BEFORE stdio finishes draining (documented:
// child_process docs note "stdio streams might still be open" at exit
// time). Wait for stdout to fully end/close so the `stdout.on('end')`
// handler has scheduled `pipeline.flush()` onto `stdoutBroadcastQueue`,
// THEN wait for the queue itself to settle. Without this two-step
// gate, trailing flushed events (final synthesized tool_end /
// tool_result) would race against — and lose to — the
// `heteroAgentSessionComplete` broadcast, leaving renderer-side
// persistence to finalize on incomplete state.
const stdoutDrained = streamFinished(stdout, { writable: false }).catch(() => {
/* end / close / error are all "done"; we still want to settle. */
});
void stdoutDrained
.then(() => stdoutBroadcastQueue)
.finally(async () => {
void this.writeCliTraceJson(traceSession, 'exit.json', {
code,
finishedAt: new Date().toISOString(),
signal,
});
await this.flushCliTrace(traceSession);
logger.info('Agent process exited:', { code, sessionId: session.sessionId, signal });
session.process = undefined;
// If *we* killed it (cancel / stop / before-quit), treat the non-zero
// exit as a clean shutdown — surfacing it as an error would make a
// user-initiated cancel look like an agent failure, and an Electron
// shutdown affecting OTHER running CC sessions would pollute their
// topics with a misleading "Agent exited with code 143" message.
if (session.cancelledByUs) {
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
resolve();
return;
}
if (code === 0) {
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
resolve();
} else {
const stderrOutput = stderrChunks.join('').trim();
const errorMsg = this.getExitErrorMessage(code, session, stderrOutput);
const sessionError = this.getSessionErrorPayload(errorMsg, session);
this.broadcast('heteroAgentSessionError', {
error: sessionError,
sessionId: session.sessionId,
});
reject(
new Error(typeof sessionError === 'string' ? sessionError : sessionError.message),
);
}
});
});
});
}
@@ -1,5 +1,5 @@
import { constants } from 'node:fs';
import { access, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises';
import { access, mkdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import {
@@ -24,6 +24,9 @@ import {
type PickFileResult,
type PrepareSkillDirectoryParams,
type PrepareSkillDirectoryResult,
type ProjectFileIndexEntry,
type ProjectFileIndexParams,
type ProjectFileIndexResult,
type RenameLocalFileResult,
type ResolveSkillResourcePathParams,
type ResolveSkillResourcePathResult,
@@ -35,6 +38,7 @@ import {
} from '@lobechat/electron-client-ipc';
import {
editLocalFile,
expandTilde,
listLocalFiles,
moveLocalFiles,
readLocalFile,
@@ -42,6 +46,7 @@ import {
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';
@@ -81,6 +86,50 @@ const resolveNearestExistingRealPath = async (targetPath: string): Promise<strin
}
};
const toPosixRelativePath = (filePath: string) => filePath.split(path.sep).join('/');
const createProjectFileEntry = (
root: string,
absolutePath: string,
isDirectory: boolean,
): ProjectFileIndexEntry => {
const relativePath = toPosixRelativePath(path.relative(root, absolutePath));
return {
isDirectory,
name: path.basename(absolutePath),
path: absolutePath,
relativePath: isDirectory ? `${relativePath}/` : relativePath,
};
};
const collectProjectDirectories = (files: string[], root: string): ProjectFileIndexEntry[] => {
const directories = new Set<string>();
for (const filePath of files) {
let current = path.dirname(filePath);
while (current && current !== root && current.startsWith(`${root}${path.sep}`)) {
if (directories.has(current)) break;
directories.add(current);
current = path.dirname(current);
}
}
return [...directories].map((directory) => createProjectFileEntry(root, directory, true));
};
const createDetectedProjectFileEntry = async (
root: string,
absolutePath: string,
): Promise<ProjectFileIndexEntry> => {
try {
const stats = await stat(absolutePath);
return createProjectFileEntry(root, absolutePath, stats.isDirectory());
} catch {
return createProjectFileEntry(root, absolutePath, false);
}
};
const resolveSafePathRealPrefixes = async (): Promise<string[]> => {
const prefixes = new Set<string>(SAFE_PATH_PREFIXES);
@@ -413,14 +462,127 @@ export default class LocalFileCtr extends ControllerModule {
// ==================== Search & Find ====================
@IpcMethod()
async getProjectFileIndex(params: ProjectFileIndexParams = {}): Promise<ProjectFileIndexResult> {
const requestedScope = params.scope || process.cwd();
const startedAt = Date.now();
try {
const rootResult = await execa(
'git',
['-C', requestedScope, 'rev-parse', '--show-toplevel'],
{
reject: false,
timeout: 5000,
},
);
const root = rootResult.exitCode === 0 ? rootResult.stdout.trim() : requestedScope;
if (rootResult.exitCode === 0) {
const [trackedResult, untrackedResult] = await Promise.all([
execa(
'git',
['-C', root, '-c', 'core.quotepath=false', 'ls-files', '--recurse-submodules'],
{
reject: false,
timeout: 10_000,
},
),
execa(
'git',
[
'-C',
root,
'-c',
'core.quotepath=false',
'ls-files',
'--others',
'--exclude-standard',
],
{ reject: false, timeout: 10_000 },
),
]);
if (trackedResult.exitCode !== 0) {
throw new Error(trackedResult.stderr || 'git ls-files failed');
}
const files = [
...trackedResult.stdout.split('\n'),
...(untrackedResult.exitCode === 0 ? untrackedResult.stdout.split('\n') : []),
]
.map((item) => item.trim())
.filter(Boolean)
.map((relativePath) => path.resolve(root, relativePath));
const seen = new Set<string>();
const fileEntries = files
.filter((filePath) => {
if (seen.has(filePath)) return false;
seen.add(filePath);
return true;
})
.map((filePath) => createProjectFileEntry(root, filePath, false));
const entries = [...collectProjectDirectories(files, root), ...fileEntries];
logger.debug('Project file index built from git', {
duration: Date.now() - startedAt,
entries: entries.length,
files: fileEntries.length,
requestedScope,
root,
});
return {
entries,
indexedAt: new Date().toISOString(),
root,
source: 'git',
totalCount: entries.length,
};
}
} catch (error) {
logger.debug('Git project file index failed, falling back to glob', {
error,
requestedScope,
});
}
const fallback = await this.searchService.glob({ pattern: '**/*', scope: requestedScope });
const files = fallback.files.map((filePath) => path.resolve(filePath));
const entries = await Promise.all(
files.map((filePath) => createDetectedProjectFileEntry(requestedScope, filePath)),
);
logger.debug('Project file index built from glob', {
duration: Date.now() - startedAt,
entries: entries.length,
engine: fallback.engine,
requestedScope,
});
return {
entries,
indexedAt: new Date().toISOString(),
root: requestedScope,
source: 'glob',
totalCount: entries.length,
};
}
/**
* Handle IPC event for local file search
*/
@IpcMethod()
async handleLocalFilesSearch(params: LocalSearchFilesParams): Promise<FileResult[]> {
const effectiveDirectory = expandTilde(params.directory ?? params.scope);
logger.debug('Received file search request:', {
directory: params.directory,
effectiveDirectory,
limit: params.limit,
keywords: params.keywords,
scope: params.scope,
});
// Build search options from params, mapping directory to onlyIn
@@ -436,7 +598,7 @@ export default class LocalFileCtr extends ControllerModule {
liveUpdate: params.liveUpdate,
modifiedAfter: params.modifiedAfter ? new Date(params.modifiedAfter) : undefined,
modifiedBefore: params.modifiedBefore ? new Date(params.modifiedBefore) : undefined,
onlyIn: params.directory, // Map directory param to onlyIn option
onlyIn: effectiveDirectory,
sortBy: params.sortBy,
sortDirection: params.sortDirection,
};
@@ -446,6 +608,14 @@ export default class LocalFileCtr extends ControllerModule {
logger.debug('File search completed', {
count: results.length,
directory: params.directory,
effectiveDirectory,
results: results.slice(0, 5).map((result) => ({
engine: result.engine,
isDirectory: result.isDirectory,
name: result.name,
path: result.path,
})),
scope: params.scope,
});
return results;
} catch (error) {
@@ -19,6 +19,26 @@ export default class TrayMenuCtr extends ControllerModule {
mainWindow.toggleVisible();
}
/**
* Get whether the application tray is visible.
*/
@IpcMethod()
getAppTrayVisible(): boolean {
return this.app.storeManager.get('appTrayVisible', true);
}
/**
* Persist and apply application tray visibility.
*/
@IpcMethod()
setAppTrayVisible(visible: boolean) {
logger.debug(`Set app tray visibility: ${visible}`);
this.app.storeManager.set('appTrayVisible', visible);
this.app.trayManager.setAppTrayVisible(visible);
return { success: true };
}
/**
* Show tray balloon notification
* @param options Balloon options
@@ -433,18 +433,23 @@ describe('GatewayConnectionCtr', () => {
}
it.each([
['readLocalFile', 'readFile', mockLocalFileCtr],
['listLocalFiles', 'listLocalFiles', mockLocalFileCtr],
['moveLocalFiles', 'handleMoveFiles', mockLocalFileCtr],
['renameLocalFile', 'handleRenameFile', mockLocalFileCtr],
['searchLocalFiles', 'handleLocalFilesSearch', mockLocalFileCtr],
['writeLocalFile', 'handleWriteFile', mockLocalFileCtr],
['editLocalFile', 'handleEditFile', mockLocalFileCtr],
['globLocalFiles', 'handleGlobFiles', mockLocalFileCtr],
['readFile', 'readFile', mockLocalFileCtr],
['listFiles', 'listLocalFiles', mockLocalFileCtr],
['moveFiles', 'handleMoveFiles', mockLocalFileCtr],
['searchFiles', 'handleLocalFilesSearch', mockLocalFileCtr],
['writeFile', 'handleWriteFile', mockLocalFileCtr],
['editFile', 'handleEditFile', mockLocalFileCtr],
['globFiles', 'handleGlobFiles', mockLocalFileCtr],
['grepContent', 'handleGrepContent', mockLocalFileCtr],
['runCommand', 'handleRunCommand', mockShellCommandCtr],
['getCommandOutput', 'handleGetCommandOutput', mockShellCommandCtr],
['killCommand', 'handleKillCommand', mockShellCommandCtr],
// Legacy aliases — older Gateway versions may still send the long form.
// `renameLocalFile` is kept even though the new surface drops rename.
['readLocalFile', 'readFile', mockLocalFileCtr],
['listLocalFiles', 'listLocalFiles', mockLocalFileCtr],
['writeLocalFile', 'handleWriteFile', mockLocalFileCtr],
['renameLocalFile', 'handleRenameFile', mockLocalFileCtr],
] as const)('should route %s to %s', async (apiName, methodName, controller) => {
const client = await connectAndOpen();
const args = { test: 'arg' };
@@ -470,7 +475,7 @@ describe('GatewayConnectionCtr', () => {
});
const client = await connectAndOpen();
client.simulateToolCallRequest('readLocalFile', { path: '/a.txt' }, 'req-42');
client.simulateToolCallRequest('readFile', { path: '/a.txt' }, 'req-42');
await vi.advanceTimersByTimeAsync(0);
expect(client.sendToolCallResponse).toHaveBeenCalledWith({
@@ -497,7 +502,7 @@ describe('GatewayConnectionCtr', () => {
vi.mocked(mockLocalFileCtr.readFile).mockRejectedValueOnce(new Error('File not found'));
const client = await connectAndOpen();
client.simulateToolCallRequest('readLocalFile', { path: '/missing' }, 'req-err');
client.simulateToolCallRequest('readFile', { path: '/missing' }, 'req-err');
await vi.advanceTimersByTimeAsync(0);
expect(client.sendToolCallResponse).toHaveBeenCalledWith({
@@ -0,0 +1,81 @@
import { describe, expect, it, vi } from 'vitest';
import { dequoteGitPath, quoteGitPath } from '../GitCtr';
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
}));
describe('quoteGitPath', () => {
it('leaves plain ASCII paths unquoted (including spaces)', () => {
expect(quoteGitPath('a/', 'src/foo.ts')).toBe('a/src/foo.ts');
expect(quoteGitPath('b/', 'src/foo bar.ts')).toBe('b/src/foo bar.ts');
expect(quoteGitPath('a/', 'with-dash_and.underscore')).toBe('a/with-dash_and.underscore');
});
it('C-style escapes TAB / LF / CR / quote / backslash', () => {
expect(quoteGitPath('b/', 'with\ttab.txt')).toBe('"b/with\\ttab.txt"');
expect(quoteGitPath('b/', 'with\nlf.txt')).toBe('"b/with\\nlf.txt"');
expect(quoteGitPath('b/', 'with\rcr.txt')).toBe('"b/with\\rcr.txt"');
expect(quoteGitPath('b/', 'with"quote.txt')).toBe('"b/with\\"quote.txt"');
expect(quoteGitPath('b/', 'with\\backslash.txt')).toBe('"b/with\\\\backslash.txt"');
});
it('octal-escapes other control bytes (NUL, 0x1F, DEL)', () => {
expect(quoteGitPath('a/', 'nul\x00here')).toBe('"a/nul\\000here"');
expect(quoteGitPath('a/', 'unit\x1Fsep')).toBe('"a/unit\\037sep"');
expect(quoteGitPath('a/', 'del\x7Fchar')).toBe('"a/del\\177char"');
});
it('puts the prefix inside the quotes', () => {
// Real git output for `git diff` of a tab-containing file:
// diff --git "a/with\there" "b/with\there"
expect(quoteGitPath('a/', 'with\there')).toBe('"a/with\\there"');
expect(quoteGitPath('b/', 'with\there')).toBe('"b/with\\there"');
});
it('round-trips through dequoteGitPath for problem characters', () => {
const cases = [
'with\ttab.txt',
'with\nlf.txt',
'with\rcr.txt',
'with"quote.txt',
'with\\backslash.txt',
'nul\x00inside',
'mix\t"of\\everything\n',
];
for (const original of cases) {
const quoted = quoteGitPath('b/', original);
// Strip the surrounding quotes + b/ prefix, then de-escape.
expect(quoted.startsWith('"b/')).toBe(true);
expect(quoted.endsWith('"')).toBe(true);
const stripped = quoted.slice(1, -1).slice('b/'.length);
expect(dequoteGitPath(stripped)).toBe(original);
}
});
});
describe('dequoteGitPath', () => {
it('decodes named C-style escapes', () => {
expect(dequoteGitPath('with\\ttab')).toBe('with\ttab');
expect(dequoteGitPath('with\\nlf')).toBe('with\nlf');
expect(dequoteGitPath('with\\rcr')).toBe('with\rcr');
expect(dequoteGitPath('with\\"quote')).toBe('with"quote');
expect(dequoteGitPath('with\\\\bs')).toBe('with\\bs');
});
it('decodes 3-digit octal escapes', () => {
expect(dequoteGitPath('nul\\000here')).toBe('nul\x00here');
expect(dequoteGitPath('unit\\037sep')).toBe('unit\x1Fsep');
expect(dequoteGitPath('del\\177char')).toBe('del\x7Fchar');
});
it('leaves unescaped chars alone', () => {
expect(dequoteGitPath('plain ascii here')).toBe('plain ascii here');
});
});
@@ -11,8 +11,12 @@ import HeterogeneousAgentCtr from '../HeterogeneousAgentCtr';
const FAKE_DESKTOP_PATH = '/Users/fake/Desktop';
const { mockGetAllWindows } = vi.hoisted(() => ({
mockGetAllWindows: vi.fn<() => any[]>(() => []),
}));
vi.mock('electron', () => ({
BrowserWindow: { getAllWindows: () => [] },
BrowserWindow: { getAllWindows: () => mockGetAllWindows() },
app: {
getPath: vi.fn((name: string) => (name === 'desktop' ? FAKE_DESKTOP_PATH : `/fake/${name}`)),
isPackaged: false,
@@ -114,13 +118,24 @@ describe('HeterogeneousAgentCtr', () => {
await rm(appStoragePath, { force: true, recursive: true });
});
describe('resolveImage', () => {
describe('image cache (delegates to shared `normalizeImage`)', () => {
// Image fetch + cache moved to `@lobechat/heterogeneous-agents/spawn`'s
// `normalizeImage`. The desktop controller passes its own cacheDir so the
// path-traversal invariant — id segments like `../../foo` MUST be hashed,
// never used as path segments — is enforced by the shared helper. Verify
// that invariant against the same cacheDir the controller would use.
const fixtureCacheDir = (storage: string) => path.join(storage, 'heteroAgent/files');
const importNormalize = async () => {
const { mkdir } = await import('node:fs/promises');
const mod = await import('@lobechat/heterogeneous-agents/spawn');
return { mkdir, normalizeImage: mod.normalizeImage };
};
it('stores traversal-looking ids inside the cache root via a stable hash key', async () => {
const ctr = new HeterogeneousAgentCtr({
appStoragePath,
storeManager: { get: vi.fn() },
} as any);
const cacheDir = path.join(appStoragePath, 'heteroAgent/files');
const { mkdir, normalizeImage } = await importNormalize();
const cacheDir = fixtureCacheDir(appStoragePath);
await mkdir(cacheDir, { recursive: true });
const escapedTargetName = `${path.basename(appStoragePath)}-outside-storage`;
const escapePath = path.join(cacheDir, `../../../${escapedTargetName}`);
@@ -130,10 +145,14 @@ describe('HeterogeneousAgentCtr', () => {
// best-effort cleanup
}
await (ctr as any).resolveImage({
id: `../../../${escapedTargetName}`,
url: 'data:text/plain;base64,T1VUU0lERQ==',
});
await normalizeImage(
{
id: `../../../${escapedTargetName}`,
type: 'url',
url: 'data:text/plain;base64,T1VUU0lERQ==',
},
{ cacheDir, fetcher: (async () => new Response('OUTSIDE', { status: 200 })) as any },
);
const cacheEntries = await readdir(cacheDir);
@@ -149,11 +168,10 @@ describe('HeterogeneousAgentCtr', () => {
});
it('does not trust pre-seeded out-of-root traversal cache files as cache hits', async () => {
const ctr = new HeterogeneousAgentCtr({
appStoragePath,
storeManager: { get: vi.fn() },
} as any);
const cacheDir = path.join(appStoragePath, 'heteroAgent/files');
const { mkdir, normalizeImage } = await importNormalize();
const cacheDir = fixtureCacheDir(appStoragePath);
await mkdir(cacheDir, { recursive: true });
const traversalId = '../../preexisting-secret';
const outOfRootDataPath = path.join(cacheDir, traversalId);
const outOfRootMetaPath = path.join(cacheDir, `${traversalId}.meta`);
@@ -164,13 +182,20 @@ describe('HeterogeneousAgentCtr', () => {
JSON.stringify({ id: traversalId, mimeType: 'text/plain' }),
);
const result = await (ctr as any).resolveImage({
id: traversalId,
url: 'data:text/plain;base64,SUdOT1JFRA==',
});
const result = await normalizeImage(
{ id: traversalId, type: 'url', url: 'data:text/plain;base64,SUdOT1JFRA==' },
{
cacheDir,
fetcher: (async () =>
new Response('IGNORED', {
headers: { 'content-type': 'text/plain' },
status: 200,
})) as any,
},
);
expect(Buffer.from(result.buffer).toString('utf8')).toBe('IGNORED');
expect(result.mimeType).toBe('text/plain');
expect(result.mediaType).toBe('text/plain');
await expect(readFile(outOfRootDataPath, 'utf8')).resolves.toBe('SECRET');
});
});
@@ -185,6 +210,7 @@ describe('HeterogeneousAgentCtr', () => {
prompt: string,
sessionOverrides: Record<string, any> = {},
stdoutLines: string[] = [],
sendPromptOverrides: Partial<{ imageList: Array<{ id: string; url: string }> }> = {},
) => {
const { proc, writes } = createFakeProc({ stdoutLines });
nextFakeProc = proc;
@@ -198,7 +224,7 @@ describe('HeterogeneousAgentCtr', () => {
command: 'claude',
...sessionOverrides,
});
await ctr.sendPrompt({ prompt, sessionId });
await ctr.sendPrompt({ operationId: 'op-test', prompt, sessionId, ...sendPromptOverrides });
const { args: cliArgs, command, options } = spawnCalls[0];
return { cliArgs, command, ctr, options, sessionId, writes };
@@ -261,6 +287,23 @@ describe('HeterogeneousAgentCtr', () => {
expect(options.cwd).toBe(explicitCwd);
});
it('omits the empty text block when only images are attached', async () => {
const { writes } = await runSendPrompt('', {}, [], {
imageList: [{ id: 'image-1', url: 'data:image/png;base64,UE5HX1RFU1Q=' }],
});
expect(writes).toHaveLength(1);
const msg = JSON.parse(writes[0].trimEnd());
// Anthropic rejects `{ text: '', type: 'text' }` with
// "messages: text content blocks must be non-empty".
expect(msg.message.content).toEqual([
{
source: { data: 'UE5HX1RFU1Q=', media_type: 'image/png', type: 'base64' },
type: 'image',
},
]);
});
it('captures the Claude Code session id from stream-json init events', async () => {
const { ctr, sessionId } = await runSendPrompt('hello', {}, [
`${JSON.stringify({ session_id: 'sess_cc_123', subtype: 'init', type: 'system' })}\n`,
@@ -296,7 +339,7 @@ describe('HeterogeneousAgentCtr', () => {
command: 'codex',
...sessionOverrides,
});
await ctr.sendPrompt({ prompt, sessionId, ...sendPromptOverrides });
await ctr.sendPrompt({ operationId: 'op-test', prompt, sessionId, ...sendPromptOverrides });
const { args: cliArgs, command, options } = spawnCalls[0];
return { cliArgs, command, ctr, options, sessionId, writes };
@@ -314,9 +357,9 @@ describe('HeterogeneousAgentCtr', () => {
command: 'codex',
});
await expect(ctr.sendPrompt({ prompt: 'hello', sessionId })).rejects.toThrow(
'Codex CLI was not found',
);
await expect(
ctr.sendPrompt({ operationId: 'op-test', prompt: 'hello', sessionId }),
).rejects.toThrow('Codex CLI was not found');
expect(detect).toHaveBeenCalledWith('codex', true);
expect(spawnCalls).toHaveLength(0);
@@ -334,9 +377,9 @@ describe('HeterogeneousAgentCtr', () => {
command: 'claude',
});
await expect(ctr.sendPrompt({ prompt: 'hello', sessionId })).rejects.toThrow(
'Claude Code CLI was not found',
);
await expect(
ctr.sendPrompt({ operationId: 'op-test', prompt: 'hello', sessionId }),
).rejects.toThrow('Claude Code CLI was not found');
expect(detect).toHaveBeenCalledWith('claude', true);
expect(spawnCalls).toHaveLength(0);
@@ -372,9 +415,9 @@ describe('HeterogeneousAgentCtr', () => {
command: 'claude-alt',
});
await expect(ctr.sendPrompt({ prompt: 'hello', sessionId })).rejects.toThrow(
'Claude Code CLI was not found',
);
await expect(
ctr.sendPrompt({ operationId: 'op-test', prompt: 'hello', sessionId }),
).rejects.toThrow('Claude Code CLI was not found');
expect(detect).not.toHaveBeenCalled();
expect(spawnCalls).toHaveLength(0);
@@ -475,6 +518,7 @@ describe('HeterogeneousAgentCtr', () => {
await expect(
ctr.sendPrompt({
imageList,
operationId: 'op-test',
prompt: 'inspect the screenshots',
sessionId,
}),
@@ -508,9 +552,9 @@ describe('HeterogeneousAgentCtr', () => {
command: 'codex',
});
await expect(ctr.sendPrompt({ prompt: 'hello', sessionId })).rejects.toThrow(
'Agent exited with code 1',
);
await expect(
ctr.sendPrompt({ operationId: 'op-test', prompt: 'hello', sessionId }),
).rejects.toThrow('Agent exited with code 1');
});
it('uses codex exec resume syntax when continuing an existing thread', async () => {
@@ -654,4 +698,108 @@ describe('HeterogeneousAgentCtr', () => {
});
});
});
/**
* Node may emit `proc.on('exit')` BEFORE stdout fully drains (documented in
* child_process docs as "stdio streams might still be open"). The phase 0
* refactor moved adapter ownership to main, so renderer no longer flushes
* its own adapter on session-complete — meaning trailing events from
* `pipeline.flush()` (e.g. Codex's synthesized `tool_end` for unfinished
* tool calls) would race against — and lose to — the
* `heteroAgentSessionComplete` broadcast without an explicit gate.
*
* The fix in `proc.on('exit')` is to await stdout `'end'/'close'` (so the
* `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)', () => {
let broadcasts: Array<{ channel: string; data: any }>;
beforeEach(() => {
spawnCalls.length = 0;
execFileMock.mockReset();
broadcasts = [];
mockGetAllWindows.mockImplementation(() => [
{
isDestroyed: () => false,
webContents: {
send: (channel: string, data: any) => broadcasts.push({ channel, data }),
},
},
]);
});
afterEach(() => {
mockGetAllWindows.mockReset();
mockGetAllWindows.mockReturnValue([]);
});
it('delivers pipeline.flush() events BEFORE heteroAgentSessionComplete even when proc exit precedes stdout end', async () => {
// Codex `item.started` for a tool — adapter buffers it as a pending
// tool call. On flush, adapter synthesizes a trailing `tool_end`. This
// is exactly the kind of event the race would lose against complete.
const itemStarted = `${JSON.stringify({
item: {
aggregated_output: '',
command: 'echo hi',
id: 'cmd-1',
status: 'in_progress',
type: 'command_execution',
},
type: 'item.started',
})}\n`;
const threadStarted = `${JSON.stringify({ thread_id: 't1', type: 'thread.started' })}\n`;
const proc = new EventEmitter() as any;
const stdout = new PassThrough();
const stderr = new PassThrough();
proc.stdout = stdout;
proc.stderr = stderr;
proc.stdin = {
end: vi.fn(),
write: vi.fn((_chunk: any, cb?: () => void) => {
cb?.();
return true;
}),
};
proc.kill = vi.fn();
proc.killed = false;
proc.__start = () => {
setImmediate(() => {
stdout.write(threadStarted);
stdout.write(itemStarted);
stderr.end();
// ⚠️ Reproduce the documented Node race: emit exit BEFORE stdout
// ends. Without the streamFinished gate in the controller, the
// broadcast queue settles immediately (no flush queued yet) and
// complete fires before the trailing tool_end ever broadcasts.
proc.emit('exit', 0);
setImmediate(() => stdout.end());
});
};
nextFakeProc = proc;
const ctr = new HeterogeneousAgentCtr({
appStoragePath,
storeManager: { get: vi.fn() },
} as any);
const { sessionId } = await ctr.startSession({ agentType: 'codex', command: 'codex' });
await ctr.sendPrompt({ operationId: 'op-test', prompt: 'hello', sessionId });
const events = broadcasts.filter((b) => b.channel === 'heteroAgentEvent');
const completeIdx = broadcasts.findIndex((b) => b.channel === 'heteroAgentSessionComplete');
const lastEventIdx = broadcasts.findLastIndex((b) => b.channel === 'heteroAgentEvent');
expect(completeIdx).toBeGreaterThan(-1);
expect(events.length).toBeGreaterThan(0);
// Every stream event must land before complete — no trailing events
// sneak in after the renderer has been told the session is done.
expect(lastEventIdx).toBeLessThan(completeIdx);
// Specifically: the synthesized tool_end for the pending command
// execution (emitted only by adapter.flush()) is in the broadcast.
const toolEnds = events.filter((b) => (b.data as any)?.event?.type === 'tool_end');
expect(toolEnds.length).toBeGreaterThan(0);
});
});
});
@@ -5,7 +5,8 @@ import { type App } from '@/core/App';
import LocalFileCtr from '../LocalFileCtr';
const { ipcMainHandleMock, fetchMock } = vi.hoisted(() => ({
const { execaMock, ipcMainHandleMock, fetchMock } = vi.hoisted(() => ({
execaMock: vi.fn(),
ipcMainHandleMock: vi.fn(),
fetchMock: vi.fn(),
}));
@@ -14,6 +15,10 @@ vi.mock('@/utils/net-fetch', () => ({
netFetch: fetchMock,
}));
vi.mock('execa', () => ({
execa: execaMock,
}));
// Mock logger
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
@@ -535,6 +540,18 @@ describe('LocalFileCtr', () => {
});
});
it('should use scope as the default search directory', async () => {
mockSearchService.search.mockResolvedValue([]);
await localFileCtr.handleLocalFilesSearch({ keywords: 'src', scope: '/workspace/project' });
expect(mockSearchService.search).toHaveBeenCalledWith('src', {
keywords: 'src',
limit: 30,
onlyIn: '/workspace/project',
});
});
it('should return empty array on search error', async () => {
mockSearchService.search.mockRejectedValue(new Error('Search failed'));
@@ -544,6 +561,94 @@ describe('LocalFileCtr', () => {
});
});
describe('getProjectFileIndex', () => {
it('should build a project file index from git files', async () => {
execaMock
.mockResolvedValueOnce({ exitCode: 0, stdout: '/workspace/project' })
.mockResolvedValueOnce({
exitCode: 0,
stdout: 'src/index.ts\nsrc/components/Button.tsx',
})
.mockResolvedValueOnce({ exitCode: 0, stdout: 'tmp/local.ts' });
const result = await localFileCtr.getProjectFileIndex({ scope: '/workspace/project' });
expect(result.source).toBe('git');
expect(result.root).toBe('/workspace/project');
expect(result.entries).toEqual(
expect.arrayContaining([
expect.objectContaining({
isDirectory: true,
path: '/workspace/project/src',
relativePath: 'src/',
}),
expect.objectContaining({
isDirectory: false,
path: '/workspace/project/src/index.ts',
relativePath: 'src/index.ts',
}),
expect.objectContaining({
isDirectory: false,
path: '/workspace/project/tmp/local.ts',
relativePath: 'tmp/local.ts',
}),
]),
);
expect(result.totalCount).toBe(result.entries.length);
});
it('should fall back to glob when git indexing fails', async () => {
execaMock.mockResolvedValueOnce({ exitCode: 1, stdout: '' });
mockSearchService.glob.mockResolvedValue({
engine: 'fast-glob',
files: ['/workspace/project/src', '/workspace/project/src/index.ts'],
success: true,
total_files: 2,
});
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath: string) => ({
isDirectory: () => filePath === '/workspace/project/src',
}));
const result = await localFileCtr.getProjectFileIndex({ scope: '/workspace/project' });
expect(result.source).toBe('glob');
expect(result.entries).toEqual([
expect.objectContaining({
isDirectory: true,
path: '/workspace/project/src',
relativePath: 'src/',
}),
expect.objectContaining({
isDirectory: false,
path: '/workspace/project/src/index.ts',
relativePath: 'src/index.ts',
}),
]);
});
it('should mark glob entries as files when stat fails', async () => {
execaMock.mockResolvedValueOnce({ exitCode: 1, stdout: '' });
mockSearchService.glob.mockResolvedValue({
engine: 'fast-glob',
files: ['/workspace/project/src/index.ts'],
success: true,
total_files: 1,
});
vi.mocked(mockFsPromises.stat).mockRejectedValue(new Error('missing'));
const result = await localFileCtr.getProjectFileIndex({ scope: '/workspace/project' });
expect(result.source).toBe('glob');
expect(result.entries).toEqual([
expect.objectContaining({
isDirectory: false,
path: '/workspace/project/src/index.ts',
relativePath: 'src/index.ts',
}),
]);
});
});
describe('handleGlobFiles', () => {
it('should glob files successfully', async () => {
const mockResult = {
@@ -40,13 +40,21 @@ const mockDisplayBalloon = vi.fn();
const mockUpdateIcon = vi.fn();
const mockUpdateTooltip = vi.fn();
const mockGetMainTray = vi.fn();
const mockSetAppTrayVisible = vi.fn();
const mockStoreGet = vi.fn(() => true);
const mockStoreSet = vi.fn();
const mockApp = {
browserManager: {
getMainWindow: mockGetMainWindow,
},
storeManager: {
get: mockStoreGet,
set: mockStoreSet,
},
trayManager: {
getMainTray: mockGetMainTray,
setAppTrayVisible: mockSetAppTrayVisible,
},
} as unknown as App;
@@ -58,9 +66,31 @@ describe('TrayMenuCtr', () => {
ipcMainHandleMock.mockClear();
// Reset mockedTray for each test
mockGetMainTray.mockReset();
mockStoreGet.mockReturnValue(true);
trayMenuCtr = new TrayMenuCtr(mockApp);
});
describe('getAppTrayVisible', () => {
it('should return stored app tray visibility', () => {
mockStoreGet.mockReturnValue(false);
const result = trayMenuCtr.getAppTrayVisible();
expect(mockStoreGet).toHaveBeenCalledWith('appTrayVisible', true);
expect(result).toBe(false);
});
});
describe('setAppTrayVisible', () => {
it('should persist and apply app tray visibility', () => {
const result = trayMenuCtr.setAppTrayVisible(false);
expect(mockStoreSet).toHaveBeenCalledWith('appTrayVisible', false);
expect(mockSetAppTrayVisible).toHaveBeenCalledWith(false);
expect(result).toEqual({ success: true });
});
});
// Restore platform settings after all tests complete
afterAll(() => {
// Restore the original platform
@@ -39,6 +39,12 @@ export class TrayManager {
initializeTrays() {
logger.debug('Initialize application tray');
if (!this.app.storeManager.get('appTrayVisible', true)) {
logger.debug('Application tray is disabled by user settings');
this.destroyAll();
return;
}
// Initialize main tray
const mainTray = this.initializeMainTray();
@@ -58,6 +64,19 @@ export class TrayManager {
return this.retrieveByIdentifier('main');
}
/**
* Toggle the application tray at runtime.
*/
setAppTrayVisible(visible: boolean) {
logger.debug(`Set application tray visible: ${visible}`);
if (visible) {
this.initializeTrays();
} else {
this.destroyAll();
}
}
/**
* Initialize main tray. On macOS we ship a template image (black + alpha)
* so the system recolors it automatically for light / dark menu bars.
@@ -55,6 +55,9 @@ describe('TrayManager', () => {
menuManager: {
buildTrayMenu: vi.fn(() => ({ _mockMenu: true }) as any),
},
storeManager: {
get: vi.fn(() => true),
},
} as unknown as App;
// Mock Tray constructor
@@ -93,6 +96,15 @@ describe('TrayManager', () => {
expect(mockApp.menuManager.buildTrayMenu).toHaveBeenCalled();
expect(mockTray.setMenu).toHaveBeenCalledWith({ _mockMenu: true });
});
it('should skip tray initialization when app tray is disabled', () => {
vi.mocked(mockApp.storeManager.get).mockReturnValue(false);
trayManager.initializeTrays();
expect(Tray).not.toHaveBeenCalled();
expect(trayManager.trays.size).toBe(0);
});
});
describe('initializeMainTray', () => {
@@ -273,6 +285,24 @@ describe('TrayManager', () => {
});
});
describe('setAppTrayVisible', () => {
it('should initialize trays when visible is true', () => {
trayManager.setAppTrayVisible(true);
expect(Tray).toHaveBeenCalled();
expect(trayManager.trays.has('main')).toBe(true);
});
it('should destroy all trays when visible is false', () => {
trayManager.initializeTrays();
trayManager.setAppTrayVisible(false);
expect(mockTray.destroy).toHaveBeenCalled();
expect(trayManager.trays.size).toBe(0);
});
});
describe('retrieveOrInitialize (private method)', () => {
it('should create new tray when it does not exist', () => {
const options = {
@@ -75,6 +75,7 @@ const menu = {
'tray.open': 'Open {{appName}}',
'tray.quickChat': 'Quick Chat',
'tray.quit': 'Quit',
'tray.settings': 'Settings',
'tray.show': 'Show {{appName}}',
'view.forceReload': 'Force Reload',
'view.reload': 'Reload',
@@ -63,7 +63,9 @@ const createMockApp = () => {
'dev.devPanel': 'Dev Panel',
'tray.openMiniToolbar': 'Quick Composer',
'tray.open': `Open ${params?.appName || 'App'}`,
'tray.quickChat': 'Quick Chat',
'tray.quit': 'Quit',
'tray.settings': 'Settings',
};
return translations[key] || key;
});
@@ -197,6 +199,7 @@ describe('LinuxMenu', () => {
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
expect(template.length).toBeGreaterThan(0);
expect(template.some((item: any) => item.label?.includes('Open'))).toBe(true);
expect(template.some((item: any) => item.label === 'Settings')).toBe(true);
expect(template.some((item: any) => item.label === 'Quit')).toBe(true);
});
});
+1 -1
View File
@@ -466,7 +466,7 @@ export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform {
{ type: 'separator' },
{
click: () => this.app.browserManager.retrieveByIdentifier('settings').show(),
label: t('file.preferences'),
label: t('tray.settings'),
},
{ type: 'separator' },
{ label: t('tray.quit'), role: 'quit' },
@@ -31,6 +31,19 @@ vi.mock('electron', () => ({
},
}));
vi.mock('electron-is', () => ({
macOS: vi.fn(() => true),
}));
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
}),
}));
// Mock isDev
vi.mock('@/const/env', () => ({
isDev: false,
@@ -177,6 +190,7 @@ describe('MacOSMenu', () => {
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
expect(template.length).toBeGreaterThan(0);
expect(template.some((item: any) => item.label?.includes('Show'))).toBe(true);
expect(template.some((item: any) => item.label === 'Settings')).toBe(true);
expect(template.some((item: any) => item.label === 'Quit')).toBe(true);
});
+1 -1
View File
@@ -694,7 +694,7 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
mainWindow.show();
mainWindow.broadcast('navigate', { path: '/settings' });
},
label: t('file.preferences'),
label: t('tray.settings'),
},
{ type: 'separator' },
{ label: t('tray.quit'), role: 'quit' },
@@ -58,7 +58,9 @@ const createMockApp = () => {
'dev.devPanel': 'Dev Panel',
'tray.openMiniToolbar': 'Quick Composer',
'tray.open': `Open ${params?.appName || 'App'}`,
'tray.quickChat': 'Quick Chat',
'tray.quit': 'Quit',
'tray.settings': 'Settings',
};
return translations[key] || key;
});
@@ -179,6 +181,7 @@ describe('WindowsMenu', () => {
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
expect(template.length).toBeGreaterThan(0);
expect(template.some((item: any) => item.label?.includes('Open'))).toBe(true);
expect(template.some((item: any) => item.label === 'Settings')).toBe(true);
expect(template.some((item: any) => item.label === 'Quit')).toBe(true);
});
});
+1 -1
View File
@@ -473,7 +473,7 @@ export class WindowsMenu extends BaseMenuPlatform implements IMenuPlatform {
{ type: 'separator' },
{
click: () => this.app.browserManager.retrieveByIdentifier('settings').show(),
label: t('file.preferences'),
label: t('tray.settings'),
},
{ type: 'separator' },
{ label: t('tray.quit'), role: 'quit' },
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from 'vitest';
import { buildFilenameKeywordExpression } from '../impl/macOS';
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
}),
}));
describe('buildFilenameKeywordExpression', () => {
it('produces a single substring term for one keyword', () => {
expect(buildFilenameKeywordExpression('package.json')).toBe(
'kMDItemFSName == "*package.json*"cd',
);
});
it('splits whitespace-separated keywords into AND-ed substring terms', () => {
// Critical fix: a free-form keyword string from the LLM (e.g. "LobeHub
// Financial Statement") used to require that exact phrase to appear in the
// filename. Real files reorder words and use _/-/. as separators, so the
// literal phrase almost never matched. AND-ing per-token substrings keeps
// each token literal but removes the order constraint.
expect(buildFilenameKeywordExpression('LobeHub Financial Statement')).toBe(
'(kMDItemFSName == "*LobeHub*"cd && kMDItemFSName == "*Financial*"cd && kMDItemFSName == "*Statement*"cd)',
);
});
it('collapses repeated whitespace and trims surrounding spaces', () => {
expect(buildFilenameKeywordExpression(' foo \t\n bar ')).toBe(
'(kMDItemFSName == "*foo*"cd && kMDItemFSName == "*bar*"cd)',
);
});
it('escapes embedded double quotes in each token', () => {
expect(buildFilenameKeywordExpression('foo "bar" baz')).toBe(
'(kMDItemFSName == "*foo*"cd && kMDItemFSName == "*\\"bar\\"*"cd && kMDItemFSName == "*baz*"cd)',
);
});
it('returns an empty string when keywords are blank', () => {
expect(buildFilenameKeywordExpression('')).toBe('');
expect(buildFilenameKeywordExpression(' \t ')).toBe('');
});
});
@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { LinuxSearchServiceImpl } from '../impl/linux';
vi.mock('node:os', () => ({
homedir: vi.fn().mockReturnValue('/Users/test-home'),
platform: vi.fn().mockReturnValue('linux'),
}));
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
}),
}));
const fgMock = vi.fn();
vi.mock('fast-glob', () => ({
default: (...args: unknown[]) => fgMock(...args),
}));
const execaMock = vi.fn();
vi.mock('execa', () => ({
execa: (...args: unknown[]) => execaMock(...args),
}));
vi.mock('node:fs/promises', () => ({
stat: vi.fn().mockResolvedValue({
atime: new Date(),
birthtime: new Date(),
isDirectory: () => false,
mtime: new Date(),
size: 0,
}),
}));
describe('UnixFileSearch glob fallback root', () => {
beforeEach(() => {
fgMock.mockReset();
execaMock.mockReset();
// Force the Unix tool selection to fall through to fast-glob so we
// don't have to mock fd/find availability checks.
execaMock.mockRejectedValue(new Error('command not found'));
fgMock.mockResolvedValue([]);
});
it('runs glob inside the user home directory when no scope is provided', async () => {
// Regression: previously fell back to process.cwd(), which inside a
// packaged Electron app is the bundle path — making `**/*foo*` searches
// effectively look at nothing user-visible.
const impl = new LinuxSearchServiceImpl();
await impl.glob({ pattern: '**/*report*' });
expect(fgMock).toHaveBeenCalledTimes(1);
const [pattern, options] = fgMock.mock.calls[0] as [string, { cwd: string }];
expect(pattern).toBe('**/*report*');
expect(options.cwd).toBe('/Users/test-home');
});
it('honors an explicit scope over the home-directory fallback', async () => {
const impl = new LinuxSearchServiceImpl();
await impl.glob({ pattern: '**/*.ts', scope: '/Users/test-home/Downloads' });
const [, options] = fgMock.mock.calls[0] as [string, { cwd: string }];
expect(options.cwd).toBe('/Users/test-home/Downloads');
});
});
@@ -13,6 +13,29 @@ import { UnixFileSearch } from './unix';
const logger = createLogger('module:FileSearch:macOS');
/**
* Build the kMDItemFSName expression for a free-form keyword string.
*
* Splits on whitespace and ANDs each token as a case/diacritic-insensitive
* substring match, so "Foo Bar" matches both `Bar_Foo.pdf` and `Foo Bar.pdf`
* instead of requiring the literal phrase "Foo Bar" to appear.
*
* Returns an empty string when the keywords contain no usable token.
*/
export const buildFilenameKeywordExpression = (keywords: string): string => {
const tokens = keywords
.trim()
.split(/\s+/)
.filter(Boolean)
.map((token) => token.replaceAll('"', '\\"'));
if (tokens.length === 0) return '';
const term = (token: string) => `kMDItemFSName == "*${token}*"cd`;
if (tokens.length === 1) return term(tokens[0]);
return `(${tokens.map(term).join(' && ')})`;
};
/**
* Fallback tool type for macOS file search
* Priority: mdfind > fd > find > fast-glob
@@ -95,7 +118,16 @@ export class MacOSSearchServiceImpl extends UnixFileSearch {
* Search using Spotlight (mdfind)
*/
private async searchWithSpotlight(options: SearchOptions): Promise<FileResult[]> {
const { cmd, args, commandString } = this.buildSearchCommand(options);
const { cmd, args, commandString, hasQuery } = this.buildSearchCommand(options);
// Spotlight (mdfind) requires a query expression; running it with only flags
// (e.g. -onlyin) makes mdfind print its usage to stdout and we'd treat each
// line as a fake file. Short-circuit to an empty result instead.
if (!hasQuery) {
logger.warn('Skipping mdfind: no keywords/contentContains/fileTypes/date filter provided');
return [];
}
logger.debug(`Executing command: ${commandString}`);
try {
@@ -176,6 +208,7 @@ export class MacOSSearchServiceImpl extends UnixFileSearch {
args: string[];
cmd: string;
commandString: string;
hasQuery: boolean;
} {
const cmd = 'mdfind';
const args: string[] = [];
@@ -204,7 +237,7 @@ export class MacOSSearchServiceImpl extends UnixFileSearch {
if (options.keywords) {
if (!options.keywords.includes('kMDItem')) {
queryExpression = `kMDItemFSName == "*${options.keywords.replaceAll('"', '\\"')}*"cd`;
queryExpression = buildFilenameKeywordExpression(options.keywords);
} else {
queryExpression = options.keywords;
}
@@ -271,13 +304,15 @@ export class MacOSSearchServiceImpl extends UnixFileSearch {
}
}
if (queryExpression) {
const hasQuery = Boolean(queryExpression);
if (hasQuery) {
args.push(queryExpression);
}
const commandString = `${cmd} ${args.map((arg) => (arg.includes(' ') || arg.includes('*') ? `"${arg}"` : arg)).join(' ')}`;
return { args, cmd, commandString };
return { args, cmd, commandString, hasQuery };
}
/**
@@ -288,7 +323,7 @@ export class MacOSSearchServiceImpl extends UnixFileSearch {
options: SearchOptions,
engine?: string,
): Promise<FileResult[]> {
const resultPromises = filePaths.map(async (filePath) => {
const resultPromises = filePaths.map(async (filePath): Promise<FileResult | null> => {
try {
const stats = await stat(filePath);
@@ -313,23 +348,15 @@ export class MacOSSearchServiceImpl extends UnixFileSearch {
return result;
} catch (error) {
logger.warn(`Error processing file stats for ${filePath}: ${(error as Error).message}`);
return {
contentType: 'unknown',
createdTime: new Date(),
engine,
isDirectory: false,
lastAccessTime: new Date(),
modifiedTime: new Date(),
name: path.basename(filePath),
path: filePath,
size: 0,
type: path.extname(filePath).toLowerCase().replace('.', ''),
};
// Drop the row instead of fabricating a 0-byte placeholder. mdfind
// occasionally returns non-path lines (e.g. usage text when the query
// is malformed) which would otherwise render as phantom files.
logger.warn(`Dropping unstattable search hit ${filePath}: ${(error as Error).message}`);
return null;
}
});
let results = await Promise.all(resultPromises);
let results = (await Promise.all(resultPromises)).filter((r): r is FileResult => r !== null);
if (options.sortBy) {
results = this.sortResults(results, options.sortBy, options.sortDirection);
@@ -337,7 +337,7 @@ export abstract class UnixFileSearch extends BaseFileSearch {
* @returns Glob results
*/
protected async globWithFd(params: GlobFilesParams): Promise<GlobFilesResult> {
const searchPath = params.scope || process.cwd();
const searchPath = params.scope || os.homedir() || process.cwd();
const logPrefix = `[glob:fd: ${params.pattern}]`;
logger.debug(`${logPrefix} Starting fd glob`, { searchPath });
@@ -393,7 +393,7 @@ export abstract class UnixFileSearch extends BaseFileSearch {
* @returns Glob results
*/
protected async globWithFind(params: GlobFilesParams): Promise<GlobFilesResult> {
const searchPath = params.scope || process.cwd();
const searchPath = params.scope || os.homedir() || process.cwd();
const logPrefix = `[glob:find: ${params.pattern}]`;
logger.debug(`${logPrefix} Starting find glob`, { searchPath });
@@ -455,7 +455,7 @@ export abstract class UnixFileSearch extends BaseFileSearch {
* @returns Glob results
*/
protected async globWithFastGlob(params: GlobFilesParams): Promise<GlobFilesResult> {
const searchPath = params.scope || process.cwd();
const searchPath = params.scope || os.homedir() || process.cwd();
const logPrefix = `[glob:fast-glob: ${params.pattern}]`;
logger.debug(`${logPrefix} Starting fast-glob`, { searchPath });
@@ -335,7 +335,7 @@ export class WindowsSearchServiceImpl extends BaseFileSearch {
* @returns Glob results
*/
private async globWithFd(params: GlobFilesParams): Promise<GlobFilesResult> {
const searchPath = params.scope || process.cwd();
const searchPath = params.scope || os.homedir() || process.cwd();
const logPrefix = `[glob:fd: ${params.pattern}]`;
logger.debug(`${logPrefix} Starting fd glob`, { searchPath });
@@ -390,7 +390,7 @@ export class WindowsSearchServiceImpl extends BaseFileSearch {
* @returns Glob results
*/
private async globWithFastGlob(params: GlobFilesParams): Promise<GlobFilesResult> {
const searchPath = params.scope || process.cwd();
const searchPath = params.scope || os.homedir() || process.cwd();
const logPrefix = `[glob:fast-glob: ${params.pattern}]`;
logger.debug(`${logPrefix} Starting fast-glob`, { searchPath });
@@ -1,4 +1,3 @@
import { JsonlStreamProcessor } from '../jsonlProcessor';
import type { HeterogeneousAgentBuildPlanParams, HeterogeneousAgentDriver } from '../types';
const CLAUDE_CODE_BASE_ARGS = [
@@ -32,10 +31,4 @@ export const claudeCodeDriver: HeterogeneousAgentDriver = {
stdinPayload,
};
},
createStreamProcessor() {
return new JsonlStreamProcessor({
extractSessionId: (payload) =>
payload?.type === 'system' && payload?.subtype === 'init' ? payload?.session_id : undefined,
});
},
};
@@ -1,4 +1,3 @@
import { JsonlStreamProcessor } from '../jsonlProcessor';
import type { HeterogeneousAgentBuildPlanParams, HeterogeneousAgentDriver } from '../types';
const CODEX_REQUIRED_ARGS = ['--json', '--skip-git-repo-check'] as const;
@@ -41,10 +40,4 @@ export const codexDriver: HeterogeneousAgentDriver = {
stdinPayload: prompt,
};
},
createStreamProcessor() {
return new JsonlStreamProcessor({
extractSessionId: (payload) =>
payload?.type === 'thread.started' ? payload?.thread_id : undefined,
});
},
};
@@ -1,61 +0,0 @@
import type { HeterogeneousAgentParsedOutput, HeterogeneousAgentStreamProcessor } from './types';
export interface JsonlProcessorOptions {
extractSessionId?: (payload: any) => string | undefined;
}
/**
* Parses stdout as JSONL / NDJSON while tolerating non-JSON noise lines.
* Different CLIs still end up sharing this framing logic even when the
* payload schema differs.
*/
export class JsonlStreamProcessor implements HeterogeneousAgentStreamProcessor {
private buffer = '';
constructor(private readonly options: JsonlProcessorOptions = {}) {}
push(chunk: Buffer | string): HeterogeneousAgentParsedOutput[] {
this.buffer += chunk instanceof Buffer ? chunk.toString('utf8') : chunk;
return this.drainCompleteLines();
}
flush(): HeterogeneousAgentParsedOutput[] {
const trailing = this.buffer.trim();
this.buffer = '';
if (!trailing) return [];
try {
return [this.toParsedOutput(JSON.parse(trailing))];
} catch {
return [];
}
}
private drainCompleteLines(): HeterogeneousAgentParsedOutput[] {
const lines = this.buffer.split('\n');
this.buffer = lines.pop() || '';
const parsed: HeterogeneousAgentParsedOutput[] = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
parsed.push(this.toParsedOutput(JSON.parse(trimmed)));
} catch {
// Ignore non-JSON stdout noise.
}
}
return parsed;
}
private toParsedOutput(payload: any): HeterogeneousAgentParsedOutput {
return {
agentSessionId: this.options.extractSessionId?.(payload),
payload,
};
}
}
@@ -24,19 +24,13 @@ export interface HeterogeneousAgentBuildPlanParams {
resumeSessionId?: string;
}
export interface HeterogeneousAgentParsedOutput {
agentSessionId?: string;
payload: any;
}
export interface HeterogeneousAgentStreamProcessor {
flush: () => HeterogeneousAgentParsedOutput[];
push: (chunk: Buffer | string) => HeterogeneousAgentParsedOutput[];
}
/**
* Per-agent CLI flag composition + stdin shape. Stream framing is no longer the
* driver's concern `AgentStreamPipeline` (`@lobechat/heterogeneous-agents/spawn`)
* runs JSONL parsing + adapter conversion uniformly for every agent type.
*/
export interface HeterogeneousAgentDriver {
buildSpawnPlan: (
params: HeterogeneousAgentBuildPlanParams,
) => Promise<HeterogeneousAgentBuildPlan>;
createStreamProcessor: () => HeterogeneousAgentStreamProcessor;
}
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
import os from 'node:os';
import type {
AgentRunRequestMessage,
SystemInfoRequestMessage,
ToolCallRequestMessage,
} from '@lobechat/device-gateway-client';
@@ -21,6 +22,10 @@ interface ToolCallHandler {
(apiName: string, args: any): Promise<unknown>;
}
interface AgentRunHandler {
(request: AgentRunRequestMessage): Promise<{ reason?: string; status: 'accepted' | 'rejected' }>;
}
/**
* GatewayConnectionService
*
@@ -35,6 +40,7 @@ export default class GatewayConnectionService extends ServiceModule {
private tokenProvider: (() => Promise<string | null>) | null = null;
private tokenRefresher: (() => Promise<{ error?: string; success: boolean }>) | null = null;
private toolCallHandler: ToolCallHandler | null = null;
private agentRunHandler: AgentRunHandler | null = null;
// ─── Configuration ───
@@ -59,6 +65,10 @@ export default class GatewayConnectionService extends ServiceModule {
this.toolCallHandler = handler;
}
setAgentRunHandler(handler: AgentRunHandler) {
this.agentRunHandler = handler;
}
// ─── Device ID ───
loadOrCreateDeviceId() {
@@ -178,6 +188,10 @@ export default class GatewayConnectionService extends ServiceModule {
this.handleSystemInfoRequest(client, request);
});
client.on('agent_run_request', (request) => {
this.handleAgentRunRequest(client, request);
});
client.on('auth_expired', () => {
logger.warn('Received auth_expired, will reconnect with refreshed token');
this.handleAuthExpired();
@@ -239,6 +253,30 @@ export default class GatewayConnectionService extends ServiceModule {
});
}
// ─── Agent Run ───
private handleAgentRunRequest = async (
client: GatewayClient,
request: AgentRunRequestMessage,
) => {
logger.info(
`Received agent_run_request: operationId=${request.operationId} type=${request.agentType}`,
);
if (!this.agentRunHandler) {
logger.warn('No agent run handler configured, rejecting request');
client.sendAgentRunAck({
operationId: request.operationId,
reason: 'no handler',
status: 'rejected',
});
return;
}
const result = await this.agentRunHandler(request);
client.sendAgentRunAck({ operationId: request.operationId, ...result });
};
// ─── Tool Call Routing ───
private handleToolCallRequest = async (
+1
View File
@@ -5,6 +5,7 @@ import type {
} from '@lobechat/electron-client-ipc';
export interface ElectronMainStore {
appTrayVisible: boolean;
dataSyncConfig: DataSyncConfig;
encryptedTokens: {
accessToken?: string;
+56 -2
View File
@@ -1,8 +1,23 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { resolveOverlayModelSelectionPayload, shouldShowOverlayModelSelector } from './ChatPanel';
import { resolvePanelPlacement } from './panelPlacement';
describe('resolvePanelPlacement', () => {
vi.mock('./chatPanel.css.ts', () => new Proxy({}, { get: (_, key) => String(key) }));
vi.mock('./cn', () => ({
cn: (...classes: Array<string | false | null | undefined>) => classes.filter(Boolean).join(' '),
}));
vi.mock('./Avatar', () => ({
default: () => null,
}));
vi.mock('@lobehub/icons', () => ({
ModelIcon: () => null,
}));
describe('ChatPanel', () => {
it('keeps the last selection placement while a reselection is in progress', () => {
expect(
resolvePanelPlacement({
@@ -30,4 +45,43 @@ describe('resolvePanelPlacement', () => {
width: 420,
});
});
it('hides the model selector and omits model payload for heterogeneous agents', () => {
const heterogeneousAgent = {
heterogeneousType: 'codex',
id: 'agent-codex',
title: 'Codex Agent',
};
expect(shouldShowOverlayModelSelector(heterogeneousAgent)).toBe(false);
expect(
resolveOverlayModelSelectionPayload({
agent: heterogeneousAgent,
model: { id: 'gpt-4.1', provider: 'openai' },
modelId: 'gpt-4.1',
}),
).toEqual({
modelId: undefined,
provider: undefined,
});
});
it('keeps the model selector and payload for regular agents', () => {
const regularAgent = {
id: 'agent-regular',
title: 'Regular Agent',
};
expect(shouldShowOverlayModelSelector(regularAgent)).toBe(true);
expect(
resolveOverlayModelSelectionPayload({
agent: regularAgent,
model: { id: 'gpt-4.1', provider: 'openai' },
modelId: 'gpt-4.1',
}),
).toEqual({
modelId: 'gpt-4.1',
provider: 'openai',
});
});
});
+70 -33
View File
@@ -57,6 +57,25 @@ export interface ChatPanelProps {
viewportWidth: number;
}
export const shouldShowOverlayModelSelector = (agent?: ScreenCaptureAgentOption) =>
!agent?.heterogeneousType;
export const resolveOverlayModelSelectionPayload = ({
agent,
model,
modelId,
}: {
agent?: ScreenCaptureAgentOption;
model?: ScreenCaptureModelOption;
modelId?: string;
}) => {
if (!shouldShowOverlayModelSelector(agent)) {
return { modelId: undefined, provider: undefined };
}
return { modelId, provider: model?.provider };
};
const formatBytes = (rect: Rect): string =>
`${Math.round(rect.width)} × ${Math.round(rect.height)} · ${OVERLAY_COPY.selectionFormatLabel}`;
@@ -140,6 +159,7 @@ const ChatPanel = memo<ChatPanelProps>(
() => models?.find((item) => item.id === modelId),
[models, modelId],
);
const showModelSelector = shouldShowOverlayModelSelector(currentAgent);
useEffect(() => {
if (!initialAgentId) return;
@@ -276,14 +296,29 @@ const ChatPanel = memo<ChatPanelProps>(
const submit = useCallback(() => {
if (selections.length === 0 || !prompt.trim() || !allUploadsReady) return;
const modelSelection = resolveOverlayModelSelectionPayload({
agent: currentAgent,
model: currentModel,
modelId,
});
onSubmit({
agentId,
captureIds: selections.map((item) => item.captureId),
modelId,
modelId: modelSelection.modelId,
prompt: prompt.trim(),
provider: currentModel?.provider,
provider: modelSelection.provider,
});
}, [selections, prompt, agentId, modelId, currentModel, onSubmit, allUploadsReady]);
}, [
selections,
prompt,
agentId,
currentAgent,
modelId,
currentModel,
onSubmit,
allUploadsReady,
]);
const handleKeyDown = useCallback(
(e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
@@ -464,38 +499,40 @@ const ChatPanel = memo<ChatPanelProps>(
</select>
</label>
<label
aria-label={OVERLAY_COPY.modelSelectLabel}
className={cn(styles.selectChip, !hasModels && styles.selectChipDisabled)}
>
{currentModel ? (
<span className={styles.modelIconBox}>
<ModelIcon model={currentModel.id} size={16} />
</span>
) : (
<span className={styles.modelIconBoxFallback} />
)}
<span className={styles.chipLabel}>
{currentModel?.displayName ??
currentModel?.id ??
OVERLAY_COPY.modelSelectPlaceholder}
</span>
<ChevronDownIcon className={styles.chevron} size={12} strokeWidth={2} />
<select
{showModelSelector && (
<label
aria-label={OVERLAY_COPY.modelSelectLabel}
className={styles.nativeSelect}
disabled={!hasModels}
value={modelId ?? ''}
onChange={handleModelChange}
className={cn(styles.selectChip, !hasModels && styles.selectChipDisabled)}
>
{!hasModels && <option value="">{OVERLAY_COPY.modelSelectPlaceholder}</option>}
{models?.map((item) => (
<option key={item.id} value={item.id}>
{item.displayName ?? item.id}
</option>
))}
</select>
</label>
{currentModel ? (
<span className={styles.modelIconBox}>
<ModelIcon model={currentModel.id} size={16} />
</span>
) : (
<span className={styles.modelIconBoxFallback} />
)}
<span className={styles.chipLabel}>
{currentModel?.displayName ??
currentModel?.id ??
OVERLAY_COPY.modelSelectPlaceholder}
</span>
<ChevronDownIcon className={styles.chevron} size={12} strokeWidth={2} />
<select
aria-label={OVERLAY_COPY.modelSelectLabel}
className={styles.nativeSelect}
disabled={!hasModels}
value={modelId ?? ''}
onChange={handleModelChange}
>
{!hasModels && <option value="">{OVERLAY_COPY.modelSelectPlaceholder}</option>}
{models?.map((item) => (
<option key={item.id} value={item.id}>
{item.displayName ?? item.id}
</option>
))}
</select>
</label>
)}
</div>
<div className={styles.actionBarRight}>
+2 -2
View File
@@ -484,8 +484,8 @@ export const connectorHidden = style({
});
const fadeIn = keyframes({
from: { opacity: 0, transform: 'translate(-50%, 8px)' },
to: { opacity: 1, transform: 'translate(-50%, 0)' },
from: { opacity: 0, transform: 'translateY(8px)' },
to: { opacity: 1, transform: 'translateY(0)' },
});
const spin = keyframes({
@@ -1,6 +1,10 @@
export const BRANDING_LOGO_URL = '';
export const BRANDING_NAME = 'LobeHub';
export const DEFAULT_EMBEDDING_PROVIDER = 'openai';
export const DEFAULT_MINI_MODEL = 'gpt-5.4-mini';
export const DEFAULT_MINI_PROVIDER = 'openai';
export const DEFAULT_PROVIDER = 'openai';
export const DEFAULT_MODEL = 'deepseek-v4-pro';
export const DEFAULT_ONBOARDING_MODEL = 'gemini-3-flash-preview';
export const DEFAULT_ONBOARDING_PROVIDER = 'google';
export const DEFAULT_PROVIDER = 'deepseek';
export const ORG_NAME = 'LobeHub';
+66 -5
View File
@@ -2,7 +2,7 @@ import { DurableObject } from 'cloudflare:workers';
import { Hono } from 'hono';
import { resolveSocketAuth, verifyApiKeyToken, verifyDesktopToken } from './auth';
import type { DeviceAttachment, Env } from './types';
import type { AgentRunRequestMessage, DeviceAttachment, Env } from './types';
const AUTH_TIMEOUT = 10_000; // 10s to authenticate after connect
const HEARTBEAT_TIMEOUT = 90_000; // 90s without heartbeat → close
@@ -31,6 +31,9 @@ export class DeviceGatewayDO extends DurableObject<Env> {
.post('/api/device/system-info', async (c) => {
return this.handleSystemInfo(c.req.raw);
})
.post('/api/device/agent/run', async (c) => {
return this.handleAgentRun(c.req.raw);
})
.all('/api/device/devices', async () => {
const sockets = this.getAuthenticatedSockets();
const devices = sockets.map((ws) => ws.deserializeAttachment() as DeviceAttachment);
@@ -102,12 +105,16 @@ export class DeviceGatewayDO extends DurableObject<Env> {
if (!att.authenticated) return;
// ─── Business messages (authenticated only) ───
if (data.type === 'tool_call_response' || data.type === 'system_info_response') {
const pending = this.pendingRequests.get(data.requestId);
if (
data.type === 'tool_call_response' ||
data.type === 'system_info_response' ||
data.type === 'agent_run_ack'
) {
const pending = this.pendingRequests.get(data.requestId ?? data.operationId);
if (pending) {
clearTimeout(pending.timer);
pending.resolve(data.result);
this.pendingRequests.delete(data.requestId);
pending.resolve(data.type === 'agent_run_ack' ? data : data.result);
this.pendingRequests.delete(data.requestId ?? data.operationId);
}
}
@@ -278,6 +285,60 @@ export class DeviceGatewayDO extends DurableObject<Env> {
}
}
// ─── Agent Run RPC ───
private async handleAgentRun(request: Request): Promise<Response> {
const sockets = this.getAuthenticatedSockets();
if (sockets.length === 0) {
return Response.json({ error: 'DEVICE_OFFLINE', success: false }, { status: 503 });
}
const body = (await request.json()) as {
agentType: 'claude-code' | 'codex';
cwd?: string;
deviceId?: string;
jwt: string;
operationId: string;
prompt: string;
resumeSessionId?: string;
timeout?: number;
topicId: string;
};
const { deviceId, timeout = 10_000, ...runParams } = body;
const targetWs = deviceId
? sockets.find((ws) => {
const att = ws.deserializeAttachment() as DeviceAttachment;
return att.deviceId === deviceId;
})
: sockets[0];
if (!targetWs) {
return Response.json({ error: 'DEVICE_NOT_FOUND', success: false }, { status: 503 });
}
try {
const ack = await new Promise<{ status: string }>((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingRequests.delete(runParams.operationId);
reject(new Error('TIMEOUT'));
}, timeout);
this.pendingRequests.set(runParams.operationId, { resolve, timer });
const msg: AgentRunRequestMessage = { type: 'agent_run_request', ...runParams };
targetWs.send(JSON.stringify(msg));
});
if (ack.status === 'rejected') {
return Response.json({ error: 'DEVICE_REJECTED', success: false }, { status: 422 });
}
return Response.json({ success: true });
} catch (err) {
return Response.json({ error: (err as Error).message, success: false }, { status: 504 });
}
}
// ─── Tool Call RPC ───
private async handleToolCall(request: Request): Promise<Response> {
+30
View File
@@ -92,12 +92,42 @@ export interface SystemInfoRequestMessage {
type: 'system_info_request';
}
/**
* CF Desktop: request the desktop to spawn `lh hetero exec` for a
* heterogeneous agent run. The JWT is operation-scoped (4h TTL) and only
* grants `heteroIngest` / `heteroFinish` for this operationId.
*/
export interface AgentRunRequestMessage {
agentType: 'claude-code' | 'codex';
/** Working directory to pass to `lh hetero exec --cwd`. */
cwd?: string;
/** Operation-scoped JWT signed by the server — inject as LOBEHUB_JWT env. */
jwt: string;
operationId: string;
/** Plain-text prompt to pass via `lh hetero exec --prompt`. */
prompt: string;
/** Native CLI session id for `lh hetero exec --resume`. */
resumeSessionId?: string;
topicId: string;
type: 'agent_run_request';
}
/** Desktop → CF: acknowledgement for an `agent_run_request`. */
export interface AgentRunAckMessage {
operationId: string;
reason?: string;
status: 'accepted' | 'rejected';
type: 'agent_run_ack';
}
export type ClientMessage =
| AgentRunAckMessage
| AuthMessage
| HeartbeatMessage
| SystemInfoResponseMessage
| ToolCallResponseMessage;
export type ServerMessage =
| AgentRunRequestMessage
| AuthExpiredMessage
| AuthFailedMessage
| AuthSuccessMessage
+17
View File
@@ -1,4 +1,21 @@
[
{
"children": {},
"date": "2026-05-01",
"version": "2.1.56"
},
{
"children": {},
"date": "2026-04-29",
"version": "2.1.55"
},
{
"children": {
"fixes": ["clear stale topic when switching agents from a topic route."]
},
"date": "2026-04-27",
"version": "2.1.54"
},
{
"children": {},
"date": "2026-04-20",
+2 -1
View File
@@ -469,5 +469,6 @@
"https://github.com/user-attachments/assets/facdc83c-e789-4649-8060-7f7a10a1b1dd": "/blog/assets05b20e40c03ced0ec8707fed2e8e0f25.webp",
"https://github.com/user-attachments/assets/fcdfb9c5-819a-488f-b28d-0857fe861219": "/blog/assets8477415ecec1f37e38ab38ff1217d0a7.webp",
"https://github.com/user-attachments/assets/fd60ab55-ead2-4930-ad00-fdf77662f5a0": "/blog/assets276a4e8748e9bd300b30dcd9d0e24980.webp",
"https://file.rene.wang/clipboard-1777343750668-9b3dcb0dfff86.png": "/blog/assetsfa267a02f20bc5ba6f1273bcf27b7c9f.webp"
"https://file.rene.wang/clipboard-1777343750668-9b3dcb0dfff86.png": "/blog/assetsfa267a02f20bc5ba6f1273bcf27b7c9f.webp",
"https://file.rene.wang/Changelog-Seedance.png": "/blog/assetsb2bf4ddf0a45ff887a993c18cb7ab983.webp"
}
@@ -0,0 +1,34 @@
---
title: 'Delegate Claude Code and Codex'
description: >-
Delegate Claude Code and Codex from inside LobeHub, with a redesigned home, a Review tab for bulk git diffs, visual understanding, and a wave of new models.
tags:
- Coding agent
- Claude Code
- Home
- Review
- Models
---
# Delegate Claude Code and Codex
## Features
- New: Delegate Claude Code and Codex in LobeHub
- Agent-specific topic grouping: switch the topic list to group by agent, with a friendlier empty state
- Review tab: a new tab that aggregates bulk git diffs across a tree, \~9× faster on large repos
- Local file mention snapshots: drag a file into chat and a snapshot is captured for the model to reason over
- Visual understanding tool: a new built-in tool for image analysis and visual reasoning
- Line bot support: connect a Line channel as an agent endpoint
- New models: `grok-4.3`, DeepSeek Anthropic runtime, plus `gpt-image-2` and Grok 4.20 in the model library
## Improvements and fixes
- DeepSeek now shows pricing in the model card and respects model defaults.
- Document modal shows a skeleton while the title loads and surfaces the document update time in space.
- Agent documents can be exposed as a virtual file system with fs-compatible output.
- Sessions are revoked after a password reset, and tRPC pagination now enforces a max limit.
- Skill OAuth no longer breaks the desktop app by skipping `redirectUri` on Electron.
- CAPTCHA retries during sign-in are handled cleanly instead of failing the flow.
@@ -0,0 +1,31 @@
---
title: 在 LobeHub 中调度 Claude Code 与 Codex
description: 在 LobeHub 中直接调度 Claude Code 与 Codex,全新首页、批量 git diff 的 Review 标签页、视觉理解工具,以及一批新模型。
tags:
- 编程 Agent
- Claude Code
- 首页
- Review
- 模型
---
# 在 LobeHub 中调度 Claude Code 与 Codex
## 新功能
- 新增:在 LobeHub 中调度 Claude Code 与 Codex
- 按 Agent 分组话题:可将话题列表切换为按 Agent 分组,并带有更友好的空状态
- Review 标签页:新增 Review 标签页,可聚合树级别的批量 git diff,大型仓库下速度提升约 9 倍
- 本地文件提及快照:将文件拖入聊天即可生成快照供模型理解
- 视觉理解工具:内置的图像分析与视觉推理工具
- Line Bot 接入:可将 Line 频道作为 Agent 接入端
- 新模型:`grok-4.3`、DeepSeek Anthropic 运行时,以及模型库新增的 `gpt-image-2` 和 Grok 4.20
## 体验优化与修复
- DeepSeek 模型卡片展示价格并尊重模型默认配置。
- 文档弹窗在标题加载时显示骨架,并在 Space 中展示文档更新时间。
- Agent 文档可作为虚拟文件系统暴露,输出兼容 fs 接口。
- 重置密码后会立即吊销已有会话,tRPC 分页接口新增最大条数限制。
- 在桌面端跳过 Skill OAuth 的 `redirectUri`,避免应用进入异常状态。
- 登录流程中的 CAPTCHA 重试可正常处理,不再直接失败。
+9
View File
@@ -2,6 +2,15 @@
"$schema": "https://github.com/lobehub/lobe-chat/blob/main/docs/changelog/schema.json",
"cloud": [],
"community": [
{
"image": "/blog/assetsb2bf4ddf0a45ff887a993c18cb7ab983.webp",
"id": "2026-05-04-task-scheduler",
"date": "2026-05-04",
"versionRange": [
"2.1.54",
"2.1.56"
]
},
{
"image": "/blog/assetsfa267a02f20bc5ba6f1273bcf27b7c9f.webp",
"id": "2026-04-27-heterogeneous-agent",
+2 -2
View File
@@ -72,7 +72,7 @@ sequenceDiagram
After the user sends a message, `sendMessage()`
(`src/store/chat/slices/aiChat/actions/conversationLifecycle.ts`)
creates the user message and assistant message placeholder,
then calls `internal_execAgentRuntime()`.
then calls `executeClientAgent()`.
### 2. Agent Runtime Drives the Loop
@@ -325,7 +325,7 @@ depends on the scenario:
- **Client-side loop** (browser): Regular 1:1 chat,
continue generation, group orchestration decisions.
The loop runs in the browser, entry point is
`internal_execAgentRuntime()`
`executeClientAgent()`
(`src/store/chat/slices/aiChat/actions/streamingExecutor.ts`)
- **Server-side loop** (queue/local):
Group chat supervisor agent, sub-agent tasks,
+2 -2
View File
@@ -68,7 +68,7 @@ sequenceDiagram
用户发送消息后,`sendMessage()`
`src/store/chat/slices/aiChat/actions/conversationLifecycle.ts`
创建用户消息和助手消息占位,然后调用 `internal_execAgentRuntime()`。
创建用户消息和助手消息占位,然后调用 `executeClientAgent()`。
### 2. Agent Runtime 驱动循环
@@ -293,7 +293,7 @@ Agent Runtime 循环的执行位置取决于场景:
- **客户端循环**(浏览器):常规 1:1 对话、继续生成、
群组编排决策。循环在浏览器中运行,
入口为 `internal_execAgentRuntime()`
入口为 `executeClientAgent()`
`src/store/chat/slices/aiChat/actions/streamingExecutor.ts`
- **服务端循环**(队列 / 本地):群聊 supervisor agent、
子 agent 任务、API/Cron 触发。循环在服务端运行,
+62 -1
View File
@@ -868,6 +868,48 @@ table messages_files {
}
}
table messenger_account_links {
id uuid [pk, not null, default: `gen_random_uuid()`]
user_id text [not null]
platform varchar(50) [not null]
tenant_id varchar(255) [not null, default: '']
platform_user_id varchar(255) [not null]
platform_username text
active_agent_id text
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 {
(platform, tenant_id, platform_user_id) [name: 'messenger_account_links_platform_tenant_user_unique', unique]
(user_id, platform, tenant_id) [name: 'messenger_account_links_user_platform_tenant_unique', unique]
active_agent_id [name: 'messenger_account_links_active_agent_idx']
}
}
table messenger_installations {
id uuid [pk, not null, default: `gen_random_uuid()`]
platform varchar(50) [not null]
tenant_id varchar(255) [not null]
application_id varchar(255) [not null]
account_id varchar(255)
credentials text [not null]
metadata jsonb [not null, default: `{}`]
token_expires_at "timestamp with time zone"
installed_by_user_id text
installed_by_platform_user_id varchar(255)
revoked_at "timestamp with time zone"
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 {
(platform, application_id, tenant_id) [name: 'messenger_installations_platform_app_tenant_unique', unique]
(platform, tenant_id) [name: 'messenger_installations_platform_tenant_idx']
token_expires_at [name: 'messenger_installations_token_expires_at_idx']
}
}
table nextauth_accounts {
access_token text
expires_at integer
@@ -1395,6 +1437,23 @@ table sessions {
}
}
table system_bot_providers {
id uuid [pk, not null, default: `gen_random_uuid()`]
platform varchar(50) [not null]
enabled boolean [not null, default: true]
credentials text [not null]
application_id varchar(255)
settings jsonb [not null, default: `{}`]
connection_mode varchar(20)
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 {
platform [name: 'system_bot_providers_platform_unique', unique]
}
}
table briefs {
id text [pk, not null]
user_id text [not null]
@@ -1412,6 +1471,8 @@ table briefs {
resolved_comment text
read_at "timestamp with time zone"
resolved_at "timestamp with time zone"
trigger varchar(255)
metadata jsonb
created_at "timestamp with time zone" [not null, default: `now()`]
indexes {
@@ -1422,6 +1483,7 @@ table briefs {
type [name: 'briefs_type_idx']
priority [name: 'briefs_priority_idx']
(user_id, resolved_at) [name: 'briefs_unresolved_idx']
trigger [name: 'briefs_trigger_idx']
}
}
@@ -1943,7 +2005,6 @@ table user_memory_persona_documents {
}
}
ref: agent_skills.user_id - users.id
ref: agent_skills.zip_file_hash - global_files.hash_id
@@ -196,6 +196,31 @@ SSRF_ALLOW_IP_ADDRESS_LIST=192.168.1.100,10.0.0.50
- Default: -
- Example: `https://cdn.example.com`
## Visual Understanding
### `VISUAL_UNDERSTANDING_PROVIDER`
- Type: Optional
- Description: Provider ID of the fallback visual understanding model. Configure this together with `VISUAL_UNDERSTANDING_MODEL` to let models without native image or video understanding inspect uploaded visual media through the built-in visual understanding tool.
- Default: -
- Example: `openai`, `google`, or `ollama`
### `VISUAL_UNDERSTANDING_MODEL`
- Type: Optional
- Description: Model ID used by the fallback visual understanding tool. This model should support the visual media types you want to analyze. The feature is enabled only when both `VISUAL_UNDERSTANDING_PROVIDER` and `VISUAL_UNDERSTANDING_MODEL` are configured.
- Default: -
- Example: `gpt-4o`, `gemini-2.5-flash`, or your local visual model ID
Configuration example:
```bash
VISUAL_UNDERSTANDING_PROVIDER=google
VISUAL_UNDERSTANDING_MODEL=gemini-2.5-flash
```
When this feature is enabled, users can upload images or videos while using a model that does not have native visual capabilities, as long as the active model supports tool use. File upload still requires the normal file storage configuration for your deployment.
## AI Image
### `AI_IMAGE_DEFAULT_IMAGE_NUM`
@@ -191,6 +191,31 @@ SSRF_ALLOW_IP_ADDRESS_LIST=192.168.1.100,10.0.0.50
- 默认值:-
- 示例:`https://cdn.example.com`
## 视觉理解
### `VISUAL_UNDERSTANDING_PROVIDER`
- 类型:可选
- 描述:兜底视觉理解模型的服务商 ID。与 `VISUAL_UNDERSTANDING_MODEL` 一起配置后,不具备原生图片或视频理解能力的模型可以通过内置视觉理解工具分析上传的视觉媒体。
- 默认值:-
- 示例:`openai`、`google` 或 `ollama`
### `VISUAL_UNDERSTANDING_MODEL`
- 类型:可选
- 描述:内置视觉理解工具使用的模型 ID。该模型应支持你希望分析的视觉媒体类型。仅当 `VISUAL_UNDERSTANDING_PROVIDER` 与 `VISUAL_UNDERSTANDING_MODEL` 同时配置时,此功能才会启用。
- 默认值:-
- 示例:`gpt-4o`、`gemini-2.5-flash` 或你的本地视觉模型 ID
配置示例:
```bash
VISUAL_UNDERSTANDING_PROVIDER=google
VISUAL_UNDERSTANDING_MODEL=gemini-2.5-flash
```
启用后,当当前模型没有原生视觉能力但支持工具调用时,用户仍可上传图片或视频,并由兜底视觉理解模型进行分析。文件上传本身仍需要部署中正常配置文件存储能力。
## AI 图像
### `AI_IMAGE_DEFAULT_IMAGE_NUM`
+2
View File
@@ -119,6 +119,8 @@ See [AI Provider Configuration](/docs/self-hosting/environment-variables/model-p
- **Cloudflare R2** — No egress fees
- **RustFS / MinIO** — Self-hosted S3 alternative (included in Docker Compose)
**Visual understanding fallback** — Optional, but recommended if users will upload images or videos while chatting with models that do not have native visual capabilities. Configure `VISUAL_UNDERSTANDING_PROVIDER` and `VISUAL_UNDERSTANDING_MODEL` with a visual-capable model from one of your enabled AI providers. See [Basic environment variables](/docs/self-hosting/environment-variables/basic#visual-understanding) for details.
**Authentication provider** — For SSO and team features (Google OAuth, GitHub OAuth, Microsoft Azure AD, Auth0, Keycloak). See [Authentication Setup](/docs/self-hosting/auth) for configuration.
## Security Considerations
+2
View File
@@ -123,6 +123,8 @@ LobeHub 由以下几个关键组件组成:
- **Cloudflare R2** — 无出口流量费用
- **RustFS / MinIO** — 自托管 S3 替代方案(Docker Compose 已内置)
**视觉理解兜底模型** — 可选,但如果用户会在没有原生视觉能力的模型中上传图片或视频,建议配置。你可以使用已启用 AI 提供商中的视觉模型配置 `VISUAL_UNDERSTANDING_PROVIDER` 和 `VISUAL_UNDERSTANDING_MODEL`。详见 [基础环境变量](/zh/docs/self-hosting/environment-variables/basic#视觉理解)。
**认证提供商** — 支持 SSO 和团队功能(Google OAuth、GitHub OAuth、Microsoft Azure AD、Auth0、Keycloak)。配置详见 [认证设置](/docs/self-hosting/auth)。
## 安全注意事项
+198
View File
@@ -0,0 +1,198 @@
---
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
group conversations.
tags:
- LINE
- Message Channels
- Bot Setup
- Integration
---
# Connect LobeHub to LINE
By connecting a LINE channel to your LobeHub agent, users can interact with the AI assistant through LINE direct messages, group chats, and multi-person rooms. The integration uses the official **LINE Messaging API** — there is no third-party broker between LINE and LobeHub.
## Prerequisites
- A LobeHub account with an active subscription
- A [LINE Business ID](https://account.line.biz/signup) (sign up with a LINE account or email)
- A **LINE Official Account** — every Messaging API channel must be attached to one
> **Important change (since 2024-09-04):** LINE no longer lets you create a Messaging API channel directly from the LINE Developers Console. You must first create a LINE Official Account and enable the Messaging API on it from LINE Official Account Manager — the channel then appears automatically in the Developers Console.
## Step 1: Create a LINE Official Account and Enable the Messaging API
<Steps>
### Create a LINE Official Account
Open [entry.line.biz](https://entry.line.biz/form/entry/unverified) and sign in with your LINE Business ID. Fill in the account name, business category, and region, then submit. Confirm the new account appears in [LINE Official Account Manager](https://manager.line.biz/).
### Enable the Messaging API in Official Account Manager
Open the new account → **Settings → Messaging API** → click **Enable Messaging API**. You will be asked to:
- Register developer information (first-time only).
- Pick a **Provider** that will own this channel in the Developers Console — reuse an existing one, or create a fresh one (e.g. "LobeHub").
> **Heads-up:** the provider assignment is **permanent**. If you manage multiple unrelated services, give each one its own provider.
### Find the channel in the Developers Console
Sign in to [LINE Developers Console](https://developers.line.biz/console/) with the same LINE Business ID, open the provider you just chose, and the Messaging API channel will appear automatically.
### Note the channel identifiers
Open the **Basic settings** tab and copy the **Channel secret** — LobeHub uses it to verify webhook signatures in Step 4.
> **Note:** the **"Your user ID"** field on the same tab is **your own** LINE user ID, **not** the bot's destination user ID. Both have the identical `U` + 32 hex format, but LobeHub needs the bot's, which is resolved automatically from the Channel Access Token in Step 4.
Then open the **Messaging API** tab and note:
- **Bot basic ID** — the `@xxxx` short ID users will search for.
</Steps>
## Step 2: Issue a Channel Access Token
<Steps>
### Open the Messaging API tab
Scroll to the bottom of the **Messaging API** tab. You will see a **Channel access token** section.
### Issue a long-lived token
Click **Issue** under "Channel access token (long-lived)". Copy the token immediately — LINE only shows it once.
> **Important:** The Channel Access Token and Channel Secret are sensitive credentials. Never commit them to source control or share them in screenshots.
</Steps>
## Step 3: Disable LINE's Built-in Auto-reply and Greeting
By default the LINE Official Account Manager auto-replies to user messages and sends a greeting on first contact. These compete with LobeHub's responses, so they must be turned off.
<Steps>
### Open the LINE Official Account Manager
In the **Messaging API** tab, click the **LINE Official Account Manager** link to open the management UI for the channel's Official Account.
### Switch the response modes
Go to **Settings → Messaging API** (or **Response settings**) and set:
- **Greeting message:** Disabled
- **Auto-response messages:** Disabled
- **Webhooks:** Enabled
This leaves your bot to handle every inbound message itself.
</Steps>
## Step 4: Configure LINE in LobeHub
<Steps>
### Open Channel Settings
In LobeHub, navigate to your agent's settings, then select the **Channels** tab. Click **LINE** from the platform list.
### Fill in the credentials
Recommended order — paste the token first so LobeHub can auto-fill the Destination User ID:
1. **Channel Access Token** — paste the long-lived token issued in Step 2.
2. **Destination User ID** — click **Fetch from LINE** next to this field. LobeHub calls `GET /v2/bot/info` with the token you just pasted and fills in the bot's `userId` (33 chars, starts with `U`) for you. You can also type/paste it manually if you already have it.
3. **Channel Secret** — paste the Channel secret from the **Basic settings** tab.
> **Why the auto-fetch?** The LINE Developers Console does **not** display the bot's destination user ID anywhere — `/v2/bot/info` is the only way to read it. The **Fetch from LINE** button removes the manual `curl` step.
>
> <details>
> <summary>Manual alternative (if the button is unavailable)</summary>
>
> ```bash
> curl -H "Authorization: Bearer <YOUR_CHANNEL_ACCESS_TOKEN>" \
> https://api.line.me/v2/bot/info
> ```
>
> Copy the `userId` field from the response into the **Destination User ID** field.
> </details>
### Save Configuration
Click **Save Configuration**. LobeHub will encrypt your credentials, call `GET /v2/bot/info` once to verify the token works and that the bot user ID matches your Destination User ID, and surface a **Webhook URL** for the next step.
> **Note:** Unlike Telegram, the LINE Messaging API does not allow programmatic webhook registration. LobeHub cannot wire the URL for you — you must paste it in the LINE Developers Console yourself in Step 5.
</Steps>
## Step 5: Wire the Webhook in the LINE Developers Console
<Steps>
### Copy the Webhook URL
In LobeHub's LINE channel detail page, copy the **Webhook URL** displayed under the credentials section. It looks like `https://app.lobehub.com/api/agent/webhooks/line/<your-destination-user-id>`.
### Paste it in the LINE Developers Console
Back in the **Messaging API** tab of your channel:
- **Webhook URL:** paste the LobeHub Webhook URL.
- Click **Update**.
- Click **Verify**. LINE sends a signed `POST` with `events: []` to LobeHub, which responds 200 if the Channel Secret matches.
- Toggle **Use webhook** to **ON**.
</Steps>
## Step 6: Test the Connection
<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.
### Send a real message
Send any message to the bot in LINE. Within a few seconds your LobeHub agent should reply.
### Run Test Connection (optional)
Click **Test Connection** in LobeHub's channel settings to re-verify the token and the bot identity match. Errors are surfaced with the exact LINE error message.
</Steps>
## Adding the Bot to Group Chats
To use the bot in LINE group chats or multi-person rooms:
1. Add the bot as a friend (Step 6).
2. Create a group or room and invite the bot, **or** invite the bot to an existing group from the bot's profile screen (`...` → **Invite**).
3. Mention the bot or send a message — the bot will reply in the group or room.
> **Note:** Allowing your bot to join groups and rooms requires enabling **"Allow bot to join group chats"** in the LINE Official Account Manager (**Response settings**). It is off by default.
## Configuration Reference
| Field | Required | Description |
| ------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Destination User ID** | Yes | The bot's user ID (`U` + 32 hex chars). LobeHub auto-fills this via the **Fetch from LINE** button (calls `GET /v2/bot/info` with the access token); the LINE Developers Console UI does not display the value. Used as the bot identity and webhook path segment. |
| **Channel Access Token** | Yes | Long-lived token issued from the **Messaging API** tab. Used as the bearer header on every LINE API call. |
| **Channel Secret** | Yes | From the **Basic settings** tab. Used to verify `X-Line-Signature` on every inbound webhook delivery. |
## Feature Notes
LINE's Messaging API has a few specifics that LobeHub maps as follows:
- **Markdown** — LINE renders text messages as **plain text** only. LobeHub strips Markdown markup before sending so emphasis / heading / list markers are removed.
- **Message editing** — the Messaging API does not support editing sent messages, so LobeHub only sends the **final reply**, not per-step progress edits.
- **Typing indicator** — the loading animation is shown in 1:1 user chats only. Group and multi-person room threads silently no-op.
- **Reactions** — LINE bots cannot send message reactions today, so the 👀 / ✏️ status reactions used on Discord and Slack are not surfaced.
- **Outbound** — LobeHub uses the **push API** (`/v2/bot/message/push`) rather than the reply API, because the reply token expires in \~60s while agent generation can take longer. Push messages count against your channel's monthly quota for paid plans; the free Developer Trial is unlimited.
- **Attachments** — inbound images, video, audio, and files are downloaded on demand from the LINE data domain and forwarded to the model. Outbound replies are text-only today.
## Troubleshooting
- **"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.
- **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.
3. The user has added the bot as a friend (LINE will not deliver messages from non-friends).
- **Bot doesn't respond in groups.** Make sure **"Allow bot to join group chats"** is enabled in the Official Account Manager's **Response settings**, then re-invite the bot to the group.
+197
View File
@@ -0,0 +1,197 @@
---
title: 将 LobeHub 连接到 LINE
description: >-
学习如何通过 LINE Messaging API 将 LINE 机器人连接到 LobeHub
代理,使您的 AI 助手能够在 LINE 私信和群组对话中与用户互动。
tags:
- LINE
- 消息渠道
- 机器人设置
- 集成
---
# 将 LobeHub 连接到 LINE
通过将 LINE 渠道连接到您的 LobeHub 代理,用户可以通过 LINE 私信、群组聊天以及多人聊天室与 AI 助手互动。集成使用官方的 **LINE Messaging API**LINE 与 LobeHub 之间没有第三方中转。
## 前置条件
- 一个拥有有效订阅的 LobeHub 账户
- 一个 [LINE Business ID](https://account.line.biz/signup)(可用 LINE 账号或邮箱注册)
- 一个 **LINE Official Account**Messaging API channel 必须挂在 Official Account 下)
> **重要变化(2024-09-04 起):** LINE 已不再允许直接在 LINE Developers Console 中创建 Messaging API channel。必须先建 LINE Official Account,再在 Official Account Manager 中启用 Messaging API,对应的 channel 才会在 Developers Console 中自动出现。
## 第一步:创建 LINE Official Account 并启用 Messaging API
<Steps>
### 创建 LINE Official Account
打开 [entry.line.biz](https://entry.line.biz/form/entry/unverified),用 LINE Business ID 登录,按表单填写账号名称、行业、地区等信息提交。完成后到 [LINE Official Account Manager](https://manager.line.biz/) 确认账号已经出现在列表中。
### 在 Account Manager 中启用 Messaging API
进入这个 Official Account → **Settings → Messaging API** → 点击 **Enable Messaging API**。系统会让你:
- 填写开发者信息(首次启用时)。
- 选择一个 **Provider**(用来在 Developers Console 中分组 channel)—— 已有的可以复用,没有就新建一个,例如 "LobeHub"。
> **注意:** Provider 一旦绑定就 **不能修改**。如果同时管理多个不相关业务,建议为每个业务用独立的 Provider。
### 在 Developers Console 中找到 channel
用同一个 LINE Business ID 登录 [LINE Developers Console](https://developers.line.biz/console/),选中刚才绑的 Provider,对应的 Messaging API channel 会自动出现。
### 记录 channel 的关键标识
打开 **Basic settings** 选项卡,复制 **Channel secret** —— 用于第四步校验 Webhook 签名。
> **注意:** 同一选项卡里的 **"Your user ID"** 是**你账号自己的** LINE user ID**不是** bot 的 destination user ID。两者格式完全一样(都以 `U` 开头共 33 位),但 LobeHub 需要的是 bot 的,会在第四步根据 Channel Access Token 自动解析。
然后切到 **Messaging API** 选项卡,记下:
- **Bot basic ID** —— 用户搜索机器人时使用的 `@xxxx` 短 ID。
</Steps>
## 第二步:签发 Channel Access Token
<Steps>
### 打开 Messaging API 选项卡
滚到 **Messaging API** 选项卡底部的 **Channel access token** 区域。
### 签发长期 Token
在 "Channel access token (long-lived)" 处点击 **Issue**。立即复制 Token —— LINE 只展示一次。
> **重要提示:** Channel Access Token 与 Channel Secret 都是敏感凭据,切勿提交到代码仓库或在截图中泄露。
</Steps>
## 第三步:关闭 LINE 自带的自动回复与欢迎语
LINE Official Account Manager 默认会自动回复用户消息并在第一次接触时发送欢迎语,会与 LobeHub 的回复发生冲突,需要关闭。
<Steps>
### 打开 LINE Official Account Manager
在 **Messaging API** 选项卡中,点击 **LINE Official Account Manager** 链接进入对应 Official Account 的管理界面。
### 切换 response 模式
打开 **设置 → Messaging API**(或 **Response settings**),调整为:
- **Greeting message** Disabled
- **Auto-response messages** Disabled
- **Webhooks** Enabled
这样所有入站消息都会交给你的机器人处理。
</Steps>
## 第四步:在 LobeHub 中配置 LINE
<Steps>
### 打开渠道设置
在 LobeHub 中,进入您的代理设置,选择 **渠道** 标签。在平台列表中点击 **LINE**。
### 填写凭据
推荐顺序 —— 先粘贴 Token,LobeHub 会帮你自动填入 Destination User ID
1. **Channel Access Token** —— 粘贴第二步签发的长期 Token。
2. **Destination User ID** —— 点击该字段旁的 **从 LINE 获取** 按钮。LobeHub 会用刚才填入的 Token 调用 `GET /v2/bot/info`,自动取出 bot 的 `userId`(以 `U` 开头共 33 位)并填入。如果你已经拿到这个值,也可以手动粘贴。
3. **Channel Secret** —— 粘贴 **Basic settings** 选项卡的 Channel secret。
> **为什么需要自动获取?** LINE Developers Console 界面**不展示** bot 的 destination user ID,唯一的获取方式就是调用 `/v2/bot/info`。**从 LINE 获取** 按钮会把这一步 `curl` 替你做掉。
>
> <details>
> <summary>手动备选方案(按钮不可用时)</summary>
>
> ```bash
> curl -H "Authorization: Bearer <你的 channel access token>" \
> https://api.line.me/v2/bot/info
> ```
>
> 把返回 JSON 中的 `userId` 字段复制到 **Destination User ID** 即可。
> </details>
### 保存配置
点击 **保存配置**。LobeHub 会加密您的凭据,调用一次 `GET /v2/bot/info` 验证 Token 可用、且返回的 bot user ID 与 Destination User ID 一致,并在凭据下方显示 **Webhook URL** 供下一步使用。
> **注意:** 与 Telegram 不同,LINE Messaging API 不支持程序化注册 WebhookLobeHub 无法替您在 LINE Developers Console 中填写 URL,需要您在第五步中自行粘贴。
</Steps>
## 第五步:在 LINE Developers Console 配置 Webhook
<Steps>
### 复制 Webhook URL
在 LobeHub 的 LINE 渠道详情页中,复制凭据区域下方显示的 **Webhook URL**。形如 `https://app.lobehub.com/api/agent/webhooks/line/<your-destination-user-id>`。
### 粘贴到 LINE Developers Console
回到 channel 的 **Messaging API** 选项卡:
- **Webhook URL** 粘贴 LobeHub 的 Webhook URL。
- 点击 **Update**。
- 点击 **Verify**。LINE 会向 LobeHub 发送一个签名后的 `POST``events: []`),Channel Secret 匹配时 LobeHub 返回 200。
- 将 **Use webhook** 切到 **ON**。
</Steps>
## 第六步:测试连接
<Steps>
### 添加机器人为好友
在 LINE Developers Console 的 **Messaging API** 选项卡,使用手机扫描机器人的 **QR code**;也可以直接在 LINE 中搜索 **Bot basic ID**(例如 `@abc1234x`)。
### 发送一条真实消息
在 LINE 里向机器人发送任意消息。几秒内 LobeHub 代理就会回复。
### 运行测试连接(可选)
在 LobeHub 渠道设置中点击 **测试连接**,再次校验 Token 与 bot 身份是否匹配,错误信息会透传 LINE 返回的具体内容。
</Steps>
## 在群聊中使用机器人
要在 LINE 群聊或多人聊天室使用机器人:
1. 先按第六步将机器人加为好友。
2. 创建群组或聊天室并邀请机器人;也可以从机器人个人页(`...` → **Invite**)邀请到已存在的群组。
3. 在群里 @ 机器人或直接发送消息,机器人会在群组或聊天室中回复。
> **注意:** 允许机器人加入群组与聊天室需要在 LINE Official Account Manager 的 **Response settings** 中启用 **"Allow bot to join group chats"**,默认是关闭的。
## 配置参考
| 字段 | 是否必需 | 描述 |
| ------------------------ | ---- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| **Destination User ID** | 是 | 机器人的用户 ID(`U` + 32 位十六进制)。LobeHub 会通过 **从 LINE 获取** 按钮(背后调用 `GET /v2/bot/info`)自动填入;LINE Developers Console 界面不展示这个值。同时作为机器人标识与 Webhook 路径。 |
| **Channel Access Token** | 是 | **Messaging API** 选项卡中签发的长期 Token,用作每个 LINE API 调用的 bearer 头。 |
| **Channel Secret** | 是 | **Basic settings** 选项卡中的 Channel secret,用于校验每条入站 Webhook 的 `X-Line-Signature`。 |
## 能力说明
LINE Messaging API 有一些平台层面的限制,LobeHub 的对接行为如下:
- **Markdown** —— LINE 文本消息按 **纯文本** 渲染。LobeHub 在发送前会通过 `stripMarkdown` 去掉强调、标题、列表等标记。
- **消息编辑** —— Messaging API 不支持编辑已发送消息,因此 LobeHub 只发送 **最终回复**,不会逐步刷新中间进度。
- **输入提示动画** —— 仅对 1:1 用户聊天有效,群组与多人聊天室静默 no-op。
- **表情反应** —— LINE 机器人当前不能发送消息反应,Discord/Slack 上使用的 👀 / ✏️ 状态反应不会显示。
- **出站** —— LobeHub 使用 **push API**`/v2/bot/message/push`)而非 reply API,因为 reply token 60 秒就过期,而 agent 生成回复可能更慢。Push 消息会消耗付费方案的月度配额,免费的 Developer Trial 实际不限量。
- **附件** —— 入站的图片、视频、音频、文件会按需通过 LINE 数据子域下载并送给模型;出站消息当前仅支持文本。
## 故障排除
- **LINE Developers Console 的 "Verify" 失败。** LobeHub 中的 Channel Secret 必须与 **Basic settings** 选项卡显示的值完全一致。重新粘贴、保存后再试。
- **保存或测试连接时出现 `Authentication failed.`。** Channel Access Token 失效或被撤销。在 Messaging API 选项卡里重新签发长期 Token,并把新值粘贴到 LobeHub。
- **`Channel access token belongs to bot Uxxx, not Uyyy`。** Destination User ID 与 Token 不匹配。最简方式:清空字段后点击 **从 LINE 获取**,让 LobeHub 重新拉到正确的 `userId`。错误消息里的 `Uxxx` 也是 Token 实际归属的 bot userId,直接复制粘贴也可以。(手动核对:`curl -H "Authorization: Bearer <token>" https://api.line.me/v2/bot/info`。)
- **Webhook 投递返回 `401 Invalid signature`。** LobeHub 中的 Channel Secret 与 LINE 的不一致,更新为正确的 Channel Secret。
- **机器人不回复。** 请依次确认:
1. Messaging API 选项卡中的 **Use webhook** 已切到 **ON**。
2. LINE Official Account Manager 中的 **Auto-response messages** 与 **Greeting message** 都已禁用。
3. 用户已经把机器人加为好友(非好友消息 LINE 不会推送)。
- **群聊中机器人不回复。** 确认 LINE Official Account Manager 的 **Response settings** 中已启用 **"Allow bot to join group chats"**,然后将机器人重新邀请进群组。
+13 -10
View File
@@ -2,8 +2,8 @@
title: Channels Overview
description: >-
Connect your LobeHub agents to external messaging platforms like Discord,
Slack, Telegram, QQ, WeChat, Feishu, and Lark, allowing users to interact with
AI assistants directly in their favorite chat apps.
Slack, Telegram, LINE, QQ, WeChat, Feishu, and Lark, allowing users to
interact with AI assistants directly in their favorite chat apps.
tags:
- Channels
- Message Channels
@@ -11,6 +11,7 @@ tags:
- Discord
- Slack
- Telegram
- LINE
- QQ
- WeChat
- Feishu
@@ -32,6 +33,7 @@ Channels allow you to connect your LobeHub agents to external messaging platform
| [Discord](/docs/usage/channels/discord) | Connect to Discord servers for channel chat and direct messages |
| [Slack](/docs/usage/channels/slack) | Connect to Slack for channel and direct message conversations |
| [Telegram](/docs/usage/channels/telegram) | Connect to Telegram for private and group conversations |
| [LINE](/docs/usage/channels/line) | Connect to LINE Messaging API for direct and group chats |
| [QQ](/docs/usage/channels/qq) | Connect to QQ for group chats and direct messages |
| [WeChat (微信)](/docs/usage/channels/wechat) | Connect to WeChat via iLink Bot for private and group chats (requires an active subscription) |
| [Feishu (飞书)](/docs/usage/channels/feishu) | Connect to Feishu for team collaboration (Chinese version) |
@@ -42,7 +44,7 @@ Channels allow you to connect your LobeHub agents to external messaging platform
Each channel integration works by linking a bot account on the target platform to a LobeHub agent. When a user sends a message to the bot, LobeHub processes it through the agent and sends the response back to the same conversation.
- **Per-agent configuration** — Each agent can have its own set of channel connections, so different agents can serve different platforms or communities.
- **Multiple channels simultaneously** — A single agent can be connected to Discord, Slack, Telegram, QQ, WeChat, Feishu, and Lark at the same time. LobeHub routes messages to the correct agent automatically.
- **Multiple channels simultaneously** — A single agent can be connected to Discord, Slack, Telegram, LINE, QQ, WeChat, Feishu, and Lark at the same time. LobeHub routes messages to the correct agent automatically.
- **Secure credential storage** — All bot tokens and app secrets are encrypted before being stored.
## Getting Started
@@ -52,6 +54,7 @@ Each channel integration works by linking a bot account on the target platform t
- [Discord](/docs/usage/channels/discord)
- [Slack](/docs/usage/channels/slack)
- [Telegram](/docs/usage/channels/telegram)
- [LINE](/docs/usage/channels/line)
- [QQ](/docs/usage/channels/qq)
- [WeChat (微信)](/docs/usage/channels/wechat)
- [Feishu (飞书)](/docs/usage/channels/feishu)
@@ -63,13 +66,13 @@ If you do not see **WeChat** in the channel list, check that your account has an
Text messages are supported across all platforms. Some features vary by platform:
| Feature | Discord | Slack | Telegram | QQ | WeChat | Feishu | Lark |
| ---------------------- | ------- | ----- | -------- | --- | ------ | ------- | ------- |
| Text messages | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| Direct messages | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| Group chats | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| Reactions | Yes | Yes | Yes | No | No | Partial | Partial |
| Image/file attachments | Yes | Yes | Yes | Yes | No | Yes | Yes |
| Feature | Discord | Slack | Telegram | LINE | QQ | WeChat | Feishu | Lark |
| ---------------------- | ------- | ----- | -------- | ------- | --- | ------ | ------- | ------- |
| Text messages | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| Direct messages | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| Group chats | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| Reactions | Yes | Yes | Yes | No | No | No | Partial | Partial |
| Image/file attachments | Yes | Yes | Yes | Inbound | Yes | No | Yes | Yes |
## Allowed Users (global)
+21 -18
View File
@@ -1,7 +1,7 @@
---
title: 渠道概览
description: >-
将 LobeHub 代理连接到外部消息平台,如 Discord、Slack、Telegram、QQ、微信、飞书和
将 LobeHub 代理连接到外部消息平台,如 Discord、Slack、Telegram、LINE、QQ、微信、飞书和
Lark,让用户可以直接在他们喜欢的聊天应用中与 AI 助手互动。
tags:
- 渠道
@@ -10,6 +10,7 @@ tags:
- Discord
- Slack
- Telegram
- LINE
- QQ
- 微信
- 飞书
@@ -26,22 +27,23 @@ tags:
## 支持的平台
| 平台 | 描述 |
| ----------------------------------------- | ---------------------------------- |
| [Discord](/docs/usage/channels/discord) | 连接到 Discord 服务器,用于频道聊天和私信 |
| [Slack](/docs/usage/channels/slack) | 连接到 Slack,用于频道和私信对话 |
| [Telegram](/docs/usage/channels/telegram) | 连接到 Telegram,用于私人和群组对话 |
| [QQ](/docs/usage/channels/qq) | 连接到 QQ,用于群聊和私信 |
| [微信](/docs/usage/channels/wechat) | 通过 iLink Bot 连接到微信,用于私聊和群聊(需要有效订阅) |
| [飞书](/docs/usage/channels/feishu) | 连接到飞书,用于团队协作(中国版) |
| [Lark](/docs/usage/channels/lark) | 连接到 Lark,用于团队协作(国际版) |
| 平台 | 描述 |
| ----------------------------------------- | -------------------------------------- |
| [Discord](/docs/usage/channels/discord) | 连接到 Discord 服务器,用于频道聊天和私信 |
| [Slack](/docs/usage/channels/slack) | 连接到 Slack,用于频道和私信对话 |
| [Telegram](/docs/usage/channels/telegram) | 连接到 Telegram,用于私人和群组对话 |
| [LINE](/docs/usage/channels/line) | 通过 LINE Messaging API 连接到 LINE,支持私聊和群聊 |
| [QQ](/docs/usage/channels/qq) | 连接到 QQ,用于群聊和私信 |
| [微信](/docs/usage/channels/wechat) | 通过 iLink Bot 连接到微信,用于私聊和群聊(需要有效订阅) |
| [飞书](/docs/usage/channels/feishu) | 连接到飞书,用于团队协作(中国版) |
| [Lark](/docs/usage/channels/lark) | 连接到 Lark,用于团队协作(国际版) |
## 工作原理
每个渠道集成都通过将目标平台上的机器人账户与 LobeHub 代理连接来实现。当用户向机器人发送消息时,LobeHub 会通过代理处理消息并将响应发送回同一对话。
- **按代理配置** — 每个代理可以拥有自己的一组渠道连接,因此不同的代理可以服务于不同的平台或社区。
- **同时支持多个渠道** — 单个代理可以同时连接到 Discord、Slack、Telegram、QQ、微信、飞书和 Lark。LobeHub 会自动将消息路由到正确的代理。
- **同时支持多个渠道** — 单个代理可以同时连接到 Discord、Slack、Telegram、LINE、QQ、微信、飞书和 Lark。LobeHub 会自动将消息路由到正确的代理。
- **安全的凭据存储** — 所有机器人令牌和应用密钥在存储前都会被加密。
## 快速开始
@@ -51,6 +53,7 @@ tags:
- [Discord](/docs/usage/channels/discord)
- [Slack](/docs/usage/channels/slack)
- [Telegram](/docs/usage/channels/telegram)
- [LINE](/docs/usage/channels/line)
- [QQ](/docs/usage/channels/qq)
- [微信](/docs/usage/channels/wechat)
- [飞书](/docs/usage/channels/feishu)
@@ -62,13 +65,13 @@ tags:
所有平台均支持文本消息。某些功能因平台而异:
| 功能 | Discord | Slack | Telegram | QQ | 微信 | 飞书 | Lark |
| --------- | ------- | ----- | -------- | -- | -- | ---- | ---- |
| 文本消息 | 是 | 是 | 是 | 是 | 是 | 是 | 是 |
| 私人消息 | 是 | 是 | 是 | 是 | 是 | 是 | 是 |
| 群组聊天 | 是 | 是 | 是 | 是 | 是 | 是 | 是 |
| 表情反应 | 是 | 是 | 是 | 否 | 否 | 部分支持 | 部分支持 |
| 图片 / 文件附件 | 是 | 是 | 是 | 是 | 否 | 是 | 是 |
| 功能 | Discord | Slack | Telegram | LINE | QQ | 微信 | 飞书 | Lark |
| --------- | ------- | ----- | -------- | ---- | -- | -- | ---- | ---- |
| 文本消息 | 是 | 是 | 是 | 是 | 是 | 是 | 是 | 是 |
| 私人消息 | 是 | 是 | 是 | 是 | 是 | 是 | 是 | 是 |
| 群组聊天 | 是 | 是 | 是 | 是 | 是 | 是 | 是 | 是 |
| 表情反应 | 是 | 是 | 是 | 否 | 否 | 否 | 部分支持 | 部分支持 |
| 图片 / 文件附件 | 是 | 是 | 是 | 仅入站 | 是 | 否 | 是 | 是 |
## 允许的用户(全局)
+14
View File
@@ -0,0 +1,14 @@
@journey @home @chat-input
Feature: Home 页面默认 Chat Input 发送链路
Home Topic
Background:
Given
@HOME-CHAT-COLD-001 @P0
Scenario: 首次打开 Agent 路由且无缓存时,Home 默认输入发送后应跳转到新建 Topic
Given Home Agent
When "cold route home message"
And Enter Home
Then Topic
And "cold route home message"
-47
View File
@@ -1,47 +0,0 @@
@journey @home @starter
Feature: Home 页面 Starter 快捷创建功能
Home Starter AgentGroup
Background:
Given
# ============================================
# 创建 Agent 后侧边栏刷新
# ============================================
@HOME-STARTER-AGENT-001 @P0
Scenario: 通过 Home 页面创建 Agent 后返回首页侧边栏应显示新创建的 Agent
Given Home
When Agent
And "E2E Test Agent"
And Enter
Then Agent profile
When Home
Then Agent
# ============================================
# 创建 Group 后侧边栏刷新
# ============================================
@HOME-STARTER-GROUP-001 @P0
Scenario: 通过 Home 页面创建 Group 后返回首页侧边栏应显示新创建的 Group
Given Home
When Group
And "E2E Test Group"
And Enter
Then Group profile
When Home
Then Group
# ============================================
# 创建文档并跳转到写作页面
# ============================================
@HOME-STARTER-WRITE-001 @P0
Scenario: 通过 Home 页面快捷创建文档并跳转到写作页面
Given Home
When
And ""
And Enter
Then
And Page Agent
+137
View File
@@ -0,0 +1,137 @@
import { After, Given, Then, When } from '@cucumber/cucumber';
import { expect } from '@playwright/test';
import { llmMockManager } from '../../mocks/llm';
import type { CustomWorld } from '../../support/world';
import { WAIT_TIMEOUT } from '../../support/world';
const COLD_ROUTE_SCRIPT_DELAY = 2500;
const delay = (ms: number) =>
new Promise((resolve) => {
setTimeout(resolve, ms);
});
const focusHomeChatInput = async (world: CustomWorld): Promise<void> => {
const candidates = [
world.page.locator('[data-testid="chat-input"] textarea'),
world.page.locator('[data-testid="chat-input"] [contenteditable="true"]'),
world.page.getByRole('textbox'),
world.page.locator('[data-testid="chat-input"]'),
];
for (const locator of candidates) {
const count = await locator.count();
for (let index = 0; index < count; index += 1) {
const item = locator.nth(index);
const visible = await item.isVisible().catch(() => false);
if (!visible) continue;
await item.click({ force: true });
return;
}
}
throw new Error('Could not find a visible Home chat input to focus');
};
Given(
'用户在冷启动 Home 页面并延迟 Agent 路由加载',
{ timeout: 45_000 },
async function (this: CustomWorld) {
console.log(' 📍 Step: 设置快速 LLM mock...');
llmMockManager.clearResponses();
llmMockManager.setConfig({
responseDelay: 0,
streamChunkSize: 1024,
streamDelay: 0,
});
llmMockManager.setResponse('cold route home message', 'cold route response');
await llmMockManager.setup(this.page);
console.log(' 📍 Step: 注册冷路由脚本延迟...');
this.testContext.delayColdAgentScripts = false;
await this.page.route('**/*', async (route) => {
const request = route.request();
const url = request.url();
const shouldDelay =
this.testContext.delayColdAgentScripts === true &&
request.resourceType() === 'script' &&
(url.includes('/_next/static/') ||
url.includes('/src/routes/(main)/agent') ||
url.includes('/src/routes/%28main%29/agent'));
if (shouldDelay) {
console.log(` ⏳ Delaying cold agent script: ${url}`);
await delay(COLD_ROUTE_SCRIPT_DELAY);
}
await route.continue();
});
console.log(' 📍 Step: 导航到 Home 页面...');
await this.page.goto('/');
const chatInputContainer = this.page.locator('[data-testid="chat-input"]').first();
await expect(chatInputContainer).toBeVisible({ timeout: WAIT_TIMEOUT });
console.log(' ✅ 已进入冷启动 Home 页面');
},
);
When(
'用户在输入框中输入 {string}',
{ timeout: 30_000 },
async function (this: CustomWorld, text: string) {
console.log(` 📍 Step: 在 Home 输入框中输入 "${text}"...`);
await focusHomeChatInput(this);
await this.page.keyboard.type(text, { delay: 20 });
console.log(' ✅ 已输入 Home 默认消息');
},
);
When('用户按 Enter 从 Home 默认输入发送', { timeout: 45_000 }, async function (this: CustomWorld) {
console.log(' 📍 Step: 启用冷路由延迟并发送默认 Home 消息...');
await this.page.waitForTimeout(200);
this.testContext.delayColdAgentScripts = true;
await this.page.keyboard.press('Enter');
await this.page.waitForURL(/\/agent\/[^/?#]+/, { timeout: WAIT_TIMEOUT });
console.log(' ✅ 已触发 Home 默认发送');
});
Then('页面应该跳转到新建 Topic 对话页面', { timeout: 45_000 }, async function (this: CustomWorld) {
console.log(' 📍 Step: 验证 URL 进入新建 Topic...');
await this.page.waitForURL(/\/agent\/[^/?#]+\/[^/?#]+/, { timeout: 30_000 });
const currentUrl = this.page.url();
expect(currentUrl).toMatch(/\/agent\/[^/?#]+\/[^/?#]+/);
console.log(` ✅ 已跳转到 Topic 页面: ${currentUrl}`);
});
Then(
'用户消息 {string} 应该保留在对话中',
{ timeout: 45_000 },
async function (this: CustomWorld, message: string) {
console.log(` 📍 Step: 验证用户消息仍在对话中: ${message}`);
await expect(this.page.getByText(message).first()).toBeVisible({ timeout: WAIT_TIMEOUT });
console.log(' ✅ 用户消息已保留在对话中');
},
);
After({ tags: '@chat-input' }, async function (this: CustomWorld) {
llmMockManager.resetConfig();
llmMockManager.clearResponses();
this.testContext.delayColdAgentScripts = false;
});
-315
View File
@@ -1,315 +0,0 @@
/* eslint-disable no-console */
/**
* Home Starter Steps
*
* Step definitions for Home page Starter E2E tests
* - Create Agent from Home input
* - Create Group from Home input
* - Create Document (Write) from Home input
* - Verify Agent/Group appears in sidebar after returning to Home
* - Verify Document page navigation and Page Agent interaction
*/
import { Given, Then, When } from '@cucumber/cucumber';
import { expect } from '@playwright/test';
import { llmMockManager, presetResponses } from '../../mocks/llm';
import { type CustomWorld, WAIT_TIMEOUT } from '../../support/world';
// Store created IDs for verification
let createdAgentId: string | null = null;
let createdGroupId: string | null = null;
let createdDocumentId: string | null = null;
// ============================================
// Given Steps
// ============================================
Given('用户在 Home 页面', async function (this: CustomWorld) {
console.log(' 📍 Step: 设置 LLM mock...');
// Setup LLM mock before navigation (for agent/group/page builder message)
llmMockManager.setResponse('E2E Test Agent', presetResponses.greeting);
llmMockManager.setResponse('E2E Test Group', presetResponses.greeting);
llmMockManager.setResponse(
'帮我写一篇关于人工智能的文章',
'好的,我来帮你写一篇关于人工智能的文章。\n\n# 人工智能:改变世界的技术\n\n人工智能(AI)是当今最具变革性的技术之一...',
);
await llmMockManager.setup(this.page);
console.log(' 📍 Step: 导航到 Home 页面...');
await this.page.goto('/');
await this.page.waitForLoadState('networkidle', { timeout: 15_000 });
await this.page.waitForTimeout(1000);
// Reset IDs for each test
createdAgentId = null;
createdGroupId = null;
createdDocumentId = null;
console.log(' ✅ 已进入 Home 页面');
});
// ============================================
// When Steps
// ============================================
When('用户点击创建 Agent 按钮', async function (this: CustomWorld) {
console.log(' 📍 Step: 点击创建 Agent 按钮...');
// Find the "Create Agent" button by text (supports both English and Chinese)
const createAgentButton = this.page
.getByRole('button', { name: /create agent|创建智能体/i })
.first();
await expect(createAgentButton).toBeVisible({ timeout: WAIT_TIMEOUT });
await createAgentButton.click();
// Wait for mode switch animation and ChatInput scroll-into-view to settle
await this.page.waitForTimeout(800);
console.log(' ✅ 已点击创建 Agent 按钮');
});
When('用户点击创建 Group 按钮', async function (this: CustomWorld) {
console.log(' 📍 Step: 点击创建 Group 按钮...');
// Find the "Create Group" button by text (supports both English and Chinese)
const createGroupButton = this.page
.getByRole('button', { name: /create group|创建群组/i })
.first();
await expect(createGroupButton).toBeVisible({ timeout: WAIT_TIMEOUT });
await createGroupButton.click();
// Wait for mode switch animation and ChatInput scroll-into-view to settle
await this.page.waitForTimeout(800);
console.log(' ✅ 已点击创建 Group 按钮');
});
When('用户点击写作按钮', async function (this: CustomWorld) {
console.log(' 📍 Step: 点击写作按钮...');
// Find the "Write" button by text (supports both English and Chinese)
const writeButton = this.page.getByRole('button', { name: /write|写作/i }).first();
await expect(writeButton).toBeVisible({ timeout: WAIT_TIMEOUT });
await writeButton.click();
// Wait for mode switch animation and ChatInput scroll-into-view to settle
await this.page.waitForTimeout(800);
console.log(' ✅ 已点击写作按钮');
});
When('用户在输入框中输入 {string}', async function (this: CustomWorld, message: string) {
console.log(` 📍 Step: 在输入框中输入 "${message}"...`);
// The chat input is a contenteditable editor, need to click first then type.
// Target the contenteditable element INSIDE the ChatInput container directly,
// since clicking the container might hit the action bar/footer area instead.
const chatInputContainer = this.page.locator('[data-testid="chat-input"]').first();
await expect(chatInputContainer).toBeVisible({ timeout: WAIT_TIMEOUT });
const editor = chatInputContainer.locator('[contenteditable="true"]').first();
await editor.click();
await this.page.waitForTimeout(300);
await this.page.keyboard.type(message, { delay: 30 });
console.log(` ✅ 已输入 "${message}"`);
});
When('用户按 Enter 发送', { timeout: 30_000 }, async function (this: CustomWorld) {
console.log(' 📍 Step: 按 Enter 发送...');
// Wait for editor's debounced onChange (100ms default) to sync inputMessage to store.
// The send() function reads directly from the editor as a fallback, but this wait
// ensures maximum reliability.
await this.page.waitForTimeout(200);
// Listen for navigation to capture the agent/group ID
const navigationPromise = this.page.waitForURL(/\/(agent|group)\/.*\/profile/, {
timeout: 30_000,
});
await this.page.keyboard.press('Enter');
// Wait for navigation to profile page
await navigationPromise;
await this.page.waitForLoadState('networkidle', { timeout: 15_000 });
// Extract agent/group ID from URL
const currentUrl = this.page.url();
const agentMatch = currentUrl.match(/\/agent\/([^/]+)/);
if (agentMatch) {
createdAgentId = agentMatch[1];
console.log(` 📍 Created agent ID: ${createdAgentId}`);
}
const groupMatch = currentUrl.match(/\/group\/([^/]+)/);
if (groupMatch) {
createdGroupId = groupMatch[1];
console.log(` 📍 Created group ID: ${createdGroupId}`);
}
console.log(' ✅ 已发送消息');
});
When('用户按 Enter 发送创建文档', { timeout: 30_000 }, async function (this: CustomWorld) {
console.log(' 📍 Step: 按 Enter 发送创建文档...');
// Wait for editor's debounced onChange (100ms default) to sync inputMessage to store
await this.page.waitForTimeout(200);
// Listen for navigation to capture the document ID
const navigationPromise = this.page.waitForURL(/\/page\/[^/]+/, {
timeout: 30_000,
});
await this.page.keyboard.press('Enter');
// Wait for navigation to page
await navigationPromise;
await this.page.waitForLoadState('networkidle', { timeout: 15_000 });
// Extract document ID from URL
const currentUrl = this.page.url();
const pageMatch = currentUrl.match(/\/page\/([^/?]+)/);
if (pageMatch) {
createdDocumentId = pageMatch[1];
console.log(` 📍 Created document ID: ${createdDocumentId}`);
}
console.log(' ✅ 已发送并创建文档');
});
When('用户返回 Home 页面', async function (this: CustomWorld) {
console.log(' 📍 Step: 返回 Home 页面...');
await this.page.goto('/');
await this.page.waitForLoadState('networkidle', { timeout: 15_000 });
await this.page.waitForTimeout(1000);
console.log(' ✅ 已返回 Home 页面');
});
// ============================================
// Then Steps
// ============================================
Then('页面应该跳转到 Agent 的 profile 页面', async function (this: CustomWorld) {
console.log(' 📍 Step: 验证页面跳转到 Agent profile 页面...');
// Check current URL matches /agent/{id}/profile pattern
const currentUrl = this.page.url();
expect(currentUrl).toMatch(/\/agent\/[^/]+\/profile/);
console.log(' ✅ 已跳转到 Agent profile 页面');
});
Then('页面应该跳转到 Group 的 profile 页面', async function (this: CustomWorld) {
console.log(' 📍 Step: 验证页面跳转到 Group profile 页面...');
// Check current URL matches /group/{id}/profile pattern
const currentUrl = this.page.url();
expect(currentUrl).toMatch(/\/group\/[^/]+\/profile/);
console.log(' ✅ 已跳转到 Group profile 页面');
});
Then('新创建的 Agent 应该在侧边栏中显示', async function (this: CustomWorld) {
console.log(' 📍 Step: 验证 Agent 在侧边栏中显示...');
// Wait for sidebar to be visible and data to load
await this.page.waitForTimeout(1500);
// Check if the agent appears in sidebar by its link (primary assertion)
// This proves that refreshAgentList() was called and the sidebar was updated
if (!createdAgentId) {
throw new Error('Agent ID was not captured during creation');
}
const agentLink = this.page.locator(`a[href="/agent/${createdAgentId}"]`).first();
await expect(agentLink).toBeVisible({ timeout: WAIT_TIMEOUT });
console.log(` ✅ 找到 Agent 链接: /agent/${createdAgentId}`);
// Get the aria-label or text content to verify it's the correct agent
const ariaLabel = await agentLink.getAttribute('aria-label');
console.log(` 📍 Agent aria-label: ${ariaLabel}`);
console.log(' ✅ Agent 已在侧边栏中显示');
});
Then('新创建的 Group 应该在侧边栏中显示', async function (this: CustomWorld) {
console.log(' 📍 Step: 验证 Group 在侧边栏中显示...');
// Wait for sidebar to be visible and data to load
await this.page.waitForTimeout(1500);
// Check if the group appears in sidebar by its link (primary assertion)
// This proves that refreshAgentList() was called and the sidebar was updated
if (!createdGroupId) {
throw new Error('Group ID was not captured during creation');
}
const groupLink = this.page.locator(`a[href="/group/${createdGroupId}"]`).first();
await expect(groupLink).toBeVisible({ timeout: WAIT_TIMEOUT });
console.log(` ✅ 找到 Group 链接: /group/${createdGroupId}`);
// Get the aria-label or text content to verify it's the correct group
const ariaLabel = await groupLink.getAttribute('aria-label');
console.log(` 📍 Group aria-label: ${ariaLabel}`);
console.log(' ✅ Group 已在侧边栏中显示');
});
Then('页面应该跳转到文档编辑页面', async function (this: CustomWorld) {
console.log(' 📍 Step: 验证页面跳转到文档编辑页面...');
// Check current URL matches /page/{id} pattern
const currentUrl = this.page.url();
expect(currentUrl).toMatch(/\/page\/[^/?]+/);
if (!createdDocumentId) {
throw new Error('Document ID was not captured during creation');
}
console.log(` ✅ 已跳转到文档编辑页面: /page/${createdDocumentId}`);
});
Then('Page Agent 应该收到用户的提示词', async function (this: CustomWorld) {
console.log(' 📍 Step: 验证 Page Agent 收到用户的提示词...');
// Wait for the page to fully load and Page Agent panel to appear
await this.page.waitForTimeout(2000);
// Look for the user message in the chat panel (Page Agent Copilot)
// The message should appear in the chat list
const userMessage = this.page.locator('text=帮我写一篇关于人工智能的文章').first();
// The message might be in the chat panel on the right side
const messageVisible = await userMessage.isVisible().catch(() => false);
if (messageVisible) {
console.log(' ✅ 找到用户发送的提示词');
} else {
// Alternative: check if there's any chat content indicating the message was sent
console.log(' ⚠️ 用户消息可能在聊天面板中,但未直接可见');
}
// Verify that the Page Agent responded (mock response should appear)
// Wait a bit longer for the mock LLM response
await this.page.waitForTimeout(3000);
// Look for AI response content
const aiResponse = this.page.locator('text=人工智能').first();
const responseVisible = await aiResponse.isVisible().catch(() => false);
if (responseVisible) {
console.log(' ✅ Page Agent 已响应用户的提示词');
} else {
console.log(' ⚠️ Page Agent 响应可能正在生成或在其他位置');
}
console.log(' ✅ Page Agent 验证完成');
});
+2 -2
View File
@@ -29,7 +29,7 @@ async function waitForPageWorkspaceReady(world: CustomWorld): Promise<void> {
}
const readyCandidates = [
world.page.locator('button:has(svg.lucide-square-pen)').first(),
world.page.locator(':is(button, [role="button"]):has(svg.lucide-square-pen)').first(),
world.page.locator('input[placeholder*="Search"], input[placeholder*="搜索"]').first(),
world.page.locator('a[href^="/page/"]').first(),
];
@@ -50,7 +50,7 @@ async function clickNewPageButton(world: CustomWorld): Promise<void> {
await waitForPageWorkspaceReady(world);
const candidates = [
world.page.locator('button:has(svg.lucide-square-pen)').first(),
world.page.locator(':is(button, [role="button"]):has(svg.lucide-square-pen)').first(),
world.page
.locator('svg.lucide-square-pen')
.first()
+5 -3
View File
@@ -114,9 +114,11 @@ async function waitForPageWorkspaceReady(world: CustomWorld): Promise<void> {
continue;
}
// Any of these means the page workspace is ready for interactions
// Any of these means the page workspace is ready for interactions.
// The new-page button is rendered by `@lobehub/ui` ActionIcon as a
// `<div role="button">` rather than a native `<button>`, so match either.
const readyCandidates = [
world.page.locator('button:has(svg.lucide-square-pen)').first(),
world.page.locator(':is(button, [role="button"]):has(svg.lucide-square-pen)').first(),
world.page.locator('input[placeholder*="Search"], input[placeholder*="搜索"]').first(),
world.page.locator('a[href^="/page/"]').first(),
];
@@ -137,7 +139,7 @@ async function clickNewPageButton(world: CustomWorld): Promise<void> {
await waitForPageWorkspaceReady(world);
const candidates = [
world.page.locator('button:has(svg.lucide-square-pen)').first(),
world.page.locator(':is(button, [role="button"]):has(svg.lucide-square-pen)').first(),
world.page
.locator('svg.lucide-square-pen')
.first()
+35
View File
@@ -1,3 +1,5 @@
import { randomBytes } from 'node:crypto';
import bcrypt from 'bcryptjs';
// Test user credentials - these are used for e2e testing only
@@ -92,6 +94,39 @@ export async function seedTestUser(): Promise<void> {
}
}
export async function createTestSession(): Promise<string | null> {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
console.log('⚠️ DATABASE_URL not set, cannot create test session');
return null;
}
await seedTestUser();
const { default: pg } = await import('pg');
const client = new pg.Client({ connectionString: databaseUrl });
try {
await client.connect();
const now = new Date();
const expiresAt = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
const sessionId = randomBytes(9).toString('base64url');
const sessionToken = randomBytes(24).toString('base64url');
await client.query(
`INSERT INTO auth_sessions (id, token, user_id, expires_at, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $5)`,
[sessionId, sessionToken, TEST_USER.id, expiresAt.toISOString(), now.toISOString()],
);
return sessionToken;
} finally {
await client.end();
}
}
/**
* Clean up test user data after tests
*/
+57 -2
View File
@@ -1,4 +1,13 @@
{
"channel.allowFrom": "المستخدمون المسموح لهم",
"channel.allowFromAdd": "إضافة مستخدم",
"channel.allowFromEmpty": "لم تتم إضافة أي مستخدم بعد — يمكن لأي شخص التفاعل مع الروبوت.",
"channel.allowFromHint": "فقط المستخدمون المدرجون يمكنهم التفاعل مع الروبوت؛ يتم تضمين 'معرّف المستخدم على المنصة' الخاص بك تلقائيًا.",
"channel.allowFromIdLabel": "معرّف المستخدم",
"channel.allowFromIdPlaceholder": "معرّف المستخدم على المنصة",
"channel.allowFromNameLabel": "ملاحظة",
"channel.allowFromNamePlaceholder": "مثال: آليس (تذكيرك)",
"channel.allowListRemove": "إزالة",
"channel.appSecret": "سر التطبيق",
"channel.appSecretHint": "سر التطبيق لتطبيق الروبوت الخاص بك. سيتم تشفيره وتخزينه بأمان.",
"channel.appSecretPlaceholder": "الصق سر التطبيق هنا",
@@ -14,8 +23,10 @@
"channel.charLimitHint": "الحد الأقصى لعدد الأحرف لكل رسالة",
"channel.concurrency": "وضع التزامن",
"channel.concurrencyDebounce": "إزالة الارتداد",
"channel.concurrencyDebounceHint": "معالجة آخر رسالة فقط في الدفعة (يتم تجاهل الرسائل السابقة)",
"channel.concurrencyHint": "يقوم الوضع التتابعي بمعالجة الرسائل واحدة تلو الأخرى؛ بينما ينتظر وضع إزالة الارتداد انتهاء دفعة الرسائل قبل المعالجة",
"channel.concurrencyQueue": "قائمة الانتظار",
"channel.concurrencyQueueHint": "معالجة الرسائل واحدة تلو الأخرى",
"channel.connectFailed": "فشل اتصال الروبوت",
"channel.connectQueued": "تم وضع اتصال الروبوت في قائمة الانتظار. سيبدأ قريبًا.",
"channel.connectStarting": "الروبوت قيد التشغيل. يرجى الانتظار لحظة.",
@@ -25,7 +36,9 @@
"channel.connectionMode": "وضع الاتصال",
"channel.connectionModeHint": "يُفضَّل استخدام WebSocket للروبوتات الجديدة. استخدم Webhook إذا كان روبوتك يحتوي بالفعل على عنوان URL مُعدّ لرد النداء على منصة QQ المفتوحة.",
"channel.connectionModeWebSocket": "WebSocket",
"channel.connectionModeWebSocketHint": "موصى به للروبوتات الجديدة",
"channel.connectionModeWebhook": "Webhook",
"channel.connectionModeWebhookHint": "استخدمه إذا كان لدى روبوتك عنوان URL لردّ النداء مُعدّ",
"channel.copied": "تم النسخ إلى الحافظة",
"channel.copy": "نسخ",
"channel.credentials": "بيانات الاعتماد",
@@ -45,13 +58,16 @@
"channel.displayToolCalls": "عرض استدعاءات الأدوات",
"channel.displayToolCallsHint": "عرض تفاصيل استدعاء الأدوات أثناء استجابات الذكاء الاصطناعي. عند التعطيل، يتم عرض الاستجابة النهائية فقط لتجربة أكثر نظافة.",
"channel.dm": "الرسائل المباشرة",
"channel.dmEnabled": "تمكين الرسائل المباشرة",
"channel.dmEnabledHint": "السماح للروبوت بتلقي الرسائل المباشرة والرد عليها",
"channel.dmPolicy": "سياسة الرسائل المباشرة",
"channel.dmPolicyAllowlist": "القائمة المسموح بها",
"channel.dmPolicyAllowlistHint": "فقط المستخدمون المدرجون يمكنهم إرسال رسائل خاصة إلى الروبوت",
"channel.dmPolicyDisabled": "معطل",
"channel.dmPolicyDisabledHint": "رفض جميع الرسائل الخاصة",
"channel.dmPolicyHint": "التحكم في من يمكنه إرسال الرسائل المباشرة إلى الروبوت",
"channel.dmPolicyOpen": "مفتوح",
"channel.dmPolicyOpenHint": "قبول الرسائل الخاصة من أي شخص",
"channel.dmPolicyPairing": "الاقتران",
"channel.dmPolicyPairingHint": "يحتاج الغرباء إلى استخدام ‎/approve لإرسال رسالة خاصة",
"channel.documentation": "التوثيق",
"channel.enabled": "مفعّل",
"channel.encryptKey": "مفتاح التشفير",
@@ -63,6 +79,22 @@
"channel.feishu.description": "قم بتوصيل هذا المساعد بـ Feishu للدردشة الخاصة والجماعية.",
"channel.feishu.webhookMigrationDesc": "يوفّر وضع WebSocket تسليمًا فوريًا للأحداث دون الحاجة إلى عنوان URL عام لرد النداء. للانتقال، قم بتغيير وضع الاتصال إلى WebSocket في الإعدادات المتقدمة. لا يلزم أي إعداد إضافي على منصة Feishu/Lark المفتوحة.",
"channel.feishu.webhookMigrationTitle": "النظر في الترقية إلى وضع WebSocket",
"channel.groupAllowFrom": "القنوات المسموح بها",
"channel.groupAllowFromAdd": "إضافة قناة",
"channel.groupAllowFromEmpty": "لم تتم إضافة أي قنوات بعد — لن يرد الروبوت في أي مكان.",
"channel.groupAllowFromHint": "معرّفات القنوات / المجموعات / الدردشات التي يمكن للروبوت الرد فيها.",
"channel.groupAllowFromIdLabel": "معرّف القناة",
"channel.groupAllowFromIdPlaceholder": "معرّف القناة / المجموعة / الدردشة",
"channel.groupAllowFromNameLabel": "ملاحظة",
"channel.groupAllowFromNamePlaceholder": "مثال: #general (تذكيرك)",
"channel.groupPolicy": "سياسة المجموعات",
"channel.groupPolicyAllowlist": "قائمة السماح",
"channel.groupPolicyAllowlistHint": "الرد فقط في القنوات المدرجة",
"channel.groupPolicyDisabled": "معطّل",
"channel.groupPolicyDisabledHint": "تجاهل جميع رسائل المجموعات",
"channel.groupPolicyHint": "أماكن رد الروبوت في المجموعات والقنوات والمواضيع",
"channel.groupPolicyOpen": "مفتوح",
"channel.groupPolicyOpenHint": "الرد في أي مجموعة أو قناة أو موضوع",
"channel.historyLimit": "حد رسائل السجل",
"channel.historyLimitHint": "العدد الافتراضي للرسائل التي يتم جلبها عند قراءة سجل القناة",
"channel.importConfig": "استيراد التكوين",
@@ -70,6 +102,19 @@
"channel.importInvalidFormat": "تنسيق ملف التكوين غير صالح",
"channel.importSuccess": "تم استيراد التكوين بنجاح",
"channel.lark.description": "قم بتوصيل هذا المساعد بـ Lark للدردشة الخاصة والجماعية.",
"channel.line.channelAccessToken": "رمز الوصول للقناة",
"channel.line.channelAccessTokenHint": "رمز طويل الأمد يتم إصداره ضمن علامة تبويب واجهة برمجة تطبيقات المراسلة. سيتم تشفير الرمز وتخزينه بأمان.",
"channel.line.channelSecret": "سر القناة",
"channel.line.channelSecretHint": "من علامة تبويب الإعدادات الأساسية. مطلوب - يُستخدم للتحقق من توقيع X-Line-Signature على كل طلب ويب وارد.",
"channel.line.description": "قم بتوصيل هذا المساعد بواجهة برمجة تطبيقات المراسلة الخاصة بـ LINE للمحادثات المباشرة والجماعية.",
"channel.line.destinationUserId": "معرّف المستخدم الوجهة",
"channel.line.destinationUserIdHint": "معرّف المستخدم الوجهة الخاص بالروبوت (يبدأ بـ `U`، إجمالي 33 حرفًا). لا يعرض وحدة تحكم مطوري LINE هذه القيمة. قم بإصدار رمز الوصول للقناة أدناه أولاً، ثم انقر على \"Fetch from LINE\" لملء هذا الحقل تلقائيًا. ملاحظة: \"معرّف المستخدم الخاص بك\" في الإعدادات الأساسية هو معرّف المستخدم الشخصي الخاص بك في LINE، وليس معرّف الروبوت.",
"channel.line.destinationUserIdPlaceholder": "مثال: U1234567890abcdef1234567890abcdef",
"channel.line.fetchBotInfo": "جلب من LINE",
"channel.line.fetchBotInfoFailed": "فشل في جلب معلومات الروبوت",
"channel.line.fetchBotInfoMissingToken": "أدخل رمز الوصول للقناة أولاً، ثم انقر على \"Fetch from LINE\".",
"channel.line.fetchBotInfoSuccess": "تم جلب معرّف المستخدم الوجهة",
"channel.line.webhookManualSetup": "لا يسمح LINE بالتسجيل البرمجي للويب هوك. انسخ هذا الرابط إلى وحدة تحكم مطوري LINE (واجهة برمجة تطبيقات المراسلة → رابط الويب هوك)، انقر على \"تحقق\"، وقم بتمكين \"استخدام الويب هوك\".",
"channel.openPlatform": "منصة مفتوحة",
"channel.platforms": "المنصات",
"channel.publicKey": "المفتاح العام",
@@ -93,6 +138,8 @@
"channel.secretTokenPlaceholder": "السر الاختياري للتحقق من الويب هوك",
"channel.serverId": "معرف الخادم / النقابة الافتراضي",
"channel.serverIdHint": "معرف الخادم أو النقابة الافتراضي الخاص بك على هذه المنصة. يستخدمه الذكاء الاصطناعي لإدراج القنوات دون الحاجة للسؤال.",
"channel.serverIdHint.discord": "فعّل وضع المطوّر (الإعدادات → متقدم)، ثم انقر بزر الفأرة الأيمن على أيقونة الخادم → انسخ معرّف الخادم.",
"channel.serverIdHint.slack": "معرّف مساحة العمل (يبدأ بـ T). اعثر عليه تحت الإعدادات والإدارة → إعدادات مساحة العمل، أو في رابط مساحة العمل.",
"channel.settings": "الإعدادات المتقدمة",
"channel.settingsResetConfirm": "هل أنت متأكد أنك تريد إعادة تعيين الإعدادات المتقدمة إلى الوضع الافتراضي؟",
"channel.settingsResetDefault": "إعادة إلى الوضع الافتراضي",
@@ -120,6 +167,14 @@
"channel.updateFailed": "فشل في تحديث الحالة",
"channel.userId": "معرف المستخدم الخاص بك على المنصة",
"channel.userIdHint": "معرف المستخدم الخاص بك على هذه المنصة. يمكن للذكاء الاصطناعي استخدامه لإرسال رسائل مباشرة إليك.",
"channel.userIdHint.discord": "فعّل وضع المطوّر (الإعدادات → متقدم)، ثم انقر بزر الفأرة الأيمن على صورتك الشخصية → انسخ معرّف المستخدم.",
"channel.userIdHint.feishu": "افتح تطبيقك على منصة Feishu / Lark Open Platform → الأذونات، ثم ابحث عن المعرّف المفتوح الخاص بك.",
"channel.userIdHint.line": "افتح وحدة تحكم مطوري LINE → قناتك → علامة تبويب الإعدادات الأساسية، ونسخ \"معرّف المستخدم الخاص بك\" (يبدأ بحرف U، 33 حرفًا).",
"channel.userIdHint.qq": "رقم QQ الخاص بك، يظهر في صفحة ملفك الشخصي.",
"channel.userIdHint.slack": "افتح ملفك الشخصي في Slack → ⋮ المزيد → انسخ معرّف العضو (يبدأ بـ U).",
"channel.userIdHint.telegram": "أرسل أي رسالة إلى @userinfobot على تيليغرام — سيرد عليك بمعرّف المستخدم الرقمي الخاص بك.",
"channel.userIdMissingDesc": "بدونه، لا يمكن لأدوات الذكاء الاصطناعي الوصول إليك عبر التذكيرات، كما سيفشل اعتماد الاقتران. قم بإدخاله في الإعدادات المتقدمة.",
"channel.userIdMissingTitle": "أضف معرّف المستخدم على منصتك",
"channel.validationError": "يرجى ملء معرف التطبيق والرمز",
"channel.verificationToken": "رمز التحقق",
"channel.verificationTokenHint": "اختياري. يُستخدم للتحقق من مصدر أحداث الويب هوك.",
+4
View File
@@ -33,6 +33,10 @@
"authModal.signIn": "تسجيل الدخول مرة أخرى",
"authModal.signingIn": "جارٍ تسجيل الدخول...",
"authModal.title": "انتهت الجلسة",
"betterAuth.captcha.continue": "استمر",
"betterAuth.captcha.description": "أكمل التحقق الأمني أدناه. سنواصل عملية التسجيل أو تسجيل الدخول تلقائيًا.",
"betterAuth.captcha.pendingDescription": "لم يكتمل التحقق. يرجى محاولة التحدي مرة أخرى.",
"betterAuth.captcha.title": "مطلوب التحقق الأمني",
"betterAuth.errors.confirmPasswordRequired": "يرجى تأكيد كلمة المرور",
"betterAuth.errors.emailExists": "هذا البريد الإلكتروني مسجل بالفعل. يرجى تسجيل الدخول بدلاً من ذلك",
"betterAuth.errors.emailInvalid": "يرجى إدخال بريد إلكتروني أو اسم مستخدم صالح",

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