Compare commits

...

89 Commits

Author SHA1 Message Date
lobehubbot 694a25822f 🔖 chore(release): release version v2.2.0 [skip ci] 2026-05-18 04:43:53 +00:00
Arvin Xu 46818e9571 🚀 release: v2.2.0 (#14915)
# 🚀 LobeHub Release (20260518)

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

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

---

##  Highlights

### 🎩 Chief Agent Operator

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

### 🚀 Cloud & runtime

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

### 💬 Workspace experience

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

---

## 🏗️ Core Agent & Signal Pipeline

### Self-iteration & Agent Signal

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

### Heterogeneous agent runtime

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

### Agent runtime & context engine

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

### Agent tracing & operations

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

---

## 📱 Platform & Integrations

### Bot / Channels

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

### Onboarding

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

### Model providers

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

### Built-in tools & knowledge base

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

---

## 🖥️ CLI & User Experience

### Chat & Conversation

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

### Tasks

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

### Navigation & Layout

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

### Other polish

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

---

## 🔧 Tooling & Developer Experience

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

---

## 🔒 Security & Reliability

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

---

## 👥 Contributors

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

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

Plus @lobehubbot for maintenance translations.

---

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

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

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

* 🔥 chore(hotkey): remove clearCurrentMessages shortcut

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

CI failures from the previous commits:

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

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

* 🐛 fix(hetero-agent): keep reasoning state live during gateway streaming

The gateway event handler only accumulated reasoning text into `message.reasoning`
without ever creating a `type: 'reasoning'` operation, so `isMessageInReasoning`
was always `false`. The Thinking UI then rendered the "已深度思考" completed title
and stayed collapsed for the entire stream. Mirror `StreamingHandler`'s lifecycle:
start a reasoning sub-op on the first thinking chunk and end it on text /
tools_calling / stream_end / stream_start (next step) / agent_runtime_end / error.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 03:03:48 +08:00
Arvin Xu 654035e7b0 🐛 fix(google): add magic thoughtSignature to all functionCall parts, not just last turn (#14904)
Previously the magic signature was only applied when the last message was a
tool message and only to functionCall parts after the last user message. This
missed cross-provider scenarios (e.g. OpenAI GPT-5 → Gemini switch) where
historical tool_calls lack thoughtSignature, causing Gemini API warnings:

  Function call is missing a thought_signature in functionCall parts.

Now we unconditionally iterate all model-role contents and add the magic
signature to any functionCall part that doesn't have one, ensuring Gemini's
thought signature validator is always satisfied regardless of conversation
history origin.

See LOBE-8662
2026-05-18 02:38:02 +08:00
Innei eb39f193c9 ♻️ refactor(chat-input): adopt native submenu header/footer slots for skill menu (#14901)
* ♻️ refactor(chat-input): adopt native submenu header/footer slots for skill menu

The skill menu in the Plus dropdown pinned its search bar and stats footer as faux menu items held by position:sticky CSS hacks (data-fixed-menu-footer / data-skill-menu-search / data-skill-stats). @lobehub/ui 5.14.0 adds native header/footer slots to submenu popups, so move the search bar and stats row onto those slots and drop the hacks.

* ♻️ refactor(knowledge-controls): integrate footer into useControls and update PlusAction to utilize new structure

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

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-05-18 00:55:49 +08:00
YuTengjing 7e514ac3e3 🐛 fix: use JSON object for video image reference (#14900) 2026-05-18 00:55:29 +08:00
Zhijie He f3f2bda880 💄 style: add ernie-5.1 support (#14643) 2026-05-18 00:44:49 +08:00
Arvin Xu 6434ee9a5d 🐛 fix(agent): stop auto-collapsing right working panel on chat mount (#14883)
* 🐛 fix(agent): stop auto-collapsing right working panel on chat mount

ChatConversation had a mount effect that forcibly toggled showRightPanel
off whenever status init completed, so switching to a new topic (which
remounts the route subtree) would close the user's Workspace panel.
Drop the effect and default showRightPanel to false instead — the
persisted user preference is now the single source of truth.

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

* 🐛 fix(agent): keep right-panel toggles usable before status hydration

INITIAL_STATUS.showRightPanel now defaults to false, which means
WorkingPanelToggle / ToggleRightPanelButton / ParamsPanelToggle render
their "open" button during the pre-hydration window. But
updateSystemStatus bails early while isStatusInit is false, so the very
first click was silently dropped and the panel stayed closed even after
hydration when storage was empty.

Defer rendering these toggles until isStatusInit flips true so a click
can never land in the no-op window. Also fix the
action.test.ts > toggleRightPanel > should toggle chat sidebar case,
which was passing only because the old default was true; it now hydrates
the store before asserting.

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

* 🐛 fix(agent): stop overwriting working-sidebar tab when reopening panel

WorkingPanelToggle unconditionally set storedTab='review' on every
click, so any Space/Files preference the user had clicked previously
got clobbered the next time they re-opened the right panel — most
visibly on hetero CC sessions where the intended default is Space.

The toggle now just toggles the panel open; the sidebar's own
resolveActiveTab handles defaulting (hetero → Space, otherwise → last
explicit click, then Review/Files based on local-system availability).

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 00:44:14 +08:00
Arvin Xu b52ff52949 🐛 fix(hetero-agent): restore tools/model from DB at ingest refresh to fix multi-replica parent_id breaks (#14897)
* 🐛 fix(hetero-agent): restore tools/model from DB at ingest refresh to fix multi-replica parent_id breaks

In prod a topic with 11 step boundaries produced 4 assistants whose
parentId pointed at the previous assistant instead of the previous tool
message — same in-memory state.toolState gets reset at the end of every
handleStepStart, so if the next step's tools_calling lands on a different
replica, this replica stays empty and the following step boundary falls
back to currentAssistantMessageId. Two of the four also had
model=null/provider=null for the same reason: handleTurnMetadata only
cached lastModel/lastProvider in memory.

Adopt DB as authoritative at the ingest() refresh: replace
state.toolState wholesale when DB has more tools or more result_msg_ids
than memory, and restore state.lastModel/lastProvider from the refreshed
assistant row. Also extend handleTurnMetadata to persist model/provider
to DB (previously only metadata.usage was written), so the refresh path
has something to recover from.

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

* 🐛 fix(hetero-agent): never mark unresolved restored tools as persisted

Three sites that hydrate `state.toolState` from DB-side `assistant.tools[]`
were unconditionally pushing every id into `persistedIds`:

- `ingest()` refresh (newly added in the prior commit on this branch)
- `loadOrCreateState` (cold replica boot)
- `syncAssistantPointerForAdvancedStep`

`persistToolBatch` writes `tools[]` in Phase 1 BEFORE creating the
`role:'tool'` row in Phase 2 and backfilling `result_msg_id`. A replica
that hydrates between those two phases sees an unresolved id; marking it
as persisted then causes a follow-up retry of the same tools_calling
event to fall out of `freshForCreate`, skip Phase 2, and rewrite the
unresolved `tools[]` unchanged — leaving the tool permanently without a
tool message / result_msg_id.

Restore only ids whose `result_msg_id` is already set. Unresolved ids
stay re-createable so the BatchIngester's outer retry can complete the
write.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 23:48:26 +08:00
Arvin Xu 4766bb3eb3 feat(hetero): surface project skills in working sidebar + markdown preview (#14896)
*  feat(hetero-cc): surface project skills in working sidebar + markdown preview

When the active agent is a heterogeneous Claude Code session, the Space tab
now lists skills discovered under `<cwd>/.agents/skills/` (with a fallback
to `<cwd>/.claude/skills/`). Each row shows the skill's frontmatter name,
file count, and a chevron to expand a peek at the bundle contents; clicking
the name opens `SKILL.md` in the LocalFile portal, and clicking a child
file opens that file directly.

The LocalFile portal also gets a Preview / Raw toggle for `.md` / `.mdx`
files — frontmatter is now parsed and the YAML block stripped from the
rendered markdown body (no more `name: x description: y` reading as a wall
of body text). The portal tab strip distinguishes SKILL.md tabs by showing
the skill name with the Skills icon instead of the generic filename, and
falls back to a file icon for all other open files. Markdown content gets
its own scroll container so the Preview pane scrolls correctly.

The space-tab AgentDocuments group is hidden for hetero CC sessions so the
panel focuses on skills.

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

*  feat(hetero-cc): default to Space tab for hetero sessions

Hetero CC right-panel now defaults to the Space tab (where the Skills
module lives) when there's no prior stored tab choice. Non-hetero sessions
keep the existing review/files/resources fallback order.

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

* 💄 style(hetero-cc): surface cumulative progress on Task inspector rows

TaskCreate / TaskUpdate-with-status inspector rows now lead with the
same ProgressRing (from pluginState.todos) and a `completed/total`
chip, so a mixed create/update column reads as one continuous progress
gauge instead of bare-text per-row signals. The verb in the label
still carries the per-row status.

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

*  feat(hetero-cc): project skills in slash menu + skills panel polish

Surfaces `.agents/skills/` SKILL.md entries as a new `projectSkill`
ActionTag category in the chat input's `/` menu so users can invoke
project skills the same way CC does internally. The chip serializes to
literal `/<skill-name>` on send, leaving CC's own skill resolution
untouched (no system prompt injection).

Side-panel polish bundled in: the Space-tab Skills list expands as a
real directory tree, the LocalFile portal renders SKILL.md frontmatter
as a metadata card (reusing parseSkillMarkdownMetadata), and skill rows
use the secondary→colorText hover pattern. Also passes `data.root` (the
exact root listProjectSkills approves) to openLocalFile so previews
never hit the workspace-root mismatch path.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 23:43:27 +08:00
Innei 7ab111fcc5 🐛 fix(onboarding): restore FullNameStep back button to the shared prefix (#14898)
FullNameStep is the classic branch's first step; its back button called
goToPreviousStep, which no-ops at step 1 — a dead link ever since the
telemetry/language steps were extracted into the shared prefix.

Route it back to ResponseLanguageStep, and let CommonOnboardingPage
re-enter the shared prefix when an explicit `?step` is present (a bare
`/onboarding` still resumes the branch).
2026-05-17 23:31:11 +08:00
Neko 6281ca4228 🐛 fix(agent-signal): route hinted skill documents (#14895) 2026-05-17 22:59:00 +08:00
Arvin Xu 73fa3b1689 feat: agent-documents index — hide web crawls + new table format (#14292)
*  feat: agent-documents index — hide web crawls + new table format

The default `<agent_documents_index>` was injecting every progressive
document — including hundreds of web-crawled snapshots (~73% of all
agent docs in production). The result was a low-signal list dominated
by duplicate page titles, plus zero metadata for the LLM to rank by.

This revamp:

- Hides `source_type=web` documents from the default index. Header
  surfaces the count and points the LLM at `listDocuments(sourceType=
  'web')` to enumerate them when needed.
- Renders the index as a fixed-width table with TITLE / ID / SIZE /
  UPDATED columns. Rows are sorted by recency (most-recent first).
  Empty docs render as `empty` to discourage retry reads.
- Adds `sourceType` and `updatedAt` to the `AgentContextDocument`
  contract; client mapping populates both from the DB row.
- Adds `sourceType: 'all' | 'file' | 'web'` parameter to the
  listDocuments tool/TRPC; service-layer filter applies before
  shaping the LLM response.
- Renames `target` → `scope` on listDocuments + createDocument
  (manifest, types, runtime, system role, TRPC, client service,
  call sites, tests). `target="currentTopic"` becomes
  `scope="currentTopic"` everywhere.

Coverage: inline snapshot tests in
`packages/context-engine/src/providers/__tests__/AgentDocumentInjector.test.ts`
pin the rendered output for the three load cases (mixed user docs,
web-hidden header, empty doc).

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

* 🐛 fix(test): update listDocuments mock assertion for sourceType default

The agent-documents listDocuments runtime now forwards sourceType
(defaulting to 'all'), so the spy receives two positional args.

* 📝 docs(builtin-tool-local-system): bump documented runCommand max timeout to 800000ms

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:08:08 +08:00
Neko 04e9f7fcea ♻️ refactor(agent-signal): adapt response language for prompts (#14890) 2026-05-17 21:20:59 +08:00
Arvin Xu 1cc92db5e2 💄 style(tab-bar): add Chrome-style divider between inactive tabs (#14892) 2026-05-17 21:10:31 +08:00
Arvin Xu 2d088ca6e2 🐛 fix(local-system): forward all grepContent params + move executor to /client (#14888)
* 🐛 fix(local-system): forward all grepContent params + move executor to /client

The local-system executor was reducing the agent's full grepContent params
({pattern, glob, output_mode, -i/-n/-A/-B/-C, multiline, head_limit, type,
scope, ...}) down to {directory, pattern} before handing them to the runtime.
`directory` isn't recognized by the IPC layer (which expects path/scope), so
cwd silently fell back to process.cwd() (= apps/desktop/ in dev), and with
glob/-i/output_mode all stripped grep matched anything containing the pattern
across the whole tree — explaining LOBE-8666's dist/main/index.js +
tsconfig.tsbuildinfo leaks.

Also audited the rest of the executor layer:
- listFiles: forward `limit` (was silently dropped → manifest default of 100
  always won).
- getCommandOutput: forward `filter` (was silently dropped → no regex filter
  ever applied to streamed output).
- runCommand: mirror `run_in_background` → `background` so
  ComputerRuntime.RunCommandState.isBackground reflects reality (the IPC
  handler reads run_in_background directly, so the command itself ran in
  background — only the state field was wrong).

Structure: moved src/executor/ → src/client/executor/ to match the other
builtin-tool packages (task / lobe-agent / knowledge-base) and consolidate
renderer-only code under /client. Dropped the `./executor` package subpath;
consumers now import from `…/client`.

Defensive: also added a resolveSearchPath helper in apps/desktop's
contentSearch module that reads params.scope as a fallback for params.path,
so any non-executor caller (direct IPC, future Gateway path) that passes
`scope` still gets routed correctly instead of falling through to
process.cwd().

Regression coverage:
- grepContent full forwarding (LOBE-8666 case + all optional flags)
- listFiles.limit forwarding
- getCommandOutput.filter forwarding
- runCommand.run_in_background → background mirror
- resolveSearchPath fallback semantics (3 cases in base.test.ts)

Verified end-to-end via Electron CDP — tool.invokeBuiltinTool with the
LOBE-8666 params returns 9 clean .ts matches (no dist/, no .tsbuildinfo);
listFiles {limit:3} returns 3 files (totalCount 10); runCommand
{run_in_background:true} reports state.isBackground=true.

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

* 🐛 fix(desktop): readFile fails with `protocol.registerSchemesAsPrivileged should be called before app is ready`

Two-part fix for a regression where reading any text/JSON/source file via the
local-system `readFile` tool surfaced an Electron protocol error in the response
content. The error fired *after* `stat()` succeeded (so missing-file ENOENT was
unaffected), making it look like the file couldn't be parsed.

## Root cause

Stack trace (instrumented `read.ts` to capture it):

```
Error: protocol.registerSchemesAsPrivileged should be called before app is ready
    at new App (apps/desktop/dist/main/index.js:105339:21)
    at Module.<anonymous> (apps/desktop/dist/main/index.js:105615:11)
    at Module._compile (...)
```

`Module._compile` on `dist/main/index.js` means the main bundle is being freshly
evaluated as a CJS module — re-running its top-level `var app = new App(); …;
app.bootstrap();` after the real Electron-launched App was already ready.

Triggering chain: agent calls `readFile` → main runs `loadFile(path)` from
`@lobechat/file-loaders` → `getFileLoader('txt')` → `await import('./text')`.
The lazy text-loader chunk back-references the main bundle for the shared util
`detectUtf16NoBom`:

```js
// dist/main/text-Cbmlmtca.js
const require_index = require("./index.js");      // ← re-evaluates main
…
const variant = require_index.detectUtf16NoBom(buffer);
```

Electron's main entry is not in Node's CJS module cache (it's bootstrapped
separately), so this `require("./index.js")` triggers a fresh compile of the
main bundle — re-running `new App()` and `protocol.registerSchemesAsPrivileged`
*after* `app.whenReady()`, which is illegal per Electron's API contract.

Introduced by #14602 (`fix(local-system): guard readFile against binary blobs
and oversized output`): adding `isBinaryContent.ts` made `detectUtf16NoBom`
shared between the main bundle (via `sniffBinaryFile`) and the lazy text chunk,
so rolldown placed it in main and rewrote the text chunk's call as a
`require_index.detectUtf16NoBom`.

Identical class of bug previously fixed for the `debug` package in #11827.

## Fix

1. **`packages/file-loaders/src/loaders/index.ts`** — TextLoader was lazy-imported
   for no real benefit. It's a 10KB module whose only deps are `node:fs/promises`
   and a tiny utf-16 detect util — nothing like the multi-MB parsers (pdfjs-dist,
   xlsx, mammoth) that the lazy pattern was designed for. Make it a static
   import; `getFileLoader('txt')` returns it synchronously. Result: the text
   chunk disappears entirely, removing this back-reference at the source.

2. **`apps/desktop/electron.vite.config.ts`** — defensive `manualChunks` rules
   so any future shared symbol doesn't recreate the same trap:
   - `vendor-file-loaders-utils` for the three small text/binary detection
     utils (`detectUtf16` / `isBinaryContent` / `isTextReadableFile`).
     Explicitly enumerated to avoid catching `parser-utils.ts`, which pulls
     in xmldom/yauzl/concat-stream (≈900KB) and belongs in the docx/pptx
     chunks instead.
   - `vendor-jszip` for JSZip — same root cause for `.docx` reads: the docx
     chunk had `require_index.require_lib()` (JSZip) back-referencing main.
     Both ends now share the vendor chunk; no main re-eval.

Follows the project precedent set by #11827 for `debug`.

## Verification (live Electron via CDP)

Bundle inventory before/after:

| Chunk | Before | After |
| --- | --- | --- |
| `text-*.js` | 9.7KB (back-refs main) | (gone, inlined into main) |
| `vendor-file-loaders-utils-*.js` | n/a | 18KB |
| `vendor-jszip-*.js` | n/a | 899KB |
| `docx-*.js` back-refs | `require_index.require_lib` | none |

End-to-end via `tool.invokeBuiltinTool('lobe-local-system', 'readFile', …)`:

| File | Before | After |
| --- | --- | --- |
| `.md` / `.json` / `.ts` | `Error accessing or processing file: protocol.registerSchemesAsPrivileged should be called before app is ready` | real file content |

`grep -o 'require_index\\.[a-zA-Z_]*' dist/main/*-*.js | sort -u` → empty.

All 61 file-loaders tests pass; all 64 builtin-tool-local-system tests pass.
2026-05-17 20:26:15 +08:00
Arvin Xu 43b0b5e854 🐛 fix(agent-runtime): honor per-tool timeout end-to-end for client tool dispatch (#14817)
* 🐛 fix(agent-runtime): honor per-tool timeout end-to-end for client tool dispatch (LOBE-8436)

Server BLPOP was hardcoded to 60s and ignored the LLM-supplied `timeout` in
`tool_call.arguments`, so long-running shell commands consistently failed
with a server-side timeout while the desktop runner was still happily
executing. Renderer also never raced its own deadline, leaving it free to
hang past the server budget.

Plumb a per-tool timeout through the full chain:

  - New `resolveToolTimeoutMs` (server) — priority: `args.timeout` >
    `manifest.api[apiName].defaultTimeoutMs` > 120s global default,
    clamped to [1s, 800s] (cloud function ceiling).
  - `dispatchClientTool` accepts `timeoutMs` in ctx; constants moved into
    `resolveToolTimeout.ts`. Default 60→120s, max 270→800s.
  - `RuntimeExecutors` calls the resolver at both client-dispatch sites
    (single + batch) using the LLM-parsed args and the effective manifest.
  - `LobeChatPluginApi` (types + context-engine) gains
    `defaultTimeoutMs?: number` so tool authors declare per-API budgets.
  - `LocalSystemManifest` sets per-API defaults: runCommand 120s,
    read/write/edit/list 30s, grep/glob/search/move 60s, killCommand 10s.
  - `local-file-shell/runner.ts` internal kill cap raised 600→800s to
    match the server ceiling.
  - Renderer `clientToolExecution.ts` rewritten to (1) race executor
    against `executionTimeoutMs - 500ms`, abort the operation's
    AbortController, and send `client_executor_timeout` on overrun;
    (2) read `gatewayConnections[operationId]` live on every send so
    reconnects between dispatch and result are picked up; (3) wrap in
    try/finally with an exactly-once `sent` guard so every `tool_execute`
    yields exactly one `tool_result` even on logic gaps.

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

* 🐛 fix(test): drop unused @ts-expect-error and tighten timeout assertion

CI lint failed on tsgo: an `@ts-expect-error` directive in
`resolveToolTimeout.test.ts` was unused (the field's `unknown` value
type happily accepts a string at compile time), and the
`sendToolResult.mock.calls[0][0]` access in `clientToolExecution.test.ts`
tripped TS2493/TS2532 because vitest typed `calls` as an empty tuple.

Cast the test-only string value through `unknown` for the resolver
defense check; merge the budget assertion into the `toHaveBeenCalledWith`
matcher via `expect.stringContaining('2000ms')` so we never index into
`mock.calls` by hand.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:23:15 +08:00
Arvin Xu 0e46085176 💄 style: share branded inspector between CC MCP and built-in Linear skill (#14884)
*  feat(linear): share branded inspector between CC MCP and built-in Linear skill

The Linear-branded inspector (logomark + action chip + parentId badge) was
only registered against `mcp__claude_ai_Linear__*` tool names emitted by the
CC adapter. LobeHub's own built-in Linear skill calls land with
`identifier='linear'` and bare apiNames (`get_issue`, `save_issue`, …), so
they fell through to the generic Title + JSON inspector despite being the
exact same Linear surface.

Moves the inspector + label utilities out of `builtin-tool-claude-code` into
`packages/builtin-tools/src/linear/` (alongside `github/`) and registers
them twice in the central inspector map: once under `LinearIdentifier =
'linear'` for the built-in skill path, once merged into the CC entry for
the MCP-prefixed wire names. Same component, same look in both cases.

`formatLinearShortLabel` now matches bare apiNames against the known tool
list too, so the collapsed workflow summary reads `Linear · Get issue`
for built-in calls as well — previously only CC got the humanized label.

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

* ♻️ refactor(linear): leave CC's LinearMcp inspector inside CC, only ship the built-in skill side

Walks back the cross-package edits from the previous commit. The CC adapter
keeps its own `LinearMcp.tsx` + `linearMcpLabels.ts` exactly as #14864 left
them — `formatLinearMcpShortLabel` is still exported from
`@lobechat/builtin-tool-claude-code/client/labels` and `toolDisplayNames.ts`
still imports it from there. CC's inspector index continues to spread
`LinearMcpInspectors` into its own map.

The new shared module under `packages/builtin-tools/src/linear/` now only
covers the built-in LobeHub Linear skill path: `LinearIdentifier='linear'`
+ bare apiNames (`get_issue`, `save_issue`, …). The inspector component is
duplicated from CC on purpose — `builtin-tools` already depends on
`builtin-tool-claude-code`, so we can't import the other way without a
circular dep, and the user wants the CC code to stay put.

Drops the `LinearMcpInspectors` re-export and the CC-entry merge in
`inspectors.ts` that the previous commit had introduced.

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

* ♻️ refactor(linear): hoist shared LinearInspector + label utilities into shared-tool-ui

The Linear-branded inspector and its tool-name parsing helpers were
duplicated between `builtin-tool-claude-code/src/client/Inspector/LinearMcp`
(MCP-prefixed wire names) and `builtin-tools/src/linear/` (built-in skill
bare names). The dep graph (`builtin-tools` → `builtin-tool-claude-code` →
`shared-tool-ui`) means CC can't import from `builtin-tools`, so the
previous round kept two copies.

Moves the component + labels into `packages/shared-tool-ui/src/Inspector/
Linear/` — both CC and `builtin-tools` already depend on `shared-tool-ui`,
so they can each pull the same `LinearInspector` and register it under
whichever key shape their code path uses:

- CC's `LinearMcp.tsx` is now a 10-line wrapper that maps the shared
  inspector across every MCP-prefixed name.
- CC's `linearMcpLabels.ts` re-exports the parsing primitives + keeps the
  CC-only `formatLinearMcpShortLabel` (the prefix check stays here so the
  workflow-summary label only fires for MCP-prefixed wire names).
- `builtin-tools/src/linear/` drops its own Inspector / labels files; the
  index just registers the shared component under bare apiNames.

Exposes a labels-only subpath `@lobechat/shared-tool-ui/inspectors/
linear-labels` so the workflow-summary path can pull parsing helpers
without dragging the React inspector (and its `keyframes`-using style
modules) into `Group.test.tsx`'s mocked antd-style context.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:59:27 +08:00
Neko e50e6859e7 ️ perf(agent-signal,prompts): better prompts and explicit rules (#14882) 2026-05-17 17:58:06 +08:00
LobeHub Bot 70097ad315 🌐 chore: translate non-English comments to English in agent-tasks (#14880)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 17:06:56 +08:00
Arvin Xu 929d23a94e feat(cc): task tools + AskUserQuestion freeform note + waitingForHuman topic status (#14870)
*  feat(cc): support TaskCreate / TaskUpdate / TaskList tools (CC 2.1.143+)

Add adapter accumulator, inspectors and Todos panel for CC's imperative
task trio that replaces TodoWrite. TaskUpdate's status flip is surfaced
as a per-call chip ("Completed: Read hosts") and the Todos panel header
mirrors that label, with subject resolved from pluginState by CC-assigned
task id.

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

*  feat(cc): escape-toggle AskUserQuestion + waitingForHuman topic status

AskUserQuestion intervention — mode-exclusive escape hatch:
- Mirror `lobe-user-interaction`'s "Or type directly" toggle: form picks
  and the freeform reply are mutually exclusive, not stacked. Default
  view shows the multi-choice options; clicking "Or type directly"
  swaps the body to a single TextArea, and "Back to options" returns.
- Submit sends either per-question picks OR `{ __freeform__: <text> }`
  (never both). Bridge formatter (`AskUserMcpServer.formatAnswerForCC`)
  forwards the text verbatim to CC when `__freeform__` is the payload,
  bypassing the `User answers:\n- <q>: <a>` framing — keeps the model
  prompt clean when the user opts out of the structured form.
- Draft persistence resumes the user back into escape mode when
  `__freeform__` is non-empty; an empty draft starts in form mode.
  Timeout fallback respects escape mode: non-empty text submits as-is
  rather than being discarded for option-1-of-each defaults.
- Render swaps to a single "user reply" card with the typed text when
  `__freeform__` is present; otherwise renders the Q&A pairs as before.

Topic status `waitingForHuman`:
- Add new enum value to `ChatTopic` status — TS-only widening (the
  drizzle `text({enum})` is not a `pgEnum`, no migration needed) —
  wired through types + zod router schema.
- Sidebar topic row renders a warning-colored Hand icon when an
  intervention is pending so the waiting state reads from the topic list.
- `heterogeneousAgentExecutor` flips status to `waitingForHuman` when
  an AskUser intervention is raised and back to `running` once the
  bridge resolves; `conversationControl.submitHeteroIntervention` also
  flips back to `running` after the user submits / skips / cancels. The
  natural `runtime_end → writeTopicStatus('active')` takes over.

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

* 💄 style(explorer-tree): drop doubled outline on selected file rows

Add `--trees-selected-focused-border-color-override: transparent` to
both ExplorerTree consumers (working-sidebar Files + AgentDocuments).
`@pierre/trees` draws an outline via `::before` on focused+selected
rows that visually fights with the filled `--trees-selected-bg`
highlight — the existing `--trees-border-color-override: transparent`
only controls structural borders, not this focus outline. Keyboard
focus ring on unselected rows stays intact (a11y).

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:06:18 +08:00
Arvin Xu ad75e25443 ♻️ refactor(agent-settings): drop Meta/Documents tabs, restore inputTemplate (#14874)
* ♻️ refactor(agent-settings): drop Meta and Documents tabs

Remove the 助理信息 (Meta) and 文档 (Documents) tabs from the agent
profile/settings UI. Default chat-settings tab falls back to Opening for
non-inbox agents.

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

*  feat(agent-chat): restore inputTemplate field in Chat Preferences

Add back the User Input Preprocessing (inputTemplate) form field that was
removed in 2.0. The pipeline (InputTemplateProcessor, i18n, types) was kept
intact when the UI was dropped — only the form entry is added back.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:15:17 +08:00
YuTengjing 93492382ca 💄 style: shorten usage token details (#14849) 2026-05-16 23:21:54 +08:00
Arvin Xu 4ea80c2915 🐛 fix(gemini): sanitize enum/required from non-compliant types in tool schema (#14740)
* fix(gemini): strip enum from non-STRING types in tool schema

* fix(gemini): handle nullable types and definitions recursion in schema sanitizer

Addresses review feedback on #14740 for LOBE-8661:

1. Preserve nullable string enums (type: ['string', 'null'])
   - Replace strict type equality checks with isStringType/isObjectType
     helpers that handle both single-string and array types.
   - Apply to both sanitizeGeminiSchema and
     convertOpenAISchemaToGoogleSchema.

2. Recurse into definitions/$defs schema maps
   - When a tool schema stores non-compliant enum/required inside
     definitions/$defs and references it with $ref, the walker now
     visits these schema maps as well.

Test coverage: 6 new cases for nullable type preservation and
definitions/$defs recursion.

* 🐛 fix(test): wrap sanitizeGeminiSchema inputs in valid JSON Schema

The 3 cases were passing bare property maps directly to the sanitizer,
which only recurses through `properties`/`items`/combinators/`$defs` —
so the inner `enum`/`required` were never visited and assertions failed.

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

---------

Co-authored-by: Arvin Xu <arvinxx@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:55:02 +08:00
YuTengjing f94f941fe8 💄 style(home): polish brief recommendations layout (#14871) 2026-05-16 20:20:32 +08:00
Arvin Xu fbc42b725e feat(hetero-agent): support Monitor-style signal callbacks in AssistantGroup (#14859)
*  feat(hetero-agent): emit externalSignal on Monitor-callback steps + reader-side SignalCallbacksNode

LOBE-8998 Phase 1 — data-layer work. Adapter detects repeated tool_results
on the same tool_use.id (Monitor stdout pushes etc.) and tags the next
stream_start(newStep) with an externalSignal peer field. Executor stamps
metadata.signal on the new assistant message. conversation-flow
MessageCollector / ContextTreeBuilder collect signal-tagged toolless
assistants into a SignalCallbacksNode appended inside AssistantGroup
children. UI rendering deferred to a follow-up commit.

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

* 🐛 fix(hetero-agent): keep parentId chain alive across toolless middle steps

LOBE-8993: when a CC step produced only text (e.g. Monitor stdout drove
Claude to reply without invoking a tool), the next step's parentId fell
back to the previous assistant. MessageCollector only walks the
assistant → tool → assistant zigzag, so each Monitor stdout line split
into its own bubble.

Carry the most recent tool result_msg_id across step boundaries via a
`lastToolMsgIdEver` tracker so toolless middle steps still chain back to
the originating tool result.

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

*  feat(chat-ui): render SignalCallbacks block inside AssistantGroup for Monitor-style callbacks

Adds the UI layer of LOBE-8998. FlatListBuilder snapshots signal-callback
groups onto the virtual AssistantGroup message via UISignalCallbacksBlock
(new typed field on UIChatMessage) and marks each callback message
processed so it does NOT render as a separate top-level bubble.
AssistantGroup reads the field and renders a collapsible
<SignalCallbacks> component under the main Group content, one block per
source tool.

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

* 🐛 fix(hetero-agent): detect Monitor callbacks via system task lifecycle instead of repeat tool_result

The previous detection model (count repeat tool_result per tool_use.id) was
based on a wrong assumption — Monitor's stdout pushes are NOT delivered as
additional tool_result events for the same tool_use.id. Verified against a
real `claude -p` trace: Monitor emits ONE tool_result (the initial "Monitor
started" ack), then each subsequent stdout line triggers a `system init` +
new `message_start` cycle within the same CLI process. The actual lifecycle
signal is `system task_started` (long-running tool registers) followed by
`system task_notification` (terminal).

New detection: a `message_start` that opens a new turn WITHOUT a preceding
`user` event, while at least one task is active, is a signal callback.
`task_started` records `{task_id → tool_use_id}`; `task_notification` drops it.
Verified against the recorded CC trace: 5/5 reactive turns get tagged with
correct sequence and source tool, the natural confirmation turn and the
post-task summary turn are correctly excluded.

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

*  feat(hetero-agent): keep CC post-task summary in same group + dedicated Monitor inspector (LOBE-8998)

The post-task summary turn (fired after `system task_notification` ends
a long-running tool) was spawning its own AssistantGroup because the
collector only followed the first non-signal toolless sibling under a
tool_result — it never saw the summary that came after the
SignalCallbacks. Adapter now stamps `signal.type = 'task-completion'`
on the summary turn so the collector keeps it inside the same group,
rendered AFTER the SignalCallbacks accordion (initial reply → callbacks
→ summary, in creation order).

Also adds a dedicated `MonitorInspector` (lucide `Monitor` icon, chip
shows description / command, trailing timeout label) so the Monitor
tool call line stops falling back to the generic `claude-code > Monitor`
display, and tightens the Flexbox spacing around SignalCallbacks +
taskCompletions inside the AssistantGroup so the three sections read
as one connected reply rather than disconnected blocks.

Adapter: arm `pendingTaskCompletion` on `task_notification` (last-task-
wins), consume it on the next natural `message_start`, clear on `result`
so it never leaks across LLM runs.

Tests: adapter (74) + executor (56) + conversation-flow (126) all green.
Verified end-to-end in Electron with a 5-tick Monitor run — single
AssistantGroup with the natural narrative inside.

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

* 🐛 fix(conversation-flow): skip signal callbacks when locating the group tail

`findLastNodeInAssistantGroup` blindly took `toolNode.children[0]` when
walking past a tool, so for the common `[signal callback, next tool-using
assistant]` order the tail landed on the callback (a leaf) and
`findNextAfterTools` returned null — truncating the AssistantGroup and
omitting follow-up messages after the real last assistant. Mirror the
signal-skip already used in `collectAssistantGroupMessages` (LOBE-8998).

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:40:57 +08:00
Arvin Xu f94e4f46a4 🐛 fix(task-schedule): enforce maxExecutions cap and block sub-10min heartbeat (#14865)
* 🐛 fix(task-schedule): enforce maxExecutions cap and block sub-10min heartbeat

The "运行次数限制" input on a scheduled task was accepted by the UI and
persisted to `tasks.config.schedule.maxExecutions`, but no execution path
ever read it — scheduleDispatch/scheduleTick/runTask had no counter and
no cap check, so a "stop after N runs" schedule would loop forever.

Separately, the server-side `heartbeatInterval` zod schema was `min(0)`,
and the `setTaskSchedule` tool manifest only said "recommend ≥600s". An
LLM could pass any positive number and trigger sub-minute heartbeats.

Enforcement (no schema migration):

- `TaskService.updateStatus` stamps `context.scheduler.scheduleStartedAt`
  (ISO) when a task transitions into `scheduled` from a non-`running`
  status. The cron loop's natural `running → scheduled` flips happen via
  `taskModel.updateStatus` (taskLifecycle), bypassing the service layer,
  so they don't reset the counter. User-initiated (re)starts do.
- `TaskTopicModel.countByTaskSince(taskId, since)` counts task_topics
  rows created since a timestamp.
- `runScheduleTick` reads `config.schedule.maxExecutions`; if the count
  since `scheduleStartedAt` has reached the cap, it marks the task
  `completed` (so the next dispatch sweep filters it out) and returns a
  new `max-executions-reached` skip reason.

Heartbeat lower bound:

- `updateSchema.heartbeatInterval` on the lambda router now refines to
  `v === 0 || v >= 600`, matching `MIN_MINUTES = 10` in the UI.
- `setTaskSchedule` tool manifest description updated to "Minimum 600s
  … the server rejects positive values below 600" so the LLM sees the
  hard limit before the zod refine bounces the call.

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

* ♻️ refactor(task-topic-model): rename countByTaskSince → countByTask, use drizzle count()

- Make `since` an optional `options` argument so the helper covers total
  counts too, not only the since-window the scheduler needed.
- Swap `sql<number>\`count(*)::int\`` for drizzle's native `count()`
  aggregator.

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

*  test(task-schedule): cover countByTask, scheduleStartedAt stamping, and tick max-exec

- `TaskTopicModel.countByTask`: total-mode, since-window mode, task scope,
  user scope (real DB).
- `TaskService.updateStatus`: stamps `context.scheduler.scheduleStartedAt`
  on user-initiated starts/restarts of a schedule task; does NOT stamp on
  the cron loop's natural `running → scheduled` cycle, on heartbeat-mode
  tasks, or when the new status isn't `scheduled`.
- `runScheduleTick`: cap not configured / under cap → runs; cap reached
  → marks `completed` and skips with `max-executions-reached`; missing
  `scheduleStartedAt` → falls through (backwards-compat for tasks created
  before this PR).

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

* 🐛 fix(task-schedule): complete capped schedules at the final allowed run

The pre-tick cap check in `runScheduleTick` only sees `runCount` *before*
starting the next tick. For low-frequency schedules (e.g. daily,
`maxExecutions=1`), this meant the task would consume its final allowed
run, get parked back at `scheduled` by `TaskLifecycleService.onTopicComplete`,
and then sit in `scheduled` for a full cron period before the next pre-tick
check noticed the cap was already consumed — contradicting the "stop after
N runs" promise.

Move the canonical stop to post-completion:

- New `TaskLifecycleService.scheduleCapReached(task)` helper counts
  `task_topics` rows since `context.scheduler.scheduleStartedAt` and
  compares against `config.schedule.maxExecutions`. Short-circuits when
  the task isn't in schedule mode, no cap is configured, or no
  `scheduleStartedAt` is stamped (pre-PR tasks).
- The default post-tick transition in `onTopicComplete` now routes a
  cap-reached schedule task to `completed` instead of `scheduled`, so
  the UI/API reflect the cap immediately.

The pre-tick check in `runScheduleTick` is kept as defense-in-depth:
covers crashed ticks that never reached `onTopicComplete`, users
editing `maxExecutions` downward past current count, and stale
`scheduled` rows from older code paths. Comment updated to reflect that.

Tests:
- `onTopicComplete`: schedule task under cap → still `scheduled`; at
  cap → `completed`; with no `scheduleStartedAt` (pre-PR) → still
  `scheduled` (helper short-circuits before querying).

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:14:29 +08:00
Arvin Xu 6478c6012f feat(cc): render Linear MCP tool calls with branded inspector (#14864)
*  feat(cc): render Linear MCP tool calls with branded inspector

CC emits Linear MCP tools as `mcp__claude_ai_Linear__<verb>_<noun>` —
the default inspector and the collapsed summary surface those raw names,
which read as `Mcp__claude_ai_ Linear__get_issue` after title-casing.

Adds a generic Linear MCP inspector that:
- Shows the monochrome Linear logomark + "Linear" product prefix
- Renders the action as a single pill split into action / value halves
  (e.g. `Get issue | id: LOBE-8743`)
- Detects `parentId` and surfaces it with a CornerLeftUp icon, either in
  the chip's value half (when parent is the primary arg) or as a secondary
  badge after the chip (mirrors the parent visual used by AgentTask UI)
- Hard-caps chip text at 60 chars so long comment bodies / search queries
  don't push the row off-screen

Also humanizes the collapsed-workflow summary via a `formatLinearMcpShortLabel`
helper exported from `@lobechat/builtin-tool-claude-code/client`, so the
bundle row reads "Linear · Get issue" instead of the raw tool name.

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

*  feat(cc): render WebSearch and WebFetch tool calls with custom inspector

CC's web tools were falling through to the generic tool UI because
`ClaudeCodeApiName` and the render/inspector registries hadn't been
extended. Adds dedicated inspector (query/url chip) and result card
(text for search, markdown for fetched pages) for both.

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

* 🐛 fix(cc): isolate Linear MCP label helper to avoid antd-style mock break

`Group.test.tsx` mocks `antd-style` with only `createStaticStyles`. The
previous wiring imported `formatLinearMcpShortLabel` through the
`@lobechat/builtin-tool-claude-code/client` barrel, which transitively
loads `LinearMcp.tsx` → `@lobechat/shared-tool-ui/styles` → `keyframes`,
crashing the mock.

Splits the pure label utilities (LINEAR_MCP_PREFIX, parseToolName,
staticLabelFor, formatLinearMcpShortLabel, LINEAR_MCP_TOOL_NAMES) into
`linearMcpLabels.ts` with no React/antd-style imports, exposes it as
`@lobechat/builtin-tool-claude-code/client/labels`, and switches the
consumer in `toolDisplayNames.ts` to that subpath. The inspector
component keeps importing the same helpers locally.

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

* 💄 ui(hetero): land manual workflow expand at full level

Heterogeneous agent workflows often run 40+ tool calls. When the user
collapsed the workflow and clicked the header to re-expand, it landed
at the height-capped `semi` state and hid most of the chain. Now we
infer a "fully expanded experience" from `defaultWorkflowExpandLevel`
— any phase opting into `full` routes the manual expand straight to
`full` instead of the legacy `semi` cap.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:41:22 +08:00
Arvin Xu ff259bdc51 🐛 fix(agent-tracing): align DB trace_s3_key with .json.zst suffix (#14860)
🐛 fix(agent-tracing): align DB trace_s3_key with `.json.zst` suffix

PR #14807 switched the S3 object key written by `S3SnapshotStore.save()`
to `.json.zst` but the DB-persistence path in `CompletionLifecycle.ts`
still hardcoded `.json`. Result: every row inserted into
`agent_operations.trace_s3_key` points at a key that does not exist —
the actual object is the `.json.zst` sibling. Any consumer that GETs by
the DB-recorded key (dc tracing UI, agent-tracing inspect via record
lookup) hits 404.

Verified in prod: 87012/87159 populated rows still end in `.json`, 0
end in `.json.zst`, including rows inserted hours after the PR #14807
deploy.

Fix factors out a single `buildFinalSnapshotKey(agentId, topicId, opId)`
helper exported from `@/server/modules/AgentTracing` so both the S3
writer and the DB writer construct the key from the same source, making
this class of drift impossible going forward.

Existing rows need a one-off backfill (run from dc):
  UPDATE agent_operations SET trace_s3_key = trace_s3_key || '.zst'
  WHERE trace_s3_key LIKE '%.json';

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:56:58 +08:00
AmAzing- 7b61b9526f feat: align self-iteration builtin tool with shared runtime and inspector patterns (#14827) 2026-05-16 13:52:08 +08:00
Arvin Xu 8c4fbf4a81 🐛 fix(home): fetch agent config so knowledge toggles reflect in UI (#14834)
* 🐛 fix(home): fetch agent config so knowledge toggles reflect in UI

Home layout didn't subscribe to the agent config SWR key, so
`toggleFile` / `toggleKnowledgeBase` succeeded server-side but the
follow-up `mutate([FETCH_AGENT_CONFIG_KEY, agentId])` had no listener
and `agentMap` was never refreshed — leaving the Library submenu
checkboxes visually frozen on the home page.

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

* ♻️ refactor(home): move agent config fetch into InputArea with loading state

Move `useInitAgentConfig(agentId)` from the home layout into InputArea
so it tracks the resolved home agent id (inbox or AgentSelect override)
and refetches when the selection changes. Disable the send button while
the agent config isn't yet in `agentMap`, matching the loading shape of
the Memory/Search/History actions.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 10:58:03 +08:00
Arvin Xu d91132c155 💄 style(thread): indent subagent rows and drop SUBAGENT badge (#14845)
Restyle subagent thread items in the Topic sidebar:
- Replace `└` TreeDownRightIcon with `↳` CornerDownRight from lucide-react
- Remove right-aligned SUBAGENT Tag badge; the indent + arrow now carry the
  nesting affordance on their own
- Apply `paddingInlineStart: 32` on the NavItem's inner Block so subagent
  rows shift right by ~one icon slot while the row background/highlight
  stays full-width
- Sync agent and group sidebar copies; drop the now-unused
  `chat:thread.subagentBadge` i18n key

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 10:55:45 +08:00
Tsuki b8a03bdc08 🐛 fix(task-schedule): stop SchedulerForm race + drop stale-refresh CLS (#14853)
* 🐛 fix(task-schedule): stop SchedulerForm race + drop stale-refresh CLS

Rapid edits in the schedule form (weekday toggles, frequency/time picks,
timezone changes) fired concurrent PUTs through `updateSchedule` and then
a SWR mutate refresh. The refresh was async and could land after the
user's next click, overwriting their latest input with whatever the
server happened to hold — the same race as setAutomationMode in LOBE-8893.

- Migrate `updateSchedule` to the shared `OptimisticEngine` introduced by
  LOBE-8893. Same `taskDetailMap.<id>` path, so schedule edits serialize
  against each other AND against mode toggles.
- Mirror every server-bound field (config.schedule.maxExecutions JSONB +
  flat schedulePattern/scheduleTimezone columns) into the optimistic
  patch and drop the post-PUT refresh.
- PUT failure now rolls back via inverse patches.
- Remove `#withCoalescedRefresh` + `#pendingWrites` — both unused after
  setAutomationMode and updateSchedule moved to the engine.

Fixes LOBE-8901

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

* 💄 style(task-trigger-tag): ellipsis the inline primary so long patterns don't wrap to two lines

A weekly schedule with many selected days (e.g. "每周 日/四/六 09:00 运行")
overflowed the 200px properties widget width and wrapped to two lines, so
adding/removing weekdays shifted the rows above and below. Truncate with
ellipsis instead — the full text + timezone is still visible on hover via
the existing tooltip.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 02:07:26 +08:00
Tsuki 8385a7c447 🐛 fix(editor): stop showing per-line placeholder once the editor has content (#14852)
LOBE-8924: TaskInstruction (and every other EditorCanvas consumer that doesn't
pass `lineEmptyPlaceholder` itself) was forwarding the same string into both
`placeholder` and `lineEmptyPlaceholder`. The latter renders the hint on every
empty block, so as soon as the user typed something and moved to a new line,
"Add task instruction…" reappeared inline next to the cursor. Drop the
`lineEmptyPlaceholder` pass-through so the hint only shows when the whole
editor is empty; callers that genuinely want per-line hints
(`SkillEditForm`, `agent/profile/EditorCanvas`, `CreatePlan`) already pass it
directly to `<Editor>`.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 02:07:12 +08:00
Tsuki c814c566d4 🐛 fix(chat): respect useCmdEnterToSend preference in thread & task inputs (#14850)
Thread feedback and task comment inputs hardcoded Cmd/Ctrl+Enter to send,
ignoring the user's "Use Cmd+Enter to send" preference and diverging from
the main chat input. Extract a shared useEnterToSend hook and apply it to
all chat-like inputs so behavior stays consistent.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 02:06:57 +08:00
Tsuki 5e03311d21 💄 style(agent-tasks): align Add Subtask button & card baseline (#14848)
💄 style(agent-tasks): align Add Subtask button with card content

Fixes LOBE-8904

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 02:06:38 +08:00
Tsuki 03f99bfeeb 💄 style(chat-input): equalize action bar padding around send button (#14846)
* 💄 style(chat-input): equalize action bar padding around send button

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

* 💄 style(task-feedback): equalize commentInputCard padding around send button

The asymmetry the issue called out lives on the TopicChatDrawer
FeedbackInput card, not the main DesktopChatInput action bar. Revert
the earlier DesktopChatInput tweak and align top/bottom/right padding
on commentInputCard instead.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 01:27:40 +08:00
Tsuki 224079b420 🐛 fix(agent-tasks): enable send button after pasting into thread/comment input (#14816)
The Editor's `onTextChange` ignores the first content-change event after listener
registration (uses a `previousContent` baseline). Because the parent re-creates
the callback ref on every render, the listener re-registers and that gate fires
on every paste — leaving `hasContent` false and the send button disabled until
the user types something.

Switch to `onChange` (which fires unconditionally), and use `editor.isEmpty` so
each fire stays O(1) despite the higher invocation rate.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 01:27:06 +08:00
Tsuki 081a0886aa 🐛 fix: preserve TopicChatDrawer state during close animation (#14803)
Wrap title, extra and body of TopicChatDrawer in `Freeze` so the drawer
keeps its last rendered content while it animates closed, instead of
flashing to the empty/"untitled" view as `topicId` and `agentId` clear.

Fixes LOBE-8900

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 01:26:47 +08:00
Tsuki d9eba30519 🐛 fix(task-schedule): stop UI flip-flop on rapid automation-mode toggles (#14801)
Rapid Segmented clicks (schedule ↔ heartbeat) used to leave the popover trigger
row flickering and the task properties widget vertically shifting.

- TaskTriggerTag inline mode now always renders a single row; timezone moves
  to the hover tooltip so the row height is stable regardless of mode.
- setAutomationMode goes through OptimisticEngine: per-task path conflicts
  serialize concurrent toggles so PUTs land in click order, and a failure
  triggers an inverse-patch rollback instead of a manual save/restore.
- Mirror every server-bound field into the optimistic patch and drop the
  post-PUT SWR refresh — the async refresh could land after the user's next
  click and overwrite their latest state.

Fixes LOBE-8893

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 01:26:28 +08:00
Rdmclin2 a47d29b0bb 🐛 fix: bot channels (#14847)
* feat: support app home welcome messger

* feat: support welcome message in bot channels

* fix: /start commands ephemeral

* chore: fix User Block trigger style

* chore: add bot channel docs

* feat: support thread participants count

* feat: bot channel support participants count
2026-05-15 22:32:40 +07:00
Innei 3864a1eaab 🐛 fix(onboarding): gate discovery progress by phase (#14842) 2026-05-15 22:23:21 +08:00
Arvin Xu 8ca3f9a372 🐛 fix(agent-runtime): forward tools into compression budget on call_llm (#14837)
* 🐛 fix(agent-runtime): forward tools into compression budget on call_llm

Tool definition tokens were already counted by `countContextTokens`, but
`GeneralChatAgent` never passed `tools` into `compressionOptions`, so a
large tool manifest (16-22K tokens observed on openrouter `:free`
variants) could push the request past the model's context window
without ever tripping the compression threshold.

Forward `state.tools` (init/user_input) and `payload.tools` (toLLMCall)
into `shouldCompress`. Fixes LOBE-8973 Bug B.

* 🐛 fix(agent-runtime): skip tool budget on force-finish continuations

When state.forceFinish is set, RuntimeExecutors.callLlm strips every tool
via buildStepToolDelta (deactivatedToolIds: ['*']) before the model call.
The compression check must mirror that stripping — otherwise the operation's
tool schemas push the budget over threshold and the runner returns
compress_context, spending an extra summarization pass on tokens that won't
be sent.

Threads state.forceFinish through the compression budget at both the
init/user_input and the toLLMCall paths.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 20:50:58 +08:00
LiJian a2d91b205e feat(cc): show cloud credentials alert and disable input when not configured (#14822)
When a heterogeneous agent (Claude Code) is opened in the browser (cloud/web
mode) and the CLAUDE_CODE_CRED_KEY env is not yet configured, the chat input
is now disabled and a warning banner is shown with a direct link to the agent
profile page so the user can set up their token.

- Add useHeteroAgentCloudConfig hook (business slot) that checks isDesktop,
  heterogeneousProvider, and env.CLAUDE_CODE_CRED_KEY
- Guard handleSendButton in ChatInput store to respect sendButtonProps.disabled
  (blocks Enter-key send when button is externally disabled)
- Render Alert banner + pass disabled:true to sendButtonProps in
  HeterogeneousChatInput when credentials are missing
- Add i18n keys: heteroAgent.cloudNotConfigured.{title,desc,action}

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 20:45:10 +08:00
Innei a35c55c57b 🐛 fix(onboarding): remind discovery turn progress (#14833) 2026-05-15 20:28:33 +08:00
Arvin Xu 625cf80b84 🐛 fix(model-runtime): fail-fast pre-flight context check for OpenAI-compatible providers (#14824)
* 🐛 fix(model-runtime): fail-fast pre-flight context check for OpenAI-compatible providers

LOBE-8291 added `resolveSafeMaxTokens` + `MaxTokensExceededError` but only
wired them into MiniMax. NVIDIA and DeepSeek hosts continued to round-trip
doomed requests to upstream just to get a 400 back ("requested 0 output
tokens and your prompt contains at least N+1 input tokens"). LOBE-8974
captures the variants still hitting users — including 5 consecutive
failures from a single user retrying across deepseek-v4-{flash,pro}.

This change:

- Promotes the pre-flight check to `openaiCompatibleFactory` via a new
  `chatCompletion.contextPreFlight` option. When set, the factory runs
  `assertContextWithinWindow` against the provider's model list before
  invoking `handlePayload`, and surfaces a structured
  `ExceededContextWindow` error so the UI can offer fork / switch-model
  affordances instead of a raw provider 400.
- Renames `MaxTokensExceededError` to `ContextExceededPreFlightError` and
  reshapes its payload to match the LOBE-8974 spec: `{ type, promptTokens,
  ctx, model, shortBy, suggestions }`. The factory intercepts the error
  centrally so providers no longer need their own `handleError` for this.
- Wires NVIDIA and DeepSeek (OpenAI path) to opt in. MiniMax keeps using
  `resolveSafeMaxTokens` for `max_tokens` capping; its bespoke
  `handleError` is removed since the factory handles it now.

Out of scope (tracked in LOBE-8974): compression-failure metrics for the
4b "input genuinely overflows 1M" cases, repeated-ECW UX guidance to fork
the topic, and DeepSeek's Anthropic-compatible path (which lives behind a
separate factory).

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

* 🐛 fix(model-runtime): pre-flight should reject only on real context overflow

The previous `assertContextWithinWindow` reused `resolveSafeMaxTokens`'s
strict thresholds — subtracting a 1024-token buffer and then requiring
another 1024 tokens of completion headroom. That made sense for MiniMax
(which caps `max_tokens` itself and needs room left for output) but
wrong for NVIDIA / DeepSeek where the harness does not pick `max_tokens`
and the upstream chooses its own default. A 198.5k-token prompt against
a 200k-token window would be rejected pre-flight with a negative
`shortBy` even though the upstream would happily serve it.

Pre-flight-only providers now reject only when the estimated prompt
strictly exceeds the model context window. `AssertContextWithinWindowOptions`
exposes a `safetyMarginTokens` knob for callers that want to absorb
estimator drift, defaulting to 0. The error class makes `minOutputTokens`
optional and only includes it in the structured payload when the
max_tokens-capping path populated it.

Adds regression tests for the near-limit case at both the helper level
and through the factory wiring.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:54:27 +08:00
Arvin Xu d02df7b897 🐛 fix(hetero-agent): drop ALL subagent-tagged events from main gateway handler (#14838)
The forwarding guard only filtered `stream_chunk` events. `tool_start` and
`tool_end` for subagent inner tools still reached the main handler, where
`tool_end` fired a `fetchAndReplaceMessages(main)` on every subagent inner
tool result — wasted work AND a state-drift window that surfaced as the
"orphan tool call" banner on the spawn's bubble even after DB had settled.

`tool_start(subagent)` was also leaking `dispatchOnBeforeCall` invocations
against the main context for what is actually a subagent inner tool, firing
renderer onBeforeCall hooks in the wrong scope.

Broadens the guard to drop ALL events with `event.data.subagent`. Safe
because:
- `tool_result(subagent)` is already handled inline at executor:1407 with
  an early `return`.
- `stream_chunk(subagent)` is routed through `persistSubagent*Chunk` into
  the per-spawn thread scope; the subagent's own in-thread renderer state
  is streamed via the thread-scoped dispatcher introduced in #14024.
- `tool_start` / `tool_end` are pure renderer-notification hooks; the
  subagent has no business firing them on the main bucket.

Regression test asserts:
- No forwarded event with `event.data.subagent` reaches the handler.
- Main's own `tool_start` / `tool_end` (no subagent flag) still reach
  the handler so the main bubble's animation + onAfterCall hooks fire.

Closes LOBE-8991.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:47:59 +08:00
Arvin Xu 19b11f05be 💄 i18n(chat): rename Agent mode label in zh-CN (#14835)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:48:36 +08:00
YuTengjing 59d2915bf9 🐛 fix: serialize file storage upload checks (#14829) 2026-05-15 17:28:56 +08:00
YuSaZh 17506e30ee 🐛 fix(desktop): resolve Windows npm CLI shims before spawning agents (#14772)
* 🐛 fix(desktop): resolve Windows CLI shims before spawning agents

* 🐛 fix(desktop): support Windows node-backed CLI shims

* 🐛 fix(desktop): resolve npm cmd node shims on Windows

* 🐛 fix(desktop): avoid async spawn wrapper for CLI agents
2026-05-15 17:24:43 +08:00
LiJian 1a48642a2d 🐛 fix(agent-profile): include hidden builtin tools in system prompt @-mention list (#14823)
* 🐛 fix(agent-profile): include hidden builtin tools in system prompt @-mention list

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

* 🐛 fix(agent-profile): use discoverableMetaList for system prompt @-mention

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 17:05:43 +08:00
Arvin Xu 205b9de5c6 🐛 fix(agent-tracing): restore legacy .json fallback when fetching remote snapshots (#14826)
🐛 fix(agent-tracing): restore legacy .json fallback in RemoteSnapshotStore.fetch

After #14807, `buildRemoteUrl` always targets `.json.zst` and
`RemoteSnapshotStore.fetch` throws on any non-OK response. Because the
S3 rollout only compresses new uploads — pre-rollout final snapshots
remain at the legacy `.json` key — every pre-rollout operation ID would
404 through the CLI/viewer.

Mirror the fallback that `S3SnapshotStore.loadPartial` already uses:
try `.json.zst` first, fall back to the sibling `.json` on non-OK, and
sniff the zstd frame magic (0x28b52ffd) on the body so decoding is
content-driven rather than suffix-driven.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:51:41 +08:00
YuTengjing 20a631a637 💄 style(subscription): update credit top-up copy (#14821) 2026-05-15 16:34:47 +08:00
Arvin Xu ba6980ffe9 🐛 fix(minimax): derive max_tokens from context window to avoid ExceededContextWindow (#14814)
* 🐛 fix(minimax): derive max_tokens from context window to avoid ExceededContextWindow

MiniMax API enforces `input_tokens + max_tokens <= context_window`. The
provider was passing the model's full `maxOutput` as `max_tokens`, which
overflowed the context window as soon as a few large tool definitions or
system prompts were attached and made the very first user message fail
with "context window exceeds limit".

Add `resolveSafeMaxTokens` utility that estimates input tokens from the
payload (messages + tools), caps `max_tokens` at
`min(maxOutput, contextWindow - estimatedInput - buffer)`, and throws a
typed `MaxTokensExceededError` when no headroom remains. The MiniMax
provider now wires this into `handlePayload` and surfaces the error as
`ExceededContextWindow` via a `handleError` callback so it short-circuits
before the doomed upstream call.

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

* 🐛 fix(minimax): estimate max_tokens against sanitized messages

handlePayload strips signed reasoning (and reasoning-without-content)
from assistant messages before sending to MiniMax, but the previous
resolveSafeMaxTokens call was still measuring the original payload.
For chats with long historical reasoning traces this overcounted the
input — capping max_tokens unnecessarily, or even raising
MaxTokensExceededError when the request would actually fit.

Pass the same processedMessages we send so the estimate matches the
wire payload.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:47:30 +08:00
Innei 55b4842f00 🐛 fix(chat-input): allow submenu to close on sibling-open and focus-out in ActionDropdown (#14802) 2026-05-15 13:47:26 +08:00
Arvin Xu 6e6970f1b2 🐛 fix(context-engine): account for tool_calls + reasoning + tool defs in compression budget (#14813)
🐛 fix(context-engine): account for tool_calls + reasoning + tool defs in compression budget

The pre-compression token check (`shouldCompress`) only counted `msg.content`,
which under-counted typical agent conversations by ~58% — tool_calls (~33%
of payload), reasoning traces (~17%), and top-level tool definitions (~2%)
were all silently ignored. As a result, conversations that the provider
tokenizer measured at ~656K passed the harness's 524K threshold without
firing compression, and were rejected upstream as ExceededContextWindow.

Verified empirically against 2 op snapshots in the same topic that hit
the failure mode (LOBE-8964): harness counted 267K, deepseek measured
649K — a 380K (58.8%) gap. ~92% of that gap is fixable by accounting
for the missing fields; the remaining ~8% is `tokenx` vs provider
tokenizer drift, compensated by a 1.25× multiplier on the trigger path.

Changes:

- New `@lobechat/context-engine/tokenAccounting` module exporting
  `countContextTokens({messages, tools, options})`. Returns structured
  per-source + per-message + per-tool breakdown — usable both by the
  compression trigger and by UI panels showing "context by type".
- `shouldCompress` in agent-runtime delegates to `countContextTokens`,
  applies the 1.25× drift multiplier on `adjustedTotal` for the trigger
  decision, exposes raw count via `currentTokenCount`. Signature now
  takes `UIChatMessage[]` directly.
- Removed deprecated `calculateMessageTokens` / `estimateTokens` /
  `TokenCountMessage` from agent-runtime — the new module supersedes
  them. `createAgentExecutors.ts` updated to call `countContextTokens`
  directly for post-compression telemetry.
- Added `raw-md` plugin to agent-runtime vitest config (needed once
  context-engine is imported transitively, since the import graph pulls
  in `@lobechat/agent-templates` `.md` files).

What's intentionally NOT counted (DB-only fields not sent to provider):
`plugin`, `pluginState`, `chunksList`, `extra`, `fileList`, etc.
Counting these would over-estimate and trigger compression too early.

Tests:

- 19 new unit tests for `countContextTokens` covering content / tool_calls
  / reasoning / tool_call_id / tool definitions / fast-path / aggregation
  / DB-only field exclusion.
- `tokenCounter.test.ts` updated for new drift semantics + UIChatMessage
  signature; one boundary case now triggers compression (intentional —
  the drift multiplier kicks in at the threshold).

Refs: LOBE-8964 (ECW edge boundary), LOBE-8972 (ECW umbrella),
LOBE-8973 (openrouter `:free` ctx), LOBE-8976 (compression diagnostics).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:22:19 +08:00
Arvin Xu da7e18281d feat(builtin-tool): add onBeforeCall / onAfterCall lifecycle hooks (#14719)
*  feat(builtin-tool): add onBeforeCall / onAfterCall lifecycle hooks

Tools that mutate state surfaced in the renderer (e.g. lobe-task) need a
way to invalidate UI caches after their own writes — but when the tool
runs server-side via a registered server runtime, the renderer never sees
the mutation and SWR caches go stale (e.g. delete-all-tasks succeeds on
the server but the kanban keeps showing the deleted rows).

Adds optional `onBeforeCall` / `onAfterCall` to `IBuiltinToolExecutor`,
both taking a single `ToolHookContext` object so the surface stays
non-breaking as we add fields. The gateway event handler dispatches them
on `tool_start` / `tool_end` regardless of whether the tool actually ran
client- or server-side.

`TaskExecutor` implements `onAfterCall` to refresh the task list / detail
SWR caches for write APIs. Also fills the missing `setTaskSchedule`
implementation in the server runtime so cloud-mode users can actually
configure schedules through the agent.

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

* 💄 style(tasks): widen empty-tasks hero to 960px

Aligns with the default `CONVERSATION_MIN_WIDTH` used elsewhere; the
720px cap was leaving the recommended-template grid feeling cramped on
wider monitors.

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

* 🐛 fix(builtin-tool-task): refresh parent task detail after subtask mutation

Deleting a subtask through the agent left the parent's detail view
showing the stale child until a manual page reload — `onAfterCall` was
only invalidating the mutated task's own detail key, never the parent
whose `subtasks[]` array embeds it.

Adopt the same multi-target pattern that `updateTask` already uses in
the detail slice: walk `taskDetailMap` via `findSubtaskParentId` to
locate the embedding parent, and also refresh `activeTaskId`
defensively (covers e.g. `createTask` whose new identifier isn't yet in
the local map but whose parent the user is viewing).

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

* 🐛 fix(builtin-tool): unwrap nested tool_end payload before dispatching hook

Real gateway `tool_end` events ship `data.payload` as the
`{ parentMessageId, toolCalling }` wrapper (see both publish sites in
`src/server/modules/AgentRuntime/RuntimeExecutors.ts`), but
`dispatchOnAfterCall` was passing that wrapper straight into
`readToolPayload`, which expects `identifier` / `apiName` at the top
level. Result: identity always undefined for server-runtime tool
completions, `onAfterCall` never fires, and the task cache invalidation
from the previous commit was effectively dead code.

Add `unwrapToolPayload` that prefers `payload.toolCalling` when present
and falls back to the flat shape, plus three regression tests covering
the wrapper, flat, and malformed cases.

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

* ♻️ refactor(builtin-tool-task): colocate executor under client subpath

Aligns with the knowledge-base / lobe-agent precedent: drop the standalone
`./executor` subpath and re-export `taskExecutor` from `./client`.

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

* 🐛 fix(builtin-tool): lazy-load executor registry to break import cycle

`gatewayEventHandler.ts` statically imported `getExecutor`, which transitively
pulled in tool client barrels (e.g. `@lobechat/builtin-tool-lobe-agent/client`
→ `PlanCard.tsx` → `@/store/chat`). Loading `gateway.ts` in isolation (as
the gateway.test.ts suite does) thus reached the chat-store module while
`gateway.ts` was still mid-evaluation, and the eager `useChatStore()` call
hit `new GatewayActionImpl(...)` before the class binding was initialized.

Dynamic-importing `getExecutor` inside the two async dispatch functions
breaks the cycle at module load; runtime behavior is unchanged.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 12:50:00 +08:00
Arvin Xu 7083ab4ef5 🐛 fix(conversation): restore HTML preview for AssistantGroup messages (#14811)
PR #14703 wired @lobehub/ui's `enableHtmlPreview` into the Assistant
useMarkdown but missed the AssistantGroup path, so any full HTML
document the LLM emits in a grouped step rendered as a plain code
block instead of an iframe preview.

Extract the shared markdown wiring (components, plugins, animated,
HtmlPreviewDrawer) into useChatMarkdown so both paths use the same
configuration and the next markdown feature won't drift between them.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 12:29:21 +08:00
Arvin Xu 3dae46911b ️ perf(agent-tracing): zstd-compress S3 snapshots (#14807)
* ️ perf(agent-tracing): zstd-compress S3 snapshots

Compress operation snapshots with zstd (level 3) before uploading to S3
and write them under a `.json.zst` key. Measured on 76839 production
snapshots: 217 GB → 25.8 GB (8.4× average ratio, p99 47×). New uploads
only; old `.json` objects are left as-is.

The `.zst` suffix is the format indicator; Content-Encoding is
intentionally omitted so the object is served as opaque bytes and
readers decompress explicitly (avoids surprise behavior from HTTP
clients that negotiate zstd).

Uses Node's built-in zstd (node:zlib, available since Node 22.15) so
no new runtime dependency is added.

Reader updates:
- RemoteSnapshotStore.fetch decompresses the downloaded payload;
  local cache stays as plain `.json` for easy inspection.
- buildRemoteUrl now points at `.json.zst`.
- S3SnapshotStore.loadPartial falls back to the legacy `.json` key so
  in-flight QStash operations spanning the deploy keep working; the
  fallback dies off naturally once partials finalize.
- removePartial deletes both keys for clean transition.

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

* 🔒 chore(agent-tracing): gate zstd compression on NODE_ENV=production

Local dev (including ENABLE_AGENT_S3_TRACING=1 for S3 testing) keeps
writing plain `.json` so devs can inspect bucket payloads directly.
Only production deployments (NODE_ENV=production) compress + use the
`.json.zst` suffix.

Readers no longer assume the URL suffix matches the body format —
they sniff the zstd frame magic (0x28b52ffd) and decode accordingly.
This way prod-written `.json.zst` and dev-written `.json` round-trip
through the same code path regardless of which environment reads.

S3SnapshotStore.loadPartial tries the active suffix first then the
sibling format; removePartial cleans up both. RemoteSnapshotStore.fetch
falls back from `.json.zst` to plain `.json` on 404 so dev-uploaded
snapshots stay inspectable from another machine via the CLI.

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

* Revert "🔒 chore(agent-tracing): gate zstd compression on NODE_ENV=production"

This reverts commit 70d0b3d857.

*  test(agent-tracing): cover S3SnapshotStore zstd round-trip + legacy fallback

9 vitest cases mocking FileS3:
- save() → key ends in .json.zst, body starts with zstd magic, decompresses to original snapshot
- save() → falls back to "unknown" for missing agentId / topicId
- savePartial() → writes to _partial/ with zstd body
- loadPartial() → decodes .json.zst happy path
- loadPartial() → falls back to legacy .json on miss
- loadPartial() → returns null when neither key exists
- removePartial() → deletes both .json.zst and .json
- removePartial() → swallows individual delete failures (allSettled)
- get/getLatest/list/listPartials → return null/[] (OTEL owns querying)

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:40:30 +08:00
Arvin Xu 36d0994ec2 🐛 fix(context-engine): attach diagnostic context to PlaceholderVariablesProcessor errors (#14741)
* fix: attach diagnostic context to ProcessorError/PipelineError

* fix: include cause summary in PipelineError message

* fix: pass structured cause to ProcessorError

* fix: enhance PlaceholderVariablesProcessor with diagnostic context

* 🐛 fix: preserve placeholderVariablesProcessed count for no-op messages

processMessagePlaceholdersWithDiagnostics always returns a spread {...message},
so the identity check `processed !== message` was always true and the count
incremented even when content was unchanged (e.g. messages with no placeholders
or only unresolved `{{missing}}` tokens). Restore the JSON-equality comparison
used by the pre-PR `processMessagePlaceholders` path.

Add regression coverage for the no-op cases and for new error paths:
- only-unresolved string content, only-unresolved array text parts, mixed batch
- per-message isolation when a generator throws
- defensive validation when variableGenerators is undefined / null

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:26:19 +08:00
Arvin Xu 516c04797d 🐛 fix(hetero-agent): defer fetch-triggering events to avoid parallel tool count rollback (#14806)
🐛 fix(hetero-agent): defer fetch-triggering events through persistQueue to avoid parallel tools[] rollback

When CC fires a large parallel tool batch, the gateway handler's
fetchAndReplaceMessages (triggered synchronously by tool_end) reads a
partial assistant.tools[] while persistToolBatch Phase 1/3 writes are
still queued, and replaceMessages clobbers the in-memory cumulative
tools[] — causing the "7 → 6 次技能调用" rollback users see in the
AssistantGroup count.

Defers tool_end / step_complete:execution_complete / stream_chunk with
toolMessageIds through persistQueue so the handler observes
DB state only after pending writes commit. Text / reasoning / regular
tools_calling forwards stay synchronous to preserve streaming UX.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:53:41 +08:00
LobeHub Bot f3cf7f4aed 🤖 style: update i18n (#14449) 2026-05-15 09:34:48 +08:00
Arvin Xu df8111aca0 🐛 fix(build): pin vite to 8.0.12 to avoid rolldown 1.0.1 preload regression (#14804)
Vite 8.0.13 bumps rolldown to 1.0.1, which ships a new
chunk-optimization dedupe pass (rolldown #9305) with an unsound
sibling-dynamic-entry handling — see rolldown #9350 (open). This
causes preload-deps entries (m.f in __vite__mapDeps) to be dropped,
leaving null slots; at runtime any dynamic import that hits the
shrunken table fires import(null) and throws "Failed to resolve
module specifier 'null'", taking down every tRPC call that flows
through src/libs/trpc/client/lambda.ts headers (await import('@/services/_auth')).

Because the repo runs with lockfile=false + resolution-mode=highest,
^8.0.9 silently floats to 8.0.13 on every fresh Vercel build. Pin
exactly to 8.0.12 (which uses rolldown 1.0.0) until rolldown 1.0.2 /
Vite 8.0.14 lands a fix.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 02:20:50 +08:00
Rdmclin2 566b261a12 feat: support bot watch (#14796)
* feat: add whatsAPP and iMessage comming soon

* chore: update i18n

* feat: support watch keyword instruction

* feat: add cli and messager api for bot channels

* fix: test cases

* feat: add system prompt for messenger tool

* feat: add messenger mdx
2026-05-15 00:36:40 +07:00
Innei e00c299d1c 🐛 fix(onboarding): resolve agent route loading stall and branch redirect (#14795)
* 🐛 fix(onboarding): refresh branch config before redirect

* 🐛 fix(onboarding): refresh agent route flag before branch guard

* 🐛 fix(onboarding): simplify agent branch guard

* 🐛 fix(onboarding): eliminate agent route loading stall

- Make AgentModel.getBuiltinAgent idempotent under concurrent callers.
  The web-onboarding builtin agent was inserted by both the bootstrap
  query and the standalone useInitBuiltinAgent SWR in parallel; the
  insert loser hit agents_slug_user_id_unique and SWR sat in its ~5s
  error-retry window before the row could be read.
- Prefetch /onboarding/agent and /onboarding/classic chunks while the
  shared-prefix steps are visible, so the branch redirect no longer
  pays a cold chunk load.

* 🐛 fix(onboarding): skip prefetch under test and complete fixture

- Add `__TEST__` Vite define so renderer code can branch on Vitest runs
  (set true in vitest.config.mts, false in sharedRendererDefine).
- Guard the shared-prefix chunk prefetch with `if (__TEST__) return`.
  Otherwise the fire-and-forget `import('@/routes/onboarding/agent')`
  resolves after the test asserts and tries to load builtin-agents,
  which the test's partial `vi.mock('@lobechat/const')` doesn't supply
  (`DEFAULT_MODEL` missing), surfacing as 25 unhandled rejections.
- Fix `extract.runtime.test.ts` fixture to include the new required
  `agentBenchmarkLoCoMo` field on `MemoryExtractionPrivateConfig`,
  added in 20267fc77c.
2026-05-15 01:19:37 +08:00
Arvin Xu e0d20e86fc feat: support chat mode and redesign chat input action bar (#14774)
* Refine chat parameter controls and working sidebar

* 💄 style: refine chat parameter controls

* 💄 style: refine chat input action affordances

* 💄 style: refine chat input control menus

* 💄 style: refine chat input skills menu

* 🐛 fix: replace skills policy dropdown with popover

* fix: base-ui dropdown

* fix: base-ui dropdown

* 💄 style: fix popover conflict and refine skills menu layout

- Extract PopoverLabel component with controlled open state to prevent
  conflict when skill policy menu opens
- Dispatch custom close event so detail popovers close before policy popover opens
- Add divider between pinned and auto skill groups
- Refine sticky search/footer padding via CSS attribute selectors
- Remove stray console.log from ActionDropdown

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

* 💄 style: refine skills policy menu and chat input UI

- Skills policy menu: change active icon color to blue, add divider +
  uninstall action for Klavis/MCP/agent-skill items, suppress detail
  popover when the "..." policy menu is open
- Minor refinements across ChatInput, Conversation Error/ContentLoading,
  and HeterogeneousAgent StatusGuide components

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

*  feat: add custom MCP tag and configure action to skills menu

- Show orange "Custom" tag next to custom MCP plugin entries
- Add Configure action above Uninstall in the policy popover that
  opens the PluginDevModal drawer for editing the custom plugin

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

*  feat: default agent mode to true and gate chat mode at the tools engine

- Move `enableAgentMode` from `LobeAgentConfig` to `LobeAgentChatConfig` so it
  persists via the existing `chat_config` jsonb column and is readable on the
  server (the top-level field was silently dropped by drizzle).
- Default to agent mode for all agents — selectors treat `undefined` as `true`;
  only an explicit `false` collapses to chat mode.
- Introduce `chatModeAllowedToolIds = [knowledge-base, memory, web-browsing]`.
  Both `createServerAgentToolsEngine` and the frontend `createAgentToolsEngine`
  now switch on this whitelist in chat mode: skip user plugins, skip
  `alwaysOnToolIds`, narrow `defaultToolIds`, and turn off
  `allowExplicitActivation` so the activator can't smuggle other tools in.
- `useToggleAgentMode` is the single mode-switch entry; `plugins[]` is left
  alone — chat mode is enforced at runtime, not by mutating saved config.

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

*  feat: extend topic status with running/paused/failed

Widen `ChatTopicStatus` enum (DB schema, types, TRPC validation) to cover the
in-flight lifecycle that gateway and heterogeneous executor runs report. Add a
`updateTopicStatus` store action and have both runtime paths write `running`
on start and `active` on completion (or `failed` on terminal error). Sidebar
topic items render a spinner while `status === 'running'`.

Note: drizzle migration for the widened enum needs to be generated separately.

* 💄 style: polish skills menu — official tag, tooltip on settings button

Add a LobeHub "official" badge to builtin tools and agent skills surfaced in
the Skills menu. Wrap the menu's settings button in a Tooltip. Scope the
group-header padding reset to the skill-activation group only so the
Knowledge submenu keeps its native section padding.

*  feat: mark topic as paused while awaiting human tool approval

Extend the heterogeneous-agent topic status machine (c0170d032f) with a
paused state. The gateway event handler writes topic.status = 'paused' on
step_start { phase: 'human_approval' } — one hook covers both Gateway and
desktop heterogeneous paths since they share the same handler.

Resume back to 'running' is free: approve / reject_continue both spawn a
fresh op via the executor entries, which already persist 'running'.

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

*  feat: gate skills and agent-document injectors at the context engine in chat mode

Thread `enableAgentMode` into `MessagesEngine`. When it is explicitly `false`,
the engine forces `enabled: false` on:
- SkillContextProvider — drops the <available_skills> block
- All AgentDocument injectors (BeforeSystem / SystemAppend / SystemReplace /
  Context / Message) — drops every agent-document position

The frontend (`src/services/chat/mecha/contextEngineering.ts`) and server
(`src/server/modules/AgentRuntime/RuntimeExecutors.ts` →
`serverMessagesEngine`) read `chatConfig.enableAgentMode` from agent config
and pass it through; no caller needs to know which injectors to skip.

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

*  feat: also gate agent-management context in chat mode

`agentManagementContext` (the `<current_agent>` + `<available_agents>` block)
was leaking into chat-mode prompts whenever the agent was in auto-skill mode,
because its caller-side guard (`isInAutoSkillMode || isAgentManagementEnabled`)
is orthogonal to `enableAgentMode`. Fold the gate into the same `isAgentMode`
switch already covering skills + agent documents in `MessagesEngine` so the
injector goes off in chat mode regardless of how the caller populates the
context.

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

* 🐛 fix: drop orphan rebase marker in OperationTraceRecorder

Leftover `<<<<<<< HEAD` from an earlier rebase that was only half cleaned —
the HEAD-side content is the one we want; just delete the marker line so the
file type-checks again.

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

* 💄 style: cursor-style action bar on home input

Rework the home ChatInput footer to read like Cursor's composer while keeping
the model picker on the right:

- Replace the `agentMode` icon-only button with a pill trigger (icon + label
  + chevron) carrying a persistent fill, dropping a `bottomLeft` mode
  popover. Reuses the `RuntimeConfig/ModeSelector` design in place so any
  other action bar consumer picks it up automatically.
- Introduce a `modelLabel` action that shows the resolved model display name
  + chevron, opening `ModelSwitchPanel`. The original `model` icon stays
  untouched for callers that prefer the compact form.
- Wire the home input to use ['agentMode','plus'] on the left and
  ['modelLabel'] on the right; bump `SendArea` gap to 12 and add
  `paddingLeft={6}` to the action bar so the pill aligns with the input
  placeholder.
- Localize `chatMode.chat` to "对话" in zh-CN (default English stays "Chat").

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

* 💄 style: surface params panel toggle and hide it for heterogeneous agents

- Drop the developer-mode gate on the conversation header params toggle so it
  ships by default; popup routes remain excluded.
- Hide both the header toggle and the right sidebar `Params` tab for
  heterogeneous agents (Claude Code / Codex etc.), since their model params
  panel doesn't apply. The active-tab resolver also falls back away from
  `params` when it isn't available.
- Strengthen the Tools popover divider to `colorFill` so the header /
  footer separators stay visible against the elevated dark-mode surface.

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

* 🚑 fix: address type errors surfaced on the new-input branch

- Move the `border` from the removed `overlayInnerStyle` onto `styles.content`
  so the AgentMode / ModeSelector popovers compile against the base-ui
  `PopoverProps` shape.
- Pass `paddingLeft: 6` through `style` on `ChatInputActions` since the
  underlying Flexbox only accepts `padding` / `paddingBlock` / `paddingInline`.
- Tighten skill / market menu items: drop the unsupported `closeOnClick`
  from the group item, fallback the uninstall display name to
  `identifier`, swap the antd-style `type: 'warning'` confirm option for
  `okButtonProps.danger`, and assert the conditionally-spread market
  items as `ItemType` so the inferred union no longer contains
  `undefined`.
- Annotate `resolveMark` in `LevelSlider` so the fallback branch returns
  a `ReactNode` label, fixing the `MarkObj` mismatch on `LevelOption`.

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

---------

Co-authored-by: Innei <tukon479@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 00:07:47 +08:00
YuTengjing b5871d327a 🐛 fix: preserve resume request trigger (#14798) 2026-05-14 23:43:09 +08:00
YuTengjing 875c9b49eb 🐛 fix: reduce task template skeleton CLS (#14788)
* 🐛 fix: reduce task template skeleton CLS

* 🐛 fix: align recommendation skeleton count

* 🐛 fix: derive recommendation skeleton count

*  test: cover recommendation count without rendering

*  test: move recommendation count coverage to const

* ♻️ refactor: simplify task template recommendation count

* ♻️ refactor: remove task template recommendation aliases

* 🐛 fix: use task template count constant in router

* ♻️ refactor: remove task template count max
2026-05-14 23:23:21 +08:00
Innei 1914ae6d43 🐛 fix(desktop): restrict local file previews (#14789)
* 🐛 fix(desktop): restrict local file previews

* 🐛 fix(desktop): close TOCTOU in localfile protocol handler

* 🐛 fix(desktop): guard approveWorkspaceRoots against undefined input

App.test.ts StoreManager mock returned undefined for unknown keys,
causing TypeError when approveWorkspaceRoots tried to call .map().
Added default parameter and updated mock to return defaultValue.

*  test: stabilize ci dependency resolution
2026-05-14 22:08:57 +08:00
YuTengjing ffd66d5465 📝 docs: simplify and refresh skill docs (#14785) 2026-05-14 15:53:05 +08:00
Arvin Xu d00770a956 💄 style: AnalyzeVisualMedia inspector, Portal HTML preview refactor & CE trace dedup (#14777)
*  feat: add AnalyzeVisualMedia inspector, Portal HTML preview refactor, and CE trace dedup

- Add AnalyzeVisualMedia inspector and state types to builtin-tool-lobe-agent
- Refactor Portal HTML renderer to use @lobehub/ui built-in HtmlPreview
- Add portal artifact type selector and portal selectors to distinguish HTML/other artifacts
- Dedup context_engine_result events in OperationTraceRecorder; add resolveCeEvent in viewer
- Update .agents/skills/builtin-tool/references/ui.md with Tool Render design principles
- Bump @lobehub/ui to 5.12.0 for HtmlPreview support

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

* 🧪 test(trace-recorder): add deduplicateCeEvent tests for context_engine_result dedup

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

* 🐛 fix(agent-tracing): wire resolveCeEvent into all CE reader paths

All render functions and CLI inspect paths now call resolveCeEvent(step, allSteps)
instead of reading step.events?.find(...) directly, so deduplicated steps
correctly reconstruct their context_engine_result input/output by walking back
through previous steps.

Affected: renderSystemRole, renderEnvContext, renderPayloadTools, renderPayload,
renderMemory, renderMessageDetail, renderStepDetail, and all --system-role /
--env / --payload-tools / --payload / --memory CLI branches (both text and --json).

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

* ♻️ refactor(conversation): pass onRegenerate through ErrorMessageExtra and fix error guard order

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

* ♻️ refactor(agent-tracing): lift context_engine_result out of events into typed contextEngine field

Replace ad-hoc CE event dedup (mutating input/output inside events[]) with a
dedicated `contextEngine` field on StepSnapshot that uses the same delta pattern
as messagesBaseline/messagesDelta. CE data is structural state, not a streaming
event — keeping it in events[] was a semantic mismatch.

- Add `StepSnapshot.contextEngine?: { input?, output? }` with full delta semantics
- OperationTraceRecorder: extract CE from events before building snapshotEvents,
  store in contextEngine, deduplicate via deduplicateCeSnapshot (no more mutations)
- viewer: add resolveCeSnapshot (reads contextEngine first, falls back to legacy
  events format for old snapshots); deprecate resolveCeEvent alias
- inspect CLI: update all call sites to resolveCeSnapshot
- tests: rewrite deduplicateCeEvent suite → contextEngine dedup suite

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

* 💄 style(loading): use colorTextTertiary for elapsed time display

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 15:25:54 +08:00
Neko 20267fc77c 🔨 chore(memory-user-memory): add benchmark agent config (#14779) 2026-05-14 14:45:30 +08:00
Neko 4630785870 🔨 chore(memory-user-memory): support source ids in extraction schemas (#14778) 2026-05-14 14:45:09 +08:00
Rdmclin2 5b7611615e 🐛 fix: system bot error (#14784)
* chore: add start link short cut

* chore: update qq zh files

* fix: add messenger block message alert

* chore: update i18n files

* fix: messenger router bridge

* fix: dm thread create problem

* chore: remove lab prefer for messenger

* chore: update i18n files

* fix: e2e test
2026-05-14 13:26:10 +07:00
Arvin Xu ec547a3b57 🐛 fix(topic): restore indent for heterogeneous agent topic rows (#14783)
Remove the dead `return null` branch that skipped icon rendering entirely
for heterogeneous agents (Claude Code, Codex, …).  The early return caused
`NavItem` to omit the 28 px icon `<Center>` container, shifting the title
text leftward and breaking visual alignment with regular topic rows.

The existing `visibility: hidden` style on the HashIcon already preserves
the layout box while hiding the glyph — the null return just prevented it
from ever running.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 12:58:09 +08:00
Innei 36c4be46f0 🐛 fix(desktop): split runtime externals from native deps (#14776) 2026-05-14 01:57:46 +08:00
Neko 7b136a210f 🐛 fix(agent-signal): avoid blocking agent execution (#14775) 2026-05-14 01:53:11 +08:00
Innei 9075d5dfd3 refactor: merge agent marketplace into web onboarding
*  feat(desktop): open-in-app + agent files tab + localfile protocol

Bundle three related desktop features:
- Open-in-app: IPC contract, main-process detector/launcher/icon-extractor,
  renderer service, OpenInAppButton + hook, agent header / portal /
  files-tab integration, user preference (defaultOpenInApp).
- Agent files tab: working sidebar files tab with file tracking, store
  wiring, i18n, reveal-in-tree action in Review/FileItem.
- LocalFile protocol: serve binary images via localfile:// for inline
  preview in the review panel.

* 🐛 fix: add explicit type annotation for ref parameter in Files test

Fix TS7031: Binding element 'ref' implicitly has an 'any' type.
This error was caught by tsgo type-check in CI.

* 🐛 fix: address codex review feedback (P1 reveal retry + P2 WebStorm Windows detection)

* 🐛 fix(open-in-app): avoid process.platform reference in renderer

The Electron renderer sandbox does not expose `process`, so reading
`process.platform` in the useOpenInApp hook crashes with a ReferenceError
on app launch. Use the `window.lobeEnv.platform` value already exposed
via preload contextBridge instead.

* 🐛 fix(conversation): keep assistant runtime errors outside workflow collapse

When an assistant block carries a runtime error, render the error in the
answer segment instead of letting it fold into the workflow collapse with
the surrounding tool calls.

*  feat(portal): add file viewer tab strip and local file protocol improvements

- Add tabbed interface for local file portal viewer
- Extend LocalFileProtocolManager with audio MIME type support
- Add portal actions for file navigation and tab management
- Improve OpenInAppButton and conversation header integration
- Update working sidebar resources section
- Add comprehensive portal action tests

*  feat(agent-sidebar): redesign Review panel and refine Files explorer

- Review: drop antd Collapse, replace with a linear disclosure list
  (hairline dividers, no rounded cards, chevron-left, role=button rows).
  Add motion height/opacity expand animation. Compact row spacing.
  Move hover-revealed copy/reveal/revert into an absolute Flexbox with
  a gradient mask so they overlay the right edge without taking layout.
- Files: extract useGitWorkingTreeFiles hook + tests; surface git
  status entries in the working tree explorer.
- ExplorerTree: share folder icon style; minor type tweak.
- Locales: new chat strings for the above.

* 🐛 fix(test): add missing chatConfigByIdSelectors mock to WorkingSidebar test
2026-05-14 01:45:43 +08:00
YuTengjing 1c429f8d28 feat(chat): add Onboarding request trigger and pass via metadata (#14770)
*  feat(chat): add Onboarding request trigger and pass via metadata

- Add RequestTrigger.Onboarding for onboarding chat requests
- Replace requestTrigger option with metadata.trigger across chat service / executors
- Tag onboarding agent send-message with metadata.trigger = Onboarding
- Persist trigger on message metadata for billing & logs

* 🔨 chore(chat): share request context header constants

* 🐛 fix(chat): preserve trigger on tool resumes

* 🔧 chore(builtin-agents): expose package entry types

*  test(types): preserve request trigger metadata

* 🐛 fix(chat): scope resumed trigger metadata to message chain
2026-05-14 00:32:26 +08:00
Neko ac250b9897 ♻️ refactor(agent-signal,server,app,database,locales): self iteration exits lab (#14769) 2026-05-14 00:04:57 +08:00
Neko e8b7fe14e1 🐛 fix(server,memory-user-memory): embedding token exceeded, should limit and cut off searched memory query (#14757) 2026-05-13 22:32:28 +08:00
Innei 79cf5febed 🐛 fix(kb): preserve files on NoSuchKey and clean orphan documents/tasks (#14501)
* 🐛 fix(kb): preserve files on NoSuchKey and clean orphan documents/tasks

NoSuchKey from object storage no longer cascades into wholesale deletion
of file rows (and their chunks/embeddings). Instead the async chunking
task is marked Error with a clear message so users can re-upload or
retry. Files whose url uses the `internal://` scheme (mirror rows for
inline custom/document) skip storage fetch entirely.

fileModel.delete and deleteMany now also remove (a) mirror documents
where sourceType='file' and fileId matches, and (b) the chunk/embedding
asyncTasks rows tied to the file. Without this, deletion left orphan
documents (still indexed by BM25, still occupying KB slots) and dangling
task rows.

Closes LOBE-8607

* 🐛 fix(kb): delete document storage objects
2026-05-13 22:22:19 +08:00
Innei 4b6b341951 💄 fix(nav-panel): polish SideBarDrawer & header layout details (#14762)
* 💄 fix(nav-panel): polish SideBarDrawer & header layout details

- Use SMALL icon size for close button and settings icon
- Remove unused imports and dead code in SideBarHeaderLayout
- Fix topic item padding in AllTopicsDrawer Content

* 🐛 fix(nav-panel): update ITEM_HEIGHT to match new row height without vertical padding

Address Codex review feedback on PR #14762.
The padding change from padding='4px 8px' to paddingInline={4} removed
the 4px top/bottom padding, reducing row height from ~44px to ~36px.
Update ITEM_HEIGHT estimate from 44 to 36 to keep virtualization
fill logic accurate.
2026-05-13 20:41:03 +08:00
AmAzing- 44892960e0 feat: add Agent Signal marker to receipt descriptions (#14764)
 feat: add agent signal marker to receipt descriptions
2026-05-13 19:19:52 +08:00
Innei dc86f38dc1 🐛 fix(onboarding): hide ModeSwitch in production environment (#14760)
The ModeSwitch component was rendering in production because the cloud
repo sets AGENT_ONBOARDING_ENABLED=true, bypassing the isDev guard
inside the component. Wrap the entire ModeSwitch with isDev so neither
the segmented control nor dev actions appear in prod.
2026-05-13 19:07:39 +08:00
LiJian 3e43683132 🔨 chore(heteroContext): clarify sandbox TTL and add public-repo fork push guide (#14761)
* 🔨 chore(heteroContext): clarify sandbox TTL and add public-repo fork push guide

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

* 🐛 fix(heteroContext): make fork remote setup idempotent

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:52:35 +08:00
lobehubbot b125565597 🔖 chore(release): release version v2.1.58 [skip ci] 2026-05-13 02:01:19 +00:00
747 changed files with 35000 additions and 4809 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: add-provider-doc
description: Guide for adding new AI provider documentation. Use when adding documentation for a new AI provider (like OpenAI, Anthropic, etc.), including usage docs, environment variables, Docker config, and image resources. Triggers on provider documentation tasks.
description: Add documentation for a new AI provider — usage docs, env vars, Docker config, image resources.
disable-model-invocation: true
argument-hint: '[provider-name]'
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: add-setting-env
description: Guide for adding environment variables to configure user settings. Use when implementing server-side environment variables that control default values for user settings. Triggers on env var configuration or setting default value tasks.
description: Add server-side environment variables that control default values for user settings.
disable-model-invocation: true
argument-hint: '[setting-name]'
---
@@ -18,6 +18,27 @@ The two reference tools to read end-to-end:
---
## Tool Render 设计原则(中文草案)
这些原则用于判断一个 builtin tool 的 Inspector / Render / Placeholder / Streaming / Intervention / Portal 应该做什么,以及做到什么程度。
1. **先保证折叠态可读。** 每个 API 都必须有 Inspector;用户不展开也应该能看懂 “正在做什么 / 对什么做 / 当前结果是什么”。Inspector 不应该只展示函数名和原始参数。
2. **Inspector 是一句话,不是详情页。** 优先表达动作、关键对象、数量、状态,例如 “分析图片 3 张”“搜索 12 个结果”“读取 config.json”。长文本、列表和结构化结果放到 Render 或 Portal。
3. **Inspector 要覆盖执行生命周期。** `args` 还在 streaming、工具执行中、执行完成、执行失败时都应该有稳定展示;必要时同时读取 `args``partialArgs``pluginState`,避免出现空白、跳变或只显示半截参数。
4. **只有结构化结果才需要 Render。** 如果工具结果只是自然语言总结,通常不需要 Render;如果结果包含列表、媒体、文件、表格、代码、diff、地图、时间线、权限请求等结构,就应该提供 Render。
5. **Render 要帮助用户检查结果,而不是复述参数。** Render 的主体应该围绕工具产物组织:可预览、可比较、可筛选、可定位。参数只作为上下文辅助出现,不要把 Render 做成一块更大的 args dump。
6. **参数和结果要一起参与渲染。** 好的 Tool UI 通常同时用 `args` 解释意图,用 `pluginState` 展示真实执行结果;但 `pluginState` 只放结果域数据,不要反向塞入可以从 `args` 推导出的内容。
7. **慢操作要有 Placeholder。** 如果工具通常需要等待网络、文件系统、模型或外部进程,Placeholder 应该先占住最终 Render 的版式,让用户知道即将看到什么,而不是只显示一个泛化 loading。
8. **Streaming 只用于连续产物。** 搜索列表、日志、长文本、文件分析、分阶段计划适合 Streaming;一次性小结果不需要强行做 Streaming。Streaming UI 要能渐进追加,并且完成后自然过渡到最终 Render。
9. **有风险的动作必须 Intervention。** 写文件、删除、发送、安装、执行命令、外部可见操作、权限敏感操作,都应该在执行前给出可理解的确认界面;确认文案要说明影响范围,而不是只问 “是否继续”。
10. **错误、空态和截断都是正式状态。** Render 不能在失败、无结果、超长结果时退化成空白。错误要说明发生在哪一步;空态要告诉用户没有产物;超长内容要明确 “展示前 N 项 / 还有 N 项”。
11. **信息密度要克制。** 默认展示最有判断价值的部分:标题、来源、状态、摘要、少量关键字段。大对象、长列表、原文、调试数据放进可展开区域或 Portal,避免把聊天流撑成后台管理页。
12. **视觉上融入聊天流。** Tool UI 应该使用 `@lobehub/ui` / base-ui、`Flexbox``createStaticStyles``cssVar.*`,遵循现有间距、圆角、颜色、字号;不要为单个工具发明一套独立视觉语言。
13. **Devtools fixture 是验收入口。** 新增或修改 Tool UI 时,应在 `/devtools` 里准备覆盖典型态、loading/streaming、空态、错误态、长内容态的 fixture;一个 API 如果在真实聊天里会出现,就不应该在 devtools 中缺席。
14. **先做用户会看的 UI,再做调试 UI。** Raw JSON、trace、schema、内部 id 可以存在,但应默认收起或放到调试区;主界面先回答用户最关心的问题:工具做了什么,结果值不值得信任,下一步能做什么。
---
## 0. Shared Style Rules
These apply across every surface.
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: cli
description: LobeHub CLI (@lobehub/cli) development guide. Use when working on CLI commands, adding new subcommands, fixing CLI bugs, or understanding CLI architecture. Triggers on CLI development, command implementation, or `lh` command questions.
description: LobeHub CLI (@lobehub/cli) development guide — commands, subcommands, architecture.
disable-model-invocation: true
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: desktop
description: Electron desktop development guide. Use when implementing desktop features, IPC handlers, controllers, preload scripts, window management, menu configuration, or Electron-specific functionality. Triggers on desktop app development, Electron IPC, or desktop local tools implementation.
description: Electron desktop development guide IPC handlers, controllers, preload scripts, window/menu management.
disable-model-invocation: true
---
+79 -130
View File
@@ -6,6 +6,10 @@ user-invocable: false
# LobeHub Project Overview
> The directory listings below are a **curated map of key locations**, not an
> exhaustive tree. `packages/`, `src/store/`, route groups etc. grow over time —
> run `ls` against the real directory for the current set.
## Project Description
Open-source, modern-design AI Agent Workspace: **LobeHub** (previously LobeChat).
@@ -14,7 +18,7 @@ Open-source, modern-design AI Agent Workspace: **LobeHub** (previously LobeChat)
- Web desktop/mobile
- Desktop (Electron)
- Mobile app (React Native) - coming soon
- Mobile app (React Native) **separate repo, already launched** (not in this monorepo)
**Logo emoji:** 🤯
@@ -39,147 +43,92 @@ Open-source, modern-design AI Agent Workspace: **LobeHub** (previously LobeChat)
| Database | Neon PostgreSQL + Drizzle ORM |
| Testing | Vitest |
## Complete Project Structure
> Exact versions live in the root `package.json` — check there, not here.
Monorepo using `@lobechat/` namespace for workspace packages.
## Monorepo Layout
This is a monorepo extending the open-source `lobehub` submodule. Two repos:
- **cloud repo root** — `src/` and `packages/business/` (`config`, `const`, `model-runtime`) hold cloud-only SaaS code that overrides/extends the submodule. See `AGENTS.md` for the override mechanism.
- **`lobehub/` submodule** — the open-source product core.
### `lobehub/` submodule — key directories
```
lobehub/
├── apps/
── desktop/ # Electron desktop app
├── docs/
── changelog/
├── development/
│ ├── self-hosting/
│ └── usage/
├── locales/
│ ├── en-US/
── zh-CN/
├── packages/
│ ├── agent-runtime/ # Agent runtime
│ ├── builtin-agents/
│ ├── builtin-tool-*/ # Builtin tool packages
│ ├── business/ # Cloud-only business logic
│ │ ├── config/
│ │ ├── const/
│ │ └── model-runtime/
│ ├── config/
│ ├── const/
── cli/ # LobeHub CLI
├── desktop/ # Electron desktop app
── device-gateway/ # Device gateway service
├── docs/ # changelog, development, self-hosting, usage
├── locales/ # en-US, zh-CN, ...
├── packages/ # ~80 @lobechat/* workspace packages — `ls` for the full set. Key ones:
│ ├── agent-runtime/ # Agent runtime
│ ├── agent-signal/ # Agent Signal pipeline
── builtin-tool-*/ # Builtin tool packages
│ ├── builtin-tools/ # Builtin tool registries
│ ├── context-engine/
│ ├── conversation-flow/
│ ├── database/
│ └── src/
│ │ ├── models/
│ │ ├── schemas/
│ │ └── repositories/
│ ├── desktop-bridge/
│ ├── edge-config/
│ ├── editor-runtime/
│ ├── electron-client-ipc/
│ ├── electron-server-ipc/
│ ├── fetch-sse/
│ ├── file-loaders/
│ ├── memory-user-memory/
│ ├── model-bank/
│ ├── model-runtime/
│ │ └── src/
│ │ ├── core/
│ │ └── providers/
│ ├── observability-otel/
│ ├── prompts/
│ ├── python-interpreter/
│ ├── ssrf-safe-fetch/
│ ├── types/
│ ├── utils/
│ └── web-crawler/
├── src/
│ ├── app/
│ │ ├── (backend)/
│ │ │ ├── api/
│ │ │ ├── f/
│ │ │ ├── market/
│ │ │ ├── middleware/
│ │ │ ├── oidc/
│ │ │ ├── trpc/
│ │ │ └── webapi/
│ │ ├── spa/ # SPA HTML template service
│ │ └── [variants]/
│ │ └── (auth)/ # Auth pages (SSR required)
│ ├── routes/ # SPA page components (Vite)
│ │ ├── (main)/
│ │ ├── (mobile)/
│ │ ├── (desktop)/
│ │ ├── onboarding/
│ │ └── share/
│ ├── spa/ # SPA entry points and router config
│ │ ├── entry.web.tsx
│ │ ├── entry.mobile.tsx
│ │ ├── entry.desktop.tsx
│ │ └── router/
│ ├── business/ # Cloud-only (client/server)
│ │ ├── client/
│ │ ├── locales/
│ │ └── server/
│ ├── components/
│ ├── config/
│ ├── const/
│ ├── envs/
│ ├── features/
│ ├── helpers/
│ ├── hooks/
│ ├── layout/
│ │ ├── AuthProvider/
│ │ └── GlobalProvider/
│ ├── libs/
│ │ ├── better-auth/
│ │ ├── oidc-provider/
│ │ └── trpc/
│ ├── locales/
│ │ └── default/
│ ├── server/
│ │ ├── featureFlags/
│ │ ├── globalConfig/
│ │ ├── modules/
│ │ ├── routers/
│ │ │ ├── async/
│ │ │ ├── lambda/
│ │ │ ├── mobile/
│ │ │ └── tools/
│ │ └── services/
│ ├── services/
│ ├── store/
│ │ ├── agent/
│ │ ├── chat/
│ │ └── user/
│ ├── styles/
│ ├── tools/
│ ├── database/ # src/{models,schemas,repositories}
│ ├── model-bank/ # Model definitions & provider cards
├── model-runtime/ # src/{core,providers}
│ ├── types/
│ └── utils/
└── e2e/ # E2E tests (Cucumber + Playwright)
└── src/
├── app/
│ ├── (backend)/ # api, f, market, middleware, oidc, trpc, webapi
│ ├── spa/ # SPA HTML template service
│ └── [variants]/(auth)/ # Auth pages (SSR required)
├── routes/ # SPA page segments (thin — delegate to features/)
│ └── (main)/ (mobile)/ (desktop)/ (popup)/ onboarding/ share/
├── spa/ # SPA entries + router config
│ ├── entry.{web,mobile,desktop,popup}.tsx
│ └── router/
├── business/ # Open-source stubs (~50) overridden by cloud src/business/
├── features/ # Domain business components
├── store/ # ~28 zustand stores — `ls` for the full set
├── server/ # featureFlags, globalConfig, modules, routers, services
└── ... # components, hooks, layout, libs, locales, services, types, utils
```
### cloud repo — key directories
```
(cloud root)
├── packages/business/ # Cloud overrides: config, const, model-runtime
├── src/
│ ├── business/ # Cloud impls of submodule stubs (client/server/locales)
│ ├── routes/ # Cloud-only route groups: (cloud)/, embed/
│ ├── store/ # Cloud-only stores (e.g. subscription/)
│ ├── server/ # Cloud routers & services (billing, budget, risk control...)
│ └── app/(backend)/cron/ # Vercel cron routes (schedules declared in root vercel.ts)
└── vercel.ts # Cron schedule declarations
```
> File search rule: a path like `@/store/x` resolves cloud `src/store/x` first, then
> `lobehub/packages/store/src/x`, then `lobehub/src/store/x`. Cloud override wins.
## Architecture Map
| Layer | Location |
| ---------------- | --------------------------------------------------- |
| UI Components | `src/components`, `src/features` |
| SPA Pages | `src/routes/` |
| React Router | `src/spa/router/` |
| Global Providers | `src/layout` |
| Zustand Stores | `src/store` |
| Client Services | `src/services/` |
| REST API | `src/app/(backend)/webapi` |
| tRPC Routers | `src/server/routers/{async\|lambda\|mobile\|tools}` |
| Server Services | `src/server/services` (can access DB) |
| Server Modules | `src/server/modules` (no DB access) |
| Feature Flags | `src/server/featureFlags` |
| Global Config | `src/server/globalConfig` |
| DB Schema | `packages/database/src/schemas` |
| DB Model | `packages/database/src/models` |
| DB Repository | `packages/database/src/repositories` |
| Third-party | `src/libs` (analytics, oidc, etc.) |
| Builtin Tools | `src/tools`, `packages/builtin-tool-*` |
| Cloud-only | `src/business/*`, `packages/business/*` |
| Layer | Location |
| ---------------- | ---------------------------------------------------- |
| UI Components | `src/components`, `src/features` |
| SPA Pages | `src/routes/` |
| React Router | `src/spa/router/` |
| Global Providers | `src/layout` |
| Zustand Stores | `src/store` |
| Client Services | `src/services/` |
| REST API | `src/app/(backend)/webapi` |
| tRPC Routers | `src/server/routers/{async\|lambda\|mobile\|tools}` |
| Server Services | `src/server/services` (can access DB) |
| Server Modules | `src/server/modules` (no DB access) |
| Feature Flags | `src/server/featureFlags` |
| Global Config | `src/server/globalConfig` |
| DB Schema | `packages/database/src/schemas` |
| DB Model | `packages/database/src/models` |
| DB Repository | `packages/database/src/repositories` |
| Third-party | `src/libs` (analytics, oidc, etc.) |
| Builtin Tools | `src/tools`, `packages/builtin-tool-*` |
| Cloud-only | `src/business/*`, `packages/business/*` (cloud repo) |
## Data Flow
+71 -70
View File
@@ -1,95 +1,96 @@
---
name: react
description: "LobeHub React/SPA component conventions: antd-style with `createStaticStyles` + `cssVar.*` (prefer zero-runtime over `createStyles` + `token`), `@lobehub/ui/base-ui` primitives before `@lobehub/ui` before antd, `Flexbox`/`Center` for layouts, react-router-dom navigation, and the `.desktop.tsx` sync rule. Use when writing or editing any `.tsx` under `src/**`, picking a styling helper, choosing a component (Select/Modal/Drawer/Button/Tooltip), wiring routes in `desktopRouter.config.tsx`/`.desktop.tsx`, or adding a `Link`/`useNavigate` call in the SPA. Triggers on `createStyles`/`createStaticStyles`, `cssVar`, `@lobehub/ui`, `antd-style`, `Flexbox`, `useNavigate`, `react-router-dom`, `Link`, 'new component', 'add a page', 'edit a layout', 'desktopRouter', 'componentMap.desktop'."
description: 'Use when writing or editing any `.tsx` under `src/**`. Triggers: createStaticStyles, createStyles, cssVar, antd-style, Flexbox, Center, Select, Modal, Drawer, Button, Tooltip, DropdownMenu, Popover, Switch, ScrollArea, Link, useNavigate, react-router-dom, next/link, desktopRouter, componentMap.desktop, .desktop.tsx, new component, new page, edit layout, add styles, zustand selector, @lobehub/ui, antd import.'
user-invocable: false
---
# React Component Writing Guide
- Use antd-style for complex styles; for simple cases, use inline `style` attribute
- **Prefer `createStaticStyles` with `cssVar.*`** (zero-runtime) — module-level, no hook call required
- Only fall back to `createStyles` + `token` when styles genuinely need runtime computation (dynamic props, JS color fns like `readableColor`/`chroma`)
- See `.cursor/docs/createStaticStyles_migration_guide.md` for full pattern
- Use `Flexbox` and `Center` from `@lobehub/ui` for layouts (see `references/layout-kit.md`)
- Component priority: `src/components` > `@lobehub/ui/base-ui` > `@lobehub/ui` > custom implementation
- Always prefer `@lobehub/ui/base-ui` primitives (Select, Modal, DropdownMenu, Popover, Switch, ScrollArea…) over antd equivalents
- Fall back to `@lobehub/ui` higher-level components when base-ui has no match
- Only implement a custom component as a last resort — never reach for antd directly
- Use selectors to access zustand store data
## Styling
## @lobehub/ui Components
| Scenario | Approach |
| ---------------------------------------------------------- | -------------------------------------------------------------- |
| Most cases | `createStaticStyles` + `cssVar.*` (zero-runtime, module-level) |
| Simple one-off | Inline `style` attribute |
| Truly dynamic (JS color fns like `readableColor`/`chroma`) | `createStyles` + `token`**last resort** |
If unsure about component usage, search existing code in this project. Most components extend antd with additional props.
## Component Priority
Reference: `node_modules/@lobehub/ui/es/index.mjs` for all available components.
1. **`src/components`** — project-specific reusable components
2. **`@lobehub/ui/base-ui`** — headless primitives (Select, Modal, DropdownMenu, Popover, Switch, ScrollArea…)
3. **`@lobehub/ui`** — higher-level components (ActionIcon, Markdown, DragPage…)
4. **Custom implementation** — last resort; never reach for antd directly
**Common Components:**
If unsure about available components, search existing code or check `node_modules/@lobehub/ui/es/index.mjs`.
- General: ActionIcon, ActionIconGroup, Block, Button, Icon
- Data Display: Avatar, Collapse, Empty, Highlighter, Markdown, Tag, Tooltip
- Data Entry: CodeEditor, CopyButton, EditableText, Form, FormModal, Input, SearchBar, Select
- Feedback: Alert, Drawer, Modal
- Layout: Center, DraggablePanel, Flexbox, Grid, Header, MaskShadow
- Navigation: Burger, Dropdown, Menu, SideNav, Tabs
### Common @lobehub/ui Components
| Category | Components |
| ------------ | ------------------------------------------------------------------------------- |
| General | ActionIcon, ActionIconGroup, Block, Button, Icon |
| Data Display | Avatar, Collapse, Empty, Highlighter, Markdown, Tag, Tooltip |
| Data Entry | CodeEditor, CopyButton, EditableText, Form, FormModal, Input, SearchBar, Select |
| Feedback | Alert, Drawer, Modal |
| Layout | Center, DraggablePanel, Flexbox, Grid, Header, MaskShadow |
| Navigation | Burger, Dropdown, Menu, SideNav, Tabs |
## Layout
Use `Flexbox` and `Center` from `@lobehub/ui`. See `references/layout-kit.md` for full props and examples.
- Use `gap` instead of `margin` for spacing between flex children
- Use `flex={1}` to fill available space
- Nest Flexbox for complex layouts; set `overflow: 'auto'` for scrollable regions
## Navigation
**For SPA pages, use `react-router-dom`, NOT `next/link`.**
```tsx
// ❌ Wrong
import Link from 'next/link';
// ✅ Correct
import { Link, useNavigate } from 'react-router-dom';
```
Access navigate from stores: `useGlobalStore.getState().navigate?.('/settings');`
## Desktop File Sync Rule
Files with a `.desktop.ts(x)` variant must be edited **in sync**. Drift causes blank pages in Electron.
| Base file (web) | Desktop file (Electron) |
| -------------------------- | ---------------------------------- |
| `desktopRouter.config.tsx` | `desktopRouter.config.desktop.tsx` |
| `componentMap.ts` | `componentMap.desktop.ts` |
**After editing any `.ts`/`.tsx`:** glob for `<filename>.desktop.{ts,tsx}` in the same directory. If found, apply the equivalent sync-import change.
## Routing Architecture
Hybrid routing: Next.js App Router (static pages) + React Router DOM (main SPA).
| Route Type | Use Case | Implementation |
| ------------------ | ---------- | -------------------------------------------------- |
| Next.js App Router | Auth pages | `src/app/[variants]/(auth)/` |
| React Router DOM | Main SPA | `desktopRouter.config.tsx` + `.desktop.tsx` (pair) |
| Route Type | Use Case | Implementation |
| ------------------ | --------------------------------- | ---------------------------------------------------------------------------- |
| Next.js App Router | Auth pages (login, signup, oauth) | `src/app/[variants]/(auth)/` |
| React Router DOM | Main SPA (chat, settings) | `desktopRouter.config.tsx` + `desktopRouter.config.desktop.tsx` (must match) |
### Key Files
- Entry: `src/spa/entry.web.tsx` (web), `src/spa/entry.mobile.tsx`, `src/spa/entry.desktop.tsx`
- Desktop router (pair — **always edit both** when changing routes): `src/spa/router/desktopRouter.config.tsx` (dynamic imports) and `src/spa/router/desktopRouter.config.desktop.tsx` (sync imports). Drift can cause unregistered routes / blank screen.
- Mobile router: `src/spa/router/mobileRouter.config.tsx`
- Router utilities: `src/utils/router.tsx`
### `.desktop.{ts,tsx}` File Sync Rule
**CRITICAL**: Some files have a `.desktop.ts(x)` variant that Electron uses instead of the base file. When editing a base file, **always check** if a `.desktop` counterpart exists and update it in sync. Drift causes blank pages or missing features in Electron.
Known pairs that must stay in sync:
| Base file (web, dynamic imports) | Desktop file (Electron, sync imports) |
| ----------------------------------------------------- | ------------------------------------------------------------- |
| `src/spa/router/desktopRouter.config.tsx` | `src/spa/router/desktopRouter.config.desktop.tsx` |
| `src/routes/(main)/settings/features/componentMap.ts` | `src/routes/(main)/settings/features/componentMap.desktop.ts` |
**How to check**: After editing any `.ts` / `.tsx` file, run `Glob` for `<filename>.desktop.{ts,tsx}` in the same directory. If a match exists, update it with the equivalent sync-import change.
### Router Utilities
Router utilities:
```tsx
import { dynamicElement, redirectElement, ErrorBoundary } from '@/utils/router';
element: dynamicElement(() => import('./chat'), 'Desktop > Chat');
element: redirectElement('/settings/profile');
errorElement: <ErrorBoundary />;
```
### Navigation
## Common Mistakes
**Important**: For SPA pages, use `Link` from `react-router-dom`, NOT `next/link`.
```tsx
// ❌ Wrong
import Link from 'next/link';
<Link href="/">Home</Link>;
// ✅ Correct
import { Link } from 'react-router-dom';
<Link to="/">Home</Link>;
// In components
import { useNavigate } from 'react-router-dom';
const navigate = useNavigate();
navigate('/chat');
// From stores
const navigate = useGlobalStore.getState().navigate;
navigate?.('/settings');
```
| Mistake | Fix |
| ----------------------------------------------------------------- | ----------------------------------------------------------------- |
| Using `next/link` in SPA | Use `react-router-dom` `Link` |
| Using antd directly | Use `@lobehub/ui/base-ui` first, then `@lobehub/ui` |
| `createStyles` for static styles | Use `createStaticStyles` + `cssVar` |
| Editing only `desktopRouter.config.tsx` | Must edit both `.tsx` and `.desktop.tsx` |
| Using `margin` for flex spacing | Use `gap` prop on Flexbox |
| Accessing zustand store without selector | Use selectors to access store data (see zustand skill) |
| Text or icon-text actions built with `Flexbox`/`Text` + `onClick` | Use `Button type={'text'} size={'small'}` with `icon` when needed |
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: version-release
description: "Version release workflow. Use when the user mentions 'release', 'hotfix', 'version upgrade', 'weekly release', or '发版'/'发布'/'小班车'. This skill is for release process and GitHub Release notes (not docs/changelog page writing)."
description: 'Version release workflow release process and GitHub Release notes (not docs/changelog pages).'
disable-model-invocation: true
argument-hint: '[minor|patch] [version?]'
---
+40
View File
@@ -21,6 +21,46 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
# Remind contributors when a non-release PR targets `main`.
# Day-to-day PRs should target `canary`; `main` is reserved for releases
# (see .agents/skills/version-release/SKILL.md). Allowed exceptions:
# - PR title matches `🚀 release: v{x.y.z}` (minor release)
# - head branch matches `hotfix/*` or `release/*` (patch release)
- name: Remind contributor if base branch is not canary
if: github.event.action == 'opened' && github.event.pull_request.base.ref == 'main'
env:
HEAD_REF: ${{ github.event.pull_request.head.ref }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_NUMBER: ${{ github.event.pull_request.number }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
run: |
if [[ "$HEAD_REF" == hotfix/* ]] || [[ "$HEAD_REF" == release/* ]]; then
echo "✅ Release/hotfix branch ($HEAD_REF) -> main is allowed"
exit 0
fi
if [[ "$PR_TITLE" =~ ^🚀[[:space:]]+release: ]]; then
echo "✅ Release-titled PR -> main is allowed"
exit 0
fi
echo "⚠️ Non-release PR targets main; posting reminder comment."
gh pr comment "$PR_NUMBER" --body "$(cat <<'EOF'
👋 Thanks for your contribution!
This PR currently targets the **`main`** branch, but `main` is reserved for release PRs only. Day-to-day development (features, fixes, refactors, docs, etc.) should target the **`canary`** branch.
### How to fix
On the PR page, click **Edit** next to the title, then change the base branch from `main` to `canary`.
### When targeting `main` is allowed
- PR title starts with `🚀 release: v{x.y.z}` (minor release)
- Head branch matches `hotfix/*` or `release/*` (patch release)
If your PR fits one of these cases, please ignore this message.
EOF
)"
- name: Check if author is a team member
id: check-team
run: |
+236
View File
@@ -2,6 +2,242 @@
# Changelog
### [Version 2.2.0](https://github.com/lobehub/lobe-chat/compare/v2.1.59-canary.27...v2.2.0)
<sup>Released on **2026-05-18**</sup>
#### 💄 Styles
- **pricing**: restore DeepSeek models to official pricing.
#### 🐛 Bug Fixes
- **conversation**: animate only the last markdown block + drop clearMessages hotkey.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### Styles
- **pricing**: restore DeepSeek models to official pricing, closes [#14911](https://github.com/lobehub/lobe-chat/issues/14911) ([e566688](https://github.com/lobehub/lobe-chat/commit/e566688))
#### What's fixed
- **conversation**: animate only the last markdown block + drop clearMessages hotkey, closes [#14906](https://github.com/lobehub/lobe-chat/issues/14906) ([469a8e6](https://github.com/lobehub/lobe-chat/commit/469a8e6))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
## [Version 2.1.58](https://github.com/lobehub/lobe-chat/compare/v2.1.57...v2.1.58)
<sup>Released on **2026-05-13**</sup>
#### ✨ Features
- **agent-runtime**: persist agent operations to `agent_operations` table.
- **misc**: support slack mpim and fix discord dm problem.
- **database**: add `agent_operations` table.
- **markdown**: user_feedback card + task card polish + Run now context menu.
- **documents**: add optimistic create/delete and inline rename for document tree.
- **devtools**: add dev-only feature flag override panel.
- **misc**: add service model assignments settings.
- **misc**: inline skill auth in recommended task templates.
- **activator**: require activation reason.
- **agent-signal,server,prompts**: consolidate in self-review implemented.
- **hetero-agent**: support AskUserQuestion tools for claude code.
- **bot**: gate device tools by sender identity.
- **misc**: add user activity business hook.
- **misc**: add Gemini 3.1 Flash-Lite provider cards.
- **misc**: home daily brief with linkable welcome + paired input hint.
- **agent-signal,prompts,database**: self-review now proposal actions to briefs, and automatically execute actions.
- **misc**: add signOperationJwt with 4h expiry for hetero-agent operations.
- **misc**: migrate Notion to LobeHub Market.
- **misc**: Cloud Claude Code V3 — repo picker, GitHub token, sandbox context.
#### 🐛 Bug Fixes
- **hetero-agent**: wire AskUserBridge response events to renderer.
- **home**: blank user bubble when sending the placeholder hint.
- **conversation**: prevent synthetic scroll from shrinking spacer.
- **task-card**: localize task card date independent of dayjs global locale.
- **web-crawler**: cap response body size to prevent serverless OOM.
- **desktop**: focus onboarding auth success state.
- **misc**: Docs image.
- **desktop**: detect Windows npm .cmd shims for CLI agents (claude/codex/…).
- **misc**: update Task page placeholder copy.
- **builtin-tool-task**: expose `lobe-task` and add `setTaskSchedule`.
- **desktop**: reset pendingLoginMethod on auth failure/cancel paths.
- **utils**: cap image binary at 3.75MB so base64 payload stays under Anthropic 5MB limit.
- **tasks**: scheduler, hotkey, comment & TodoList polish.
- **cli**: remove stale cron entry from generated man page.
- **misc**: sidebar add agent.
- **misc**: replace ScrollShadow with ScrollArea to fix React #185 infinite render loop.
- **heteroFinish**: trigger task lifecycle on cloud sandbox agent completion.
- **hotkey**: remove redundant onClear to prevent double updateHotkey calls.
- **misc**: reject inactive OIDC access.
- **misc**: drop unreachable aihubmix empty-apiKey test.
- **aihubmix**: use full models endpoint to return complete model list.
- **onboarding**: skip marketplace on early exit, drop CJK in prompts.
- **model-runtime**: enrich stream parse errors with provider/model context.
- **home**: strip markdown links from daily-brief input placeholder.
- **misc**: consume visual content parts in server runtime.
- **misc**: store onboarding interests as keys.
- **hetero-agent**: sync new-step assistant across replicas.
- **misc**: remove the old cron job from lobehub.
- **misc**: refresh content baseline from DB on every ingest call.
- **hetero-agent**: disable Claude Code AskUserQuestion to avoid auto-decline.
- **local-system**: guard readFile against binary blobs and oversized output.
- **database,utils,userMemories**: should perfer to use `paradedb.match(...)` instead of hardcoded normalizer.
- **database**: attach error listeners to Neon/Node pools to prevent Lambda crash.
- **misc**: gateway client-tool pluginState + drop redundant `Exit code: 0` tail.
- **gemini**: handle zero cachedContentTokenCount in usage conversion.
- **misc**: first inject the cloudecc runtime session should use the existingStatus.
- **misc**: slack connect error & slash commands.
- **misc**: polish task agent manager.
- **agent-runtime**: recover malformed tool_call names instead of finishing silently.
- **misc**: remove signin captcha flow.
- **misc**: add temporary email auth error locale.
- **misc**: add bot callback service.
- **misc**: sanitize sensitive comments and examples from production JS bundle.
- **misc**: multiple account link.
#### 💄 Styles
- **misc**: use @lobehub/ui built-in HtmlPreview instead of custom component.
- **misc**: polish desktop header icons, sidebar density, and task menus.
- **review-panel**: hover revert button to discard per-file working-tree changes.
- **misc**: standardize header action icon sizes.
- **tool**: add word wrap toggle to tool arguments display.
- **nav**: unify ActionIcon sizing and improve TodoList encapsulation.
- **web-onboarding**: add Render for saveUserQuestion & showAgentMarketplace.
- **misc**: add `reasoning_effort` support for Grok 4.3.
- **misc**: increase chat topic title length.
- **hetero-agent**: read-only SubAgent threads with breadcrumb header and thread switcher.
- **chat-input**: show skeleton in action bar while config is loading.
- **home**: add Recommendations module with hetero agent action library.
- **copyable-label**: wrap long tool-call params instead of truncating.
- **misc**: format tool execution time as Xmin Ys instead of X.Y min.
- **misc**: Add new DeepSeek-V4 models.
- **topic**: add copy session ID to topic dropdown menu.
- **misc**: use visible divider between queued messages.
- **intervention**: polish confirmation bar layout.
- **settings**: remove image avatar from lab input markdown rendering item.
- **task**: activity card stop run + register /tasks in SPA proxy.
- **misc**: update auth captcha retry copy.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's improved
- **agent-runtime**: persist agent operations to `agent_operations` table, closes [#14736](https://github.com/lobehub/lobe-chat/issues/14736) ([a772341](https://github.com/lobehub/lobe-chat/commit/a772341))
- **misc**: support slack mpim and fix discord dm problem, closes [#14733](https://github.com/lobehub/lobe-chat/issues/14733) ([729265a](https://github.com/lobehub/lobe-chat/commit/729265a))
- **database**: add `agent_operations` table, closes [#14416](https://github.com/lobehub/lobe-chat/issues/14416) ([cb8b616](https://github.com/lobehub/lobe-chat/commit/cb8b616))
- **markdown**: user_feedback card + task card polish + Run now context menu, closes [#14727](https://github.com/lobehub/lobe-chat/issues/14727) ([79152fa](https://github.com/lobehub/lobe-chat/commit/79152fa))
- **documents**: add optimistic create/delete and inline rename for document tree, closes [#14714](https://github.com/lobehub/lobe-chat/issues/14714) ([0007984](https://github.com/lobehub/lobe-chat/commit/0007984))
- **devtools**: add dev-only feature flag override panel, closes [#14565](https://github.com/lobehub/lobe-chat/issues/14565) ([18b1c25](https://github.com/lobehub/lobe-chat/commit/18b1c25))
- **misc**: add service model assignments settings, closes [#14712](https://github.com/lobehub/lobe-chat/issues/14712) ([eb924ec](https://github.com/lobehub/lobe-chat/commit/eb924ec))
- **misc**: inline skill auth in recommended task templates, closes [#14676](https://github.com/lobehub/lobe-chat/issues/14676) ([4490e3e](https://github.com/lobehub/lobe-chat/commit/4490e3e))
- **activator**: require activation reason, closes [#14597](https://github.com/lobehub/lobe-chat/issues/14597) ([5f14b7e](https://github.com/lobehub/lobe-chat/commit/5f14b7e))
- **agent-signal,server,prompts**: consolidate in self-review implemented, closes [#14657](https://github.com/lobehub/lobe-chat/issues/14657) ([1374fd2](https://github.com/lobehub/lobe-chat/commit/1374fd2))
- **hetero-agent**: support AskUserQuestion tools for claude code, closes [#14639](https://github.com/lobehub/lobe-chat/issues/14639) ([49c3d7e](https://github.com/lobehub/lobe-chat/commit/49c3d7e))
- **bot**: gate device tools by sender identity, closes [#14634](https://github.com/lobehub/lobe-chat/issues/14634) ([3c81011](https://github.com/lobehub/lobe-chat/commit/3c81011))
- **misc**: add user activity business hook, closes [#14601](https://github.com/lobehub/lobe-chat/issues/14601) ([521566b](https://github.com/lobehub/lobe-chat/commit/521566b))
- **misc**: add Gemini 3.1 Flash-Lite provider cards, closes [#14604](https://github.com/lobehub/lobe-chat/issues/14604) ([9b032f0](https://github.com/lobehub/lobe-chat/commit/9b032f0))
- **misc**: home daily brief with linkable welcome + paired input hint, closes [#14589](https://github.com/lobehub/lobe-chat/issues/14589) ([12e37f1](https://github.com/lobehub/lobe-chat/commit/12e37f1))
- **agent-signal,prompts,database**: self-review now proposal actions to briefs, and automatically execute actions, closes [#14583](https://github.com/lobehub/lobe-chat/issues/14583) ([b7a5020](https://github.com/lobehub/lobe-chat/commit/b7a5020))
- **misc**: add signOperationJwt with 4h expiry for hetero-agent operations, closes [#14586](https://github.com/lobehub/lobe-chat/issues/14586) ([d2c379c](https://github.com/lobehub/lobe-chat/commit/d2c379c))
- **misc**: migrate Notion to LobeHub Market, closes [#14578](https://github.com/lobehub/lobe-chat/issues/14578) ([f1f2e58](https://github.com/lobehub/lobe-chat/commit/f1f2e58))
- **misc**: Cloud Claude Code V3 — repo picker, GitHub token, sandbox context, closes [#14568](https://github.com/lobehub/lobe-chat/issues/14568) ([7792f63](https://github.com/lobehub/lobe-chat/commit/7792f63))
#### What's fixed
- **hetero-agent**: wire AskUserBridge response events to renderer, closes [#14732](https://github.com/lobehub/lobe-chat/issues/14732) ([5174c13](https://github.com/lobehub/lobe-chat/commit/5174c13))
- **home**: blank user bubble when sending the placeholder hint, closes [#14678](https://github.com/lobehub/lobe-chat/issues/14678) ([fc275ca](https://github.com/lobehub/lobe-chat/commit/fc275ca))
- **conversation**: prevent synthetic scroll from shrinking spacer, closes [#14584](https://github.com/lobehub/lobe-chat/issues/14584) ([217afcf](https://github.com/lobehub/lobe-chat/commit/217afcf))
- **task-card**: localize task card date independent of dayjs global locale, closes [#14730](https://github.com/lobehub/lobe-chat/issues/14730) ([df0e635](https://github.com/lobehub/lobe-chat/commit/df0e635))
- **web-crawler**: cap response body size to prevent serverless OOM, closes [#14660](https://github.com/lobehub/lobe-chat/issues/14660) ([2202189](https://github.com/lobehub/lobe-chat/commit/2202189))
- **desktop**: focus onboarding auth success state, closes [#14694](https://github.com/lobehub/lobe-chat/issues/14694) ([4e4294f](https://github.com/lobehub/lobe-chat/commit/4e4294f))
- **misc**: Docs image, closes [#14726](https://github.com/lobehub/lobe-chat/issues/14726) ([3a4bd4a](https://github.com/lobehub/lobe-chat/commit/3a4bd4a))
- **desktop**: detect Windows npm .cmd shims for CLI agents (claude/codex/…), closes [#14720](https://github.com/lobehub/lobe-chat/issues/14720) ([a40fe91](https://github.com/lobehub/lobe-chat/commit/a40fe91))
- **misc**: update Task page placeholder copy, closes [#14704](https://github.com/lobehub/lobe-chat/issues/14704) ([eea742f](https://github.com/lobehub/lobe-chat/commit/eea742f))
- **builtin-tool-task**: expose `lobe-task` and add `setTaskSchedule`, closes [#14713](https://github.com/lobehub/lobe-chat/issues/14713) ([5ff4590](https://github.com/lobehub/lobe-chat/commit/5ff4590))
- **desktop**: reset pendingLoginMethod on auth failure/cancel paths, closes [#14695](https://github.com/lobehub/lobe-chat/issues/14695) ([51cefe0](https://github.com/lobehub/lobe-chat/commit/51cefe0))
- **utils**: cap image binary at 3.75MB so base64 payload stays under Anthropic 5MB limit, closes [#14711](https://github.com/lobehub/lobe-chat/issues/14711) ([948e48b](https://github.com/lobehub/lobe-chat/commit/948e48b))
- **tasks**: scheduler, hotkey, comment & TodoList polish, closes [#14707](https://github.com/lobehub/lobe-chat/issues/14707) ([1ae774d](https://github.com/lobehub/lobe-chat/commit/1ae774d))
- **cli**: remove stale cron entry from generated man page, closes [#14709](https://github.com/lobehub/lobe-chat/issues/14709) ([94e4ea6](https://github.com/lobehub/lobe-chat/commit/94e4ea6))
- **misc**: sidebar add agent, closes [#14693](https://github.com/lobehub/lobe-chat/issues/14693) ([fdedc96](https://github.com/lobehub/lobe-chat/commit/fdedc96))
- **misc**: replace ScrollShadow with ScrollArea to fix React #185 infinite render loop, closes [#185](https://github.com/lobehub/lobe-chat/issues/185), closes [#14689](https://github.com/lobehub/lobe-chat/issues/14689) ([7349ad0](https://github.com/lobehub/lobe-chat/commit/7349ad0))
- **heteroFinish**: trigger task lifecycle on cloud sandbox agent completion, closes [#14681](https://github.com/lobehub/lobe-chat/issues/14681) ([744059c](https://github.com/lobehub/lobe-chat/commit/744059c))
- **hotkey**: remove redundant onClear to prevent double updateHotkey calls, closes [#14663](https://github.com/lobehub/lobe-chat/issues/14663) ([dfe1932](https://github.com/lobehub/lobe-chat/commit/dfe1932))
- **misc**: reject inactive OIDC access, closes [#14674](https://github.com/lobehub/lobe-chat/issues/14674) ([b79c5d8](https://github.com/lobehub/lobe-chat/commit/b79c5d8))
- **misc**: drop unreachable aihubmix empty-apiKey test, closes [#14669](https://github.com/lobehub/lobe-chat/issues/14669) ([b0ee35d](https://github.com/lobehub/lobe-chat/commit/b0ee35d))
- **aihubmix**: use full models endpoint to return complete model list, closes [#14511](https://github.com/lobehub/lobe-chat/issues/14511) ([f4de472](https://github.com/lobehub/lobe-chat/commit/f4de472))
- **onboarding**: skip marketplace on early exit, drop CJK in prompts, closes [#14598](https://github.com/lobehub/lobe-chat/issues/14598) ([a9eb904](https://github.com/lobehub/lobe-chat/commit/a9eb904))
- **model-runtime**: enrich stream parse errors with provider/model context, closes [#14636](https://github.com/lobehub/lobe-chat/issues/14636) ([7daed90](https://github.com/lobehub/lobe-chat/commit/7daed90))
- **home**: strip markdown links from daily-brief input placeholder, closes [#14635](https://github.com/lobehub/lobe-chat/issues/14635) ([0babdcf](https://github.com/lobehub/lobe-chat/commit/0babdcf))
- **misc**: consume visual content parts in server runtime, closes [#14637](https://github.com/lobehub/lobe-chat/issues/14637) ([d445a89](https://github.com/lobehub/lobe-chat/commit/d445a89))
- **misc**: store onboarding interests as keys, closes [#14624](https://github.com/lobehub/lobe-chat/issues/14624) ([9982de3](https://github.com/lobehub/lobe-chat/commit/9982de3))
- **hetero-agent**: sync new-step assistant across replicas, closes [#14631](https://github.com/lobehub/lobe-chat/issues/14631) ([7675bd9](https://github.com/lobehub/lobe-chat/commit/7675bd9))
- **misc**: remove the old cron job from lobehub, closes [#14630](https://github.com/lobehub/lobe-chat/issues/14630) ([457d112](https://github.com/lobehub/lobe-chat/commit/457d112))
- **misc**: refresh content baseline from DB on every ingest call, closes [#14603](https://github.com/lobehub/lobe-chat/issues/14603) ([6595961](https://github.com/lobehub/lobe-chat/commit/6595961))
- **hetero-agent**: disable Claude Code AskUserQuestion to avoid auto-decline, closes [#14629](https://github.com/lobehub/lobe-chat/issues/14629) ([ae8f9cf](https://github.com/lobehub/lobe-chat/commit/ae8f9cf))
- **local-system**: guard readFile against binary blobs and oversized output, closes [#14602](https://github.com/lobehub/lobe-chat/issues/14602) ([96165e4](https://github.com/lobehub/lobe-chat/commit/96165e4))
- **database,utils,userMemories**: should perfer to use `paradedb.match(...)` instead of hardcoded normalizer, closes [#14590](https://github.com/lobehub/lobe-chat/issues/14590) ([38b793f](https://github.com/lobehub/lobe-chat/commit/38b793f))
- **database**: attach error listeners to Neon/Node pools to prevent Lambda crash, closes [#14606](https://github.com/lobehub/lobe-chat/issues/14606) ([11ec59b](https://github.com/lobehub/lobe-chat/commit/11ec59b))
- **misc**: gateway client-tool pluginState + drop redundant `Exit code: 0` tail, closes [#14596](https://github.com/lobehub/lobe-chat/issues/14596) ([4bfd434](https://github.com/lobehub/lobe-chat/commit/4bfd434))
- **gemini**: handle zero cachedContentTokenCount in usage conversion, closes [#14567](https://github.com/lobehub/lobe-chat/issues/14567) ([307cd8e](https://github.com/lobehub/lobe-chat/commit/307cd8e))
- **misc**: first inject the cloudecc runtime session should use the existingStatus, closes [#14592](https://github.com/lobehub/lobe-chat/issues/14592) ([09c66ff](https://github.com/lobehub/lobe-chat/commit/09c66ff))
- **misc**: slack connect error & slash commands, closes [#14591](https://github.com/lobehub/lobe-chat/issues/14591) ([8274be0](https://github.com/lobehub/lobe-chat/commit/8274be0))
- **misc**: polish task agent manager, closes [#14569](https://github.com/lobehub/lobe-chat/issues/14569) ([a02ecbc](https://github.com/lobehub/lobe-chat/commit/a02ecbc))
- **agent-runtime**: recover malformed tool_call names instead of finishing silently, closes [#14577](https://github.com/lobehub/lobe-chat/issues/14577) ([5f8ec8b](https://github.com/lobehub/lobe-chat/commit/5f8ec8b))
- **misc**: remove signin captcha flow, closes [#14573](https://github.com/lobehub/lobe-chat/issues/14573) ([181b7eb](https://github.com/lobehub/lobe-chat/commit/181b7eb))
- **misc**: add temporary email auth error locale, closes [#14564](https://github.com/lobehub/lobe-chat/issues/14564) ([2bdd901](https://github.com/lobehub/lobe-chat/commit/2bdd901))
- **misc**: add bot callback service, closes [#14570](https://github.com/lobehub/lobe-chat/issues/14570) ([e4b5e52](https://github.com/lobehub/lobe-chat/commit/e4b5e52))
- **misc**: sanitize sensitive comments and examples from production JS bundle, closes [#14557](https://github.com/lobehub/lobe-chat/issues/14557) ([1a6e07b](https://github.com/lobehub/lobe-chat/commit/1a6e07b))
- **misc**: multiple account link, closes [#14562](https://github.com/lobehub/lobe-chat/issues/14562) ([760a342](https://github.com/lobehub/lobe-chat/commit/760a342))
#### Styles
- **misc**: use @lobehub/ui built-in HtmlPreview instead of custom component, closes [#14703](https://github.com/lobehub/lobe-chat/issues/14703) ([266d102](https://github.com/lobehub/lobe-chat/commit/266d102))
- **misc**: polish desktop header icons, sidebar density, and task menus, closes [#14724](https://github.com/lobehub/lobe-chat/issues/14724) ([e56edab](https://github.com/lobehub/lobe-chat/commit/e56edab))
- **review-panel**: hover revert button to discard per-file working-tree changes, closes [#14716](https://github.com/lobehub/lobe-chat/issues/14716) ([846e648](https://github.com/lobehub/lobe-chat/commit/846e648))
- **misc**: standardize header action icon sizes, closes [#14717](https://github.com/lobehub/lobe-chat/issues/14717) ([ca9a781](https://github.com/lobehub/lobe-chat/commit/ca9a781))
- **tool**: add word wrap toggle to tool arguments display, closes [#14706](https://github.com/lobehub/lobe-chat/issues/14706) ([bfa2850](https://github.com/lobehub/lobe-chat/commit/bfa2850))
- **nav**: unify ActionIcon sizing and improve TodoList encapsulation, closes [#14692](https://github.com/lobehub/lobe-chat/issues/14692) ([877052f](https://github.com/lobehub/lobe-chat/commit/877052f))
- **web-onboarding**: add Render for saveUserQuestion & showAgentMarketplace, closes [#14667](https://github.com/lobehub/lobe-chat/issues/14667) ([f591f7a](https://github.com/lobehub/lobe-chat/commit/f591f7a))
- **misc**: add `reasoning_effort` support for Grok 4.3, closes [#14642](https://github.com/lobehub/lobe-chat/issues/14642) ([a1fac45](https://github.com/lobehub/lobe-chat/commit/a1fac45))
- **misc**: increase chat topic title length, closes [#14659](https://github.com/lobehub/lobe-chat/issues/14659) ([e0ead0c](https://github.com/lobehub/lobe-chat/commit/e0ead0c))
- **hetero-agent**: read-only SubAgent threads with breadcrumb header and thread switcher, closes [#14658](https://github.com/lobehub/lobe-chat/issues/14658) ([31e9130](https://github.com/lobehub/lobe-chat/commit/31e9130))
- **chat-input**: show skeleton in action bar while config is loading, closes [#14656](https://github.com/lobehub/lobe-chat/issues/14656) ([84b802c](https://github.com/lobehub/lobe-chat/commit/84b802c))
- **home**: add Recommendations module with hetero agent action library, closes [#14645](https://github.com/lobehub/lobe-chat/issues/14645) ([e261a6f](https://github.com/lobehub/lobe-chat/commit/e261a6f))
- **copyable-label**: wrap long tool-call params instead of truncating, closes [#14640](https://github.com/lobehub/lobe-chat/issues/14640) ([60a127b](https://github.com/lobehub/lobe-chat/commit/60a127b))
- **misc**: format tool execution time as Xmin Ys instead of X.Y min, closes [#14641](https://github.com/lobehub/lobe-chat/issues/14641) ([b85a1ad](https://github.com/lobehub/lobe-chat/commit/b85a1ad))
- **misc**: Add new DeepSeek-V4 models, closes [#14110](https://github.com/lobehub/lobe-chat/issues/14110) ([867e22a](https://github.com/lobehub/lobe-chat/commit/867e22a))
- **topic**: add copy session ID to topic dropdown menu, closes [#14595](https://github.com/lobehub/lobe-chat/issues/14595) ([a275009](https://github.com/lobehub/lobe-chat/commit/a275009))
- **misc**: use visible divider between queued messages, closes [#14593](https://github.com/lobehub/lobe-chat/issues/14593) ([909b1ec](https://github.com/lobehub/lobe-chat/commit/909b1ec))
- **intervention**: polish confirmation bar layout, closes [#14587](https://github.com/lobehub/lobe-chat/issues/14587) ([5c11130](https://github.com/lobehub/lobe-chat/commit/5c11130))
- **settings**: remove image avatar from lab input markdown rendering item, closes [#14582](https://github.com/lobehub/lobe-chat/issues/14582) ([d73de25](https://github.com/lobehub/lobe-chat/commit/d73de25))
- **task**: activity card stop run + register /tasks in SPA proxy, closes [#14559](https://github.com/lobehub/lobe-chat/issues/14559) ([a7cc553](https://github.com/lobehub/lobe-chat/commit/a7cc553))
- **misc**: update auth captcha retry copy, closes [#14561](https://github.com/lobehub/lobe-chat/issues/14561) ([c208723](https://github.com/lobehub/lobe-chat/commit/c208723))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
## [Version 2.1.57](https://github.com/lobehub/lobe-chat/compare/v2.1.57-canary.33...v2.1.57)
<sup>Released on **2026-05-09**</sup>
+202
View File
@@ -269,6 +269,204 @@ function registerAllowlistCommand(bot: Command, opts: AllowlistGroupOptions) {
});
}
// ── Watch keywords subcommand factory ──────────────────
interface WatchKeywordEntry {
instruction?: string;
keyword: string;
}
/**
* Normalise `settings.watchKeywords` into the canonical
* `{keyword, instruction?}[]` shape. Mirrors `extractWatchKeywordEntries`
* in `src/server/services/bot/platforms/const.ts` so the CLI accepts the
* same legacy on-disk shapes (`string`, `string[]`, `{keyword, …}[]`)
* the runtime is forgiving about — including the rare comma/whitespace
* separated string from a hand-pasted upgrade.
*/
function normalizeWatchKeywords(raw: unknown): WatchKeywordEntry[] {
const push = (out: Map<string, WatchKeywordEntry>, keyword: unknown, instruction?: unknown) => {
if (typeof keyword !== 'string') return;
const normalised = keyword.trim().toLowerCase();
if (!normalised) return;
const trimmedInstruction =
typeof instruction === 'string' && instruction.trim() ? instruction.trim() : undefined;
const existing = out.get(normalised);
if (!existing) {
out.set(normalised, { instruction: trimmedInstruction, keyword: normalised });
return;
}
if (!existing.instruction && trimmedInstruction) existing.instruction = trimmedInstruction;
};
const collected = new Map<string, WatchKeywordEntry>();
if (typeof raw === 'string') {
for (const piece of raw.split(/[\s,]+/)) push(collected, piece);
} else if (Array.isArray(raw)) {
for (const entry of raw) {
if (typeof entry === 'string') {
push(collected, entry);
continue;
}
if (entry && typeof entry === 'object' && 'keyword' in entry) {
const obj = entry as { instruction?: unknown; keyword?: unknown };
push(collected, obj.keyword, obj.instruction);
}
}
}
return [...collected.values()];
}
/**
* Build a `list / add / remove / clear` subcommand group around
* `settings.watchKeywords`. Shape differs from the user/channel allowlists
* (`{keyword, instruction?}` vs `{id, name?}`), so we duplicate the
* scaffolding instead of squeezing both shapes through one factory — the
* help text, column headers, and `--instruction` flag are all keyword-
* specific and would just bloat the unified version.
*/
function registerWatchKeywordsCommand(bot: Command) {
const group = bot
.command('watch-keywords')
.description(
'Manage watch keywords (non-mention channel triggers; the optional instruction is prepended to the user message before being sent to the AI)',
);
const readEntries = (bot: any): WatchKeywordEntry[] =>
normalizeWatchKeywords((bot.settings as Record<string, unknown> | null)?.watchKeywords);
const buildPayload = (bot: any, nextEntries: WatchKeywordEntry[]) => ({
id: bot.id,
settings: {
...(bot.settings as Record<string, unknown>),
watchKeywords: nextEntries,
},
});
group
.command('list <botId>')
.description('List watch-keyword entries')
.option('--json', 'Output JSON')
.action(async (botId: string, options: { json?: boolean }) => {
const client = await getTrpcClient();
const b = await findBot(client, botId);
const entries = readEntries(b);
if (options.json) {
outputJson(entries);
return;
}
if (entries.length === 0) {
console.log(`${pc.dim('No watch-keyword entries.')}`);
return;
}
printTable(
entries.map((e) => [e.keyword, e.instruction ?? pc.dim('-')]),
['KEYWORD', 'INSTRUCTION'],
);
});
group
.command('add <botId> <keyword>')
.description('Add a watch keyword (with optional instruction prefix)')
.option(
'--instruction <text>',
'Prompt prepended to the user message when this keyword fires (omit for "just wake the bot")',
)
.action(async (botId: string, keyword: string, options: { instruction?: string }) => {
const trimmedKeyword = keyword.trim().toLowerCase();
if (!trimmedKeyword) {
log.error('Keyword cannot be empty.');
process.exit(1);
return;
}
const trimmedInstruction = options.instruction?.trim();
const client = await getTrpcClient();
const b = await findBot(client, botId);
const entries = readEntries(b);
const existing = entries.find((e) => e.keyword === trimmedKeyword);
if (existing) {
// Upsert instruction on duplicate keyword — operators commonly
// re-run `add` to tweak the prompt without remembering to remove first.
if (trimmedInstruction && existing.instruction !== trimmedInstruction) {
existing.instruction = trimmedInstruction;
await client.agentBotProvider.update.mutate(buildPayload(b, entries) as any);
console.log(
`${pc.green('✓')} Updated instruction for ${pc.bold(trimmedKeyword)} (${entries.length} entr${entries.length === 1 ? 'y' : 'ies'})`,
);
return;
}
log.info(`${trimmedKeyword} is already on watchKeywords — nothing to do.`);
return;
}
const next = [
...entries,
trimmedInstruction
? { instruction: trimmedInstruction, keyword: trimmedKeyword }
: { keyword: trimmedKeyword },
];
await client.agentBotProvider.update.mutate(buildPayload(b, next) as any);
console.log(
`${pc.green('✓')} Added ${pc.bold(trimmedKeyword)}${trimmedInstruction ? ' (with instruction)' : ''} to watchKeywords (now ${next.length} entr${next.length === 1 ? 'y' : 'ies'})`,
);
});
group
.command('remove <botId> <keyword>')
.description('Remove a watch keyword')
.action(async (botId: string, keyword: string) => {
const trimmedKeyword = keyword.trim().toLowerCase();
const client = await getTrpcClient();
const b = await findBot(client, botId);
const entries = readEntries(b);
const next = entries.filter((e) => e.keyword !== trimmedKeyword);
if (next.length === entries.length) {
log.info(`${trimmedKeyword} is not on watchKeywords — nothing to do.`);
return;
}
await client.agentBotProvider.update.mutate(buildPayload(b, next) as any);
console.log(
`${pc.green('✓')} Removed ${pc.bold(trimmedKeyword)} from watchKeywords (${next.length} entr${next.length === 1 ? 'y' : 'ies'} left)`,
);
});
group
.command('clear <botId>')
.description('Clear all watch keywords')
.option('--yes', 'Skip confirmation prompt')
.action(async (botId: string, options: { yes?: boolean }) => {
const client = await getTrpcClient();
const b = await findBot(client, botId);
const entries = readEntries(b);
if (entries.length === 0) {
log.info('watchKeywords is already empty — nothing to do.');
return;
}
if (!options.yes) {
const confirmed = await confirm(
`Clear all ${entries.length} watch-keyword entr${entries.length === 1 ? 'y' : 'ies'} from this bot?`,
);
if (!confirmed) {
console.log('Cancelled.');
return;
}
}
await client.agentBotProvider.update.mutate(buildPayload(b, []) as any);
console.log(`${pc.green('✓')} Cleared watchKeywords on bot ${pc.bold(botId)}`);
});
}
// ── Command Registration ─────────────────────────────────
export function registerBotCommand(program: Command) {
@@ -608,6 +806,10 @@ export function registerBotCommand(program: Command) {
name: 'group-allowlist',
});
// ── watch-keywords (LOBE-8891) ────────────────────────
registerWatchKeywordsCommand(bot);
// ── remove ────────────────────────────────────────────
bot
+7
View File
@@ -6,6 +6,10 @@ import { fileURLToPath } from 'node:url';
import dotenv from 'dotenv';
import {
copyExternalRuntimeModulesToSource,
getExternalRuntimeModulesFilesConfig,
} from './external-runtime-deps.config.mjs';
import {
copyNativeModules,
copyNativeModulesToSource,
@@ -106,6 +110,7 @@ const config = {
*/
beforePack: async () => {
await copyNativeModulesToSource();
await copyExternalRuntimeModulesToSource();
console.info('📦 Downloading agent-browser binary...');
execSync('node scripts/download-agent-browser.mjs', { stdio: 'inherit', cwd: __dirname });
@@ -251,6 +256,8 @@ const config = {
'!node_modules',
// Then explicitly include native modules using object form (handles pnpm symlinks)
...getNativeModulesFilesConfig(),
// Include non-native runtime modules that are intentionally externalized from Vite.
...getExternalRuntimeModulesFilesConfig(),
],
generateUpdatesFilesForAllChannels: true,
linux: {
+37 -4
View File
@@ -13,7 +13,8 @@ import {
sharedRendererPlugins,
sharedRollupOutput,
} from '../../plugins/vite/sharedRendererConfig';
import { getExternalDependencies } from './native-deps.config.mjs';
import { externalRuntimeModules } from './external-runtime-deps.config.mjs';
import { getNativeExternalDependencies } from './native-deps.config.mjs';
/**
* Force `base: '/'` in renderer config. The `electron-vite` preset
@@ -99,7 +100,11 @@ const desktopPackageJson = JSON.parse(
readFileSync(path.resolve(__dirname, 'package.json'), 'utf8'),
) as { version: string };
const electronRuntimeExternals = ['electron'];
const mainProcessRuntimeExternals = [...electronRuntimeExternals, 'node-mac-permissions'];
const mainProcessRuntimeExternals = [
...electronRuntimeExternals,
...externalRuntimeModules,
'node-mac-permissions',
];
console.info(`[electron-vite.config.ts] Detected UPDATE_CHANNEL: ${updateChannel}`);
@@ -113,17 +118,45 @@ export default defineConfig({
// bufferutil and utf-8-validate are optional peer deps of ws that may not be installed.
external: [
...mainProcessRuntimeExternals,
...getExternalDependencies(),
...getNativeExternalDependencies(),
'bufferutil',
'utf-8-validate',
],
output: {
// Prevent debug package from being bundled into index.js to avoid side-effect pollution
// Prevent shared deps from being bundled into index.js to avoid side-effect pollution.
// Pattern: when a module is imported by both the main bundle (statically) and a
// dynamic-import chunk (lazy loader), rolldown places it in main and makes the
// chunk back-reference `require("./index.js")`. Electron's main entry isn't in
// Node's CJS cache, so that require recompiles `index.js` from scratch — which
// re-runs `new App()` at top-level and triggers `protocol.registerSchemesAsPrivileged`
// *after* the app is ready → throw.
//
// Same root cause as the original `debug` regression fixed in #11827. Isolate
// each shared module into its own vendor chunk so both ends reference the vendor
// chunk instead of back-referencing main.
manualChunks(id) {
if (id.includes('node_modules/debug')) {
return 'vendor-debug';
}
// Small text/binary detection utilities in file-loaders/utils. Imported by
// main (via `sniffBinaryFile`) and potentially by lazy loader chunks.
// Explicitly enumerated to avoid catching `parser-utils.ts`, which pulls in
// xmldom / yauzl / concat-stream — those belong in docx/pptx loader chunks.
if (
/packages\/file-loaders\/src\/utils\/(?:detectUtf16|isBinaryContent|isTextReadableFile)\.ts$/.test(
id,
)
) {
return 'vendor-file-loaders-utils';
}
// jszip — imported by main (via some static path) AND by the docx loader chunk.
// Without this, reading a .docx file throws the protocol re-init error.
if (id.includes('node_modules/jszip')) {
return 'vendor-jszip';
}
// Split i18n json resources by namespace (ns), not by locale.
// Example: ".../resources/locales/zh-CN/common.json?import" -> "locales-common"
const normalizedId = id.replaceAll('\\', '/').split('?')[0];
@@ -0,0 +1,33 @@
import {
copyModulesToSource,
getDependenciesForModules,
getModuleFilesConfig,
} from './module-deps.config.mjs';
/**
* Non-native modules intentionally externalized from the main-process bundle.
*
* These modules are not native dependencies. They stay external because their
* process-level side effects must be owned by one Node runtime module instance.
*/
export const externalRuntimeModules = ['electron-log'];
/**
* Get all dependencies for runtime external modules.
* @returns {string[]}
*/
export function getAllExternalRuntimeDependencies() {
return getDependenciesForModules(externalRuntimeModules);
}
/**
* Generate files config objects for non-native runtime external modules.
* @returns {Array<{from: string, to: string, filter: string[]}>}
*/
export function getExternalRuntimeModulesFilesConfig() {
return getModuleFilesConfig(externalRuntimeModules);
}
export async function copyExternalRuntimeModulesToSource() {
await copyModulesToSource(externalRuntimeModules, 'runtime external module');
}
+189
View File
@@ -0,0 +1,189 @@
/* eslint-disable no-console */
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const sourceNodeModules = path.join(__dirname, 'node_modules');
/**
* Recursively resolve all dependencies of a module.
* @param {string} moduleName - The module to resolve
* @param {Set<string>} visited - Set of already visited modules
* @param {string} nodeModulesPath - Path to node_modules directory
* @returns {Set<string>} Set of all dependencies
*/
function resolveDependencies(moduleName, visited = new Set(), nodeModulesPath = sourceNodeModules) {
if (visited.has(moduleName)) {
return visited;
}
// Always add the module name first. Workspace and optional platform modules
// may not be materialized locally, but they still need stable package rules.
visited.add(moduleName);
const packageJsonPath = path.join(nodeModulesPath, moduleName, 'package.json');
if (!fs.existsSync(packageJsonPath)) {
return visited;
}
try {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
const dependencies = packageJson.dependencies || {};
const optionalDependencies = packageJson.optionalDependencies || {};
for (const dep of Object.keys(dependencies)) {
resolveDependencies(dep, visited, nodeModulesPath);
}
for (const dep of Object.keys(optionalDependencies)) {
resolveDependencies(dep, visited, nodeModulesPath);
}
} catch {
// Ignore unreadable package.json files; electron-builder will surface any
// actual missing runtime dependency during packaging or startup.
}
return visited;
}
/**
* Get all transitive dependencies for a set of top-level modules.
* @param {string[]} modules
* @returns {string[]}
*/
export function getDependenciesForModules(modules) {
const allDeps = new Set();
for (const moduleName of modules) {
const deps = resolveDependencies(moduleName);
for (const dep of deps) {
allDeps.add(dep);
}
}
return [...allDeps];
}
/**
* Generate glob patterns for electron-builder files config.
* @param {string[]} modules
* @returns {string[]}
*/
export function getModuleFilesPatterns(modules) {
return getDependenciesForModules(modules).map((dep) => `node_modules/${dep}/**/*`);
}
/**
* Generate object-form electron-builder files config.
* Object form is required because pnpm symlinks are resolved before packaging.
* @param {string[]} modules
* @returns {Array<{from: string, to: string, filter: string[]}>}
*/
export function getModuleFilesConfig(modules) {
return getDependenciesForModules(modules).map((dep) => ({
filter: ['**/*'],
from: `node_modules/${dep}`,
to: `node_modules/${dep}`,
}));
}
/**
* Copy module symlinks in source node_modules to real directories so
* electron-builder can include them via file rules.
* @param {string[]} modules
* @param {string} label
*/
export async function copyModulesToSource(modules, label) {
const deps = getDependenciesForModules(modules);
console.log(`📦 Resolving ${deps.length} ${label} symlinks for packaging...`);
for (const dep of deps) {
const modulePath = path.join(sourceNodeModules, dep);
try {
const stat = await fs.promises.lstat(modulePath);
if (stat.isSymbolicLink()) {
const realPath = await fs.promises.realpath(modulePath);
console.log(` 📎 ${dep} (resolving symlink)`);
await fs.promises.rm(modulePath, { force: true, recursive: true });
await fs.promises.mkdir(path.dirname(modulePath), { recursive: true });
await copyDir(realPath, modulePath);
}
} catch (err) {
console.log(` ⏭️ ${dep} (skipped: ${err.code || err.message})`);
}
}
console.log(`${label} symlinks resolved`);
}
/**
* Copy modules to a destination node_modules directory, resolving symlinks.
* @param {string[]} modules
* @param {string} destNodeModules
* @param {string} label
*/
export async function copyModulesToDirectory(modules, destNodeModules, label) {
const deps = getDependenciesForModules(modules);
console.log(`📦 Copying ${deps.length} ${label} to unpacked directory...`);
for (const dep of deps) {
const sourcePath = path.join(sourceNodeModules, dep);
const destPath = path.join(destNodeModules, dep);
try {
const stat = await fs.promises.lstat(sourcePath);
if (stat.isSymbolicLink()) {
const realPath = await fs.promises.realpath(sourcePath);
console.log(` 📎 ${dep} (symlink -> ${path.relative(sourceNodeModules, realPath)})`);
await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
await copyDir(realPath, destPath);
} else if (stat.isDirectory()) {
console.log(` 📁 ${dep}`);
await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
await copyDir(sourcePath, destPath);
}
} catch (err) {
console.log(` ⏭️ ${dep} (skipped: ${err.code || err.message})`);
}
}
console.log(`${label} copied successfully`);
}
/**
* Recursively copy a directory.
* @param {string} src
* @param {string} dest
*/
async function copyDir(src, dest) {
await fs.promises.mkdir(dest, { recursive: true });
const entries = await fs.promises.readdir(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
await copyDir(srcPath, destPath);
} else if (entry.isSymbolicLink()) {
const realPath = await fs.promises.realpath(srcPath);
const realStat = await fs.promises.stat(realPath);
if (realStat.isDirectory()) {
await copyDir(realPath, destPath);
} else {
await fs.promises.copyFile(realPath, destPath);
}
} else {
await fs.promises.copyFile(srcPath, destPath);
}
}
}
+17 -176
View File
@@ -1,4 +1,3 @@
/* eslint-disable no-console */
/**
* Native dependencies configuration for Electron build
*
@@ -9,12 +8,15 @@
*
* This module automatically resolves the full dependency tree.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
import {
copyModulesToDirectory,
copyModulesToSource,
getDependenciesForModules,
getModuleFilesConfig,
getModuleFilesPatterns,
} from './module-deps.config.mjs';
/**
* Get the current target platform
@@ -40,78 +42,20 @@ export const nativeModules = [
'node-screenshots',
];
/**
* Recursively resolve all dependencies of a module
* @param {string} moduleName - The module to resolve
* @param {Set<string>} visited - Set of already visited modules (to avoid cycles)
* @param {string} nodeModulesPath - Path to node_modules directory
* @returns {Set<string>} Set of all dependencies
*/
function resolveDependencies(
moduleName,
visited = new Set(),
nodeModulesPath = path.join(__dirname, 'node_modules'),
) {
if (visited.has(moduleName)) {
return visited;
}
// Always add the module name first (important for workspace dependencies
// that may not be in local node_modules but are declared in nativeModules)
visited.add(moduleName);
const packageJsonPath = path.join(nodeModulesPath, moduleName, 'package.json');
// If module doesn't exist locally, still keep it in visited but skip dependency resolution
if (!fs.existsSync(packageJsonPath)) {
return visited;
}
try {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
const dependencies = packageJson.dependencies || {};
const optionalDependencies = packageJson.optionalDependencies || {};
// Resolve regular dependencies
for (const dep of Object.keys(dependencies)) {
resolveDependencies(dep, visited, nodeModulesPath);
}
// Also resolve optional dependencies (important for native modules like @napi-rs/canvas
// which have platform-specific binaries in optional deps)
for (const dep of Object.keys(optionalDependencies)) {
resolveDependencies(dep, visited, nodeModulesPath);
}
} catch {
// Ignore errors reading package.json
}
return visited;
}
/**
* Get all dependencies for all native modules (including transitive dependencies)
* @returns {string[]} Array of all dependency names
*/
export function getAllDependencies() {
const allDeps = new Set();
for (const nativeModule of nativeModules) {
const deps = resolveDependencies(nativeModule);
for (const dep of deps) {
allDeps.add(dep);
}
}
return [...allDeps];
export function getAllNativeDependencies() {
return getDependenciesForModules(nativeModules);
}
/**
* Generate glob patterns for electron-builder files config
* @returns {string[]} Array of glob patterns
*/
export function getFilesPatterns() {
return getAllDependencies().map((dep) => `node_modules/${dep}/**/*`);
export function getNativeModuleFilesPatterns() {
return getModuleFilesPatterns(nativeModules);
}
/**
@@ -120,11 +64,7 @@ export function getFilesPatterns() {
* @returns {Array<{from: string, to: string, filter: string[]}>}
*/
export function getNativeModulesFilesConfig() {
return getAllDependencies().map((dep) => ({
filter: ['**/*'],
from: `node_modules/${dep}`,
to: `node_modules/${dep}`,
}));
return getModuleFilesConfig(nativeModules);
}
/**
@@ -132,15 +72,15 @@ export function getNativeModulesFilesConfig() {
* @returns {string[]} Array of glob patterns
*/
export function getAsarUnpackPatterns() {
return getAllDependencies().map((dep) => `node_modules/${dep}/**/*`);
return getNativeModuleFilesPatterns();
}
/**
* Get the list of native dependencies for Vite external config
* @returns {string[]} Array of dependency names
*/
export function getExternalDependencies() {
return getAllDependencies();
export function getNativeExternalDependencies() {
return getAllNativeDependencies();
}
/**
@@ -149,39 +89,7 @@ export function getExternalDependencies() {
* included in the asar archive (electron-builder glob doesn't follow symlinks).
*/
export async function copyNativeModulesToSource() {
const fsPromises = await import('node:fs/promises');
const deps = getAllDependencies();
const sourceNodeModules = path.join(__dirname, 'node_modules');
console.log(`📦 Resolving ${deps.length} native module symlinks for packaging...`);
for (const dep of deps) {
const modulePath = path.join(sourceNodeModules, dep);
try {
const stat = await fsPromises.lstat(modulePath);
if (stat.isSymbolicLink()) {
// Resolve the symlink to get the real path
const realPath = await fsPromises.realpath(modulePath);
console.log(` 📎 ${dep} (resolving symlink)`);
// Remove the symlink
await fsPromises.rm(modulePath, { force: true, recursive: true });
// Create parent directory if needed (for scoped packages like @napi-rs)
await fsPromises.mkdir(path.dirname(modulePath), { recursive: true });
// Copy the actual directory content in place of the symlink
await copyDir(realPath, modulePath);
}
} catch (err) {
// Module might not exist (optional dependency for different platform)
console.log(` ⏭️ ${dep} (skipped: ${err.code || err.message})`);
}
}
console.log(`✅ Native module symlinks resolved`);
await copyModulesToSource(nativeModules, 'native module');
}
/**
@@ -190,72 +98,5 @@ export async function copyNativeModulesToSource() {
* @param {string} destNodeModules - Destination node_modules path
*/
export async function copyNativeModules(destNodeModules) {
const fsPromises = await import('node:fs/promises');
const deps = getAllDependencies();
const sourceNodeModules = path.join(__dirname, 'node_modules');
console.log(`📦 Copying ${deps.length} native modules to unpacked directory...`);
for (const dep of deps) {
const sourcePath = path.join(sourceNodeModules, dep);
const destPath = path.join(destNodeModules, dep);
try {
// Check if source exists (might be a symlink)
const stat = await fsPromises.lstat(sourcePath);
if (stat.isSymbolicLink()) {
// Resolve the symlink to get the real path
const realPath = await fsPromises.realpath(sourcePath);
console.log(` 📎 ${dep} (symlink -> ${path.relative(sourceNodeModules, realPath)})`);
// Create destination directory
await fsPromises.mkdir(path.dirname(destPath), { recursive: true });
// Copy the actual directory content (not the symlink)
await copyDir(realPath, destPath);
} else if (stat.isDirectory()) {
console.log(` 📁 ${dep}`);
await fsPromises.mkdir(path.dirname(destPath), { recursive: true });
await copyDir(sourcePath, destPath);
}
} catch (err) {
// Module might not exist (optional dependency for different platform)
console.log(` ⏭️ ${dep} (skipped: ${err.code || err.message})`);
}
}
console.log(`✅ Native modules copied successfully`);
}
/**
* Recursively copy a directory
* @param {string} src - Source directory
* @param {string} dest - Destination directory
*/
async function copyDir(src, dest) {
const fsPromises = await import('node:fs/promises');
await fsPromises.mkdir(dest, { recursive: true });
const entries = await fsPromises.readdir(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
await copyDir(srcPath, destPath);
} else if (entry.isSymbolicLink()) {
// For symlinks within the module, resolve and copy the actual file
const realPath = await fsPromises.realpath(srcPath);
const realStat = await fsPromises.stat(realPath);
if (realStat.isDirectory()) {
await copyDir(realPath, destPath);
} else {
await fsPromises.copyFile(realPath, destPath);
}
} else {
await fsPromises.copyFile(srcPath, destPath);
}
}
await copyModulesToDirectory(nativeModules, destNodeModules, 'native modules');
}
+2 -2
View File
@@ -44,6 +44,7 @@
"dependencies": {
"@lobehub/fluent-emoji": "^4.1.0",
"@napi-rs/canvas": "^0.1.70",
"electron-log": "^5.4.3",
"get-windows": "^9.3.0",
"node-screenshots": "^0.2.8"
},
@@ -79,7 +80,6 @@
"electron-builder": "^26.8.1",
"electron-devtools-installer": "4.0.0",
"electron-is": "^3.0.0",
"electron-log": "^5.4.3",
"electron-store": "^8.2.0",
"electron-updater": "^6.6.2",
"electron-vite": "6.0.0-beta.1",
@@ -109,7 +109,7 @@
"typescript": "^5.9.3",
"undici": "^7.16.0",
"uuid": "^14.0.0",
"vite": "^8.0.9",
"vite": "8.0.12",
"vitest": "^3.2.4",
"zod": "^3.25.76"
},
+3
View File
@@ -1 +1,4 @@
export const ELECTRON_BE_PROTOCOL_SCHEME = 'lobe-backend';
export const LOCAL_FILE_PROTOCOL_SCHEME = 'localfile';
export const LOCAL_FILE_PROTOCOL_HOST = 'file';
+1
View File
@@ -35,6 +35,7 @@ export const STORE_DEFAULTS: ElectronMainStore = {
gatewayEnabled: true,
gatewayUrl: 'https://device-gateway.lobehub.com',
locale: 'auto',
localFileWorkspaceRoots: [],
networkProxy: defaultProxySettings,
shortcuts: DEFAULT_ELECTRON_DESKTOP_SHORTCUTS,
storagePath: appStorageDir,
@@ -24,11 +24,15 @@ import {
buildAgentInput,
materializeImageToPath,
normalizeImage,
resolveCliSpawnPlan,
} from '@lobechat/heterogeneous-agents/spawn';
import { app as electronApp, BrowserWindow } from 'electron';
import { getHeterogeneousAgentDriver } from '@/modules/heterogeneousAgent';
import type { HeterogeneousAgentImageAttachment } from '@/modules/heterogeneousAgent/types';
import type {
HeterogeneousAgentBuildPlan,
HeterogeneousAgentImageAttachment,
} from '@/modules/heterogeneousAgent/types';
import { buildProxyEnv } from '@/modules/networkProxy/envBuilder';
import { detectHeterogeneousCliCommand } from '@/modules/toolDetectors';
import { createLogger } from '@/utils/logger';
@@ -868,169 +872,210 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
}
const useStdin = spawnPlan.stdinPayload !== undefined;
const cliArgs = spawnPlan.args;
const resolvedCliSpawnPlan = await resolveCliSpawnPlan(session.command, cliArgs);
logger.info(
'Spawning agent:',
resolvedCliSpawnPlan.command,
resolvedCliSpawnPlan.args.join(' '),
`(cwd: ${cwd})`,
);
// `detached: true` on Unix puts the child in a new process group so we
// can SIGINT/SIGKILL the whole tree (claude + any tool subprocesses)
// via `process.kill(-pid, sig)` on cancel. Without this, SIGINT to just
// the claude binary can leave bash/grep/etc. tool children running and
// the CLI hung waiting on them. Windows has different semantics — use
// taskkill /T /F there; no detached flag needed.
// Forward the user's proxy settings to the CLI. The main-process undici
// dispatcher doesn't reach child processes — they need env vars.
const proxyEnv = buildProxyEnv(this.app.storeManager.get('networkProxy'));
const spawnOptions = {
cwd,
detached: process.platform !== 'win32',
env: { ...process.env, ...proxyEnv, ...session.env },
stdio: [useStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] as ['pipe' | 'ignore', 'pipe', 'pipe'],
};
return new Promise<void>((resolve, reject) => {
logger.info('Spawning agent:', session.command, cliArgs.join(' '), `(cwd: ${cwd})`);
// `detached: true` on Unix puts the child in a new process group so we
// can SIGINT/SIGKILL the whole tree (claude + any tool subprocesses)
// via `process.kill(-pid, sig)` on cancel. Without this, SIGINT to just
// the claude binary can leave bash/grep/etc. tool children running and
// the CLI hung waiting on them. Windows has different semantics — use
// taskkill /T /F there; no detached flag needed.
// Forward the user's proxy settings to the CLI. The main-process undici
// dispatcher doesn't reach child processes — they need env vars.
const proxyEnv = buildProxyEnv(this.app.storeManager.get('networkProxy'));
const proc = spawn(session.command, cliArgs, {
cwd,
detached: process.platform !== 'win32',
env: { ...process.env, ...proxyEnv, ...session.env },
stdio: [useStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
const proc = spawn(resolvedCliSpawnPlan.command, resolvedCliSpawnPlan.args, spawnOptions);
this.handleSpawnedAgentProcess({
intervention,
params,
proc,
reject,
resolve,
session,
traceSession,
useStdin,
spawnPlan,
});
});
}
// In stdin mode, write the prepared payload and close stdin.
if (useStdin && spawnPlan.stdinPayload !== undefined && proc.stdin) {
void this.writeCliTraceFile(traceSession, 'stdin.txt', spawnPlan.stdinPayload);
const stdin = proc.stdin as Writable;
stdin.write(spawnPlan.stdinPayload, () => {
stdin.end();
});
}
session.process = proc;
// 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,
private handleSpawnedAgentProcess({
intervention,
params,
proc,
reject,
resolve,
session,
spawnPlan,
traceSession,
useStdin,
}: {
intervention?: Awaited<ReturnType<HeterogeneousAgentCtr['setupInterventionForOp']>>;
params: SendPromptParams;
proc: ChildProcess;
reject: (reason?: unknown) => void;
resolve: () => void;
session: AgentSession;
spawnPlan: HeterogeneousAgentBuildPlan;
traceSession: CliTraceSession | undefined;
useStdin: boolean;
}) {
proc.on('error', (err) => {
logger.error('Agent process error:', err);
void this.writeCliTraceJson(traceSession, 'process-error.json', {
message: err.message,
name: err.name,
});
let stdoutBroadcastQueue: Promise<void> = Promise.resolve();
const broadcastPipelineBatch = (produce: () => ReturnType<AgentStreamPipeline['push']>) => {
stdoutBroadcastQueue = stdoutBroadcastQueue
.then(async () => {
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 agent stream batch:', error);
});
};
// 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);
broadcastPipelineBatch(() => pipeline.push(chunk));
void this.flushCliTrace(traceSession);
const sessionError = this.getSessionErrorPayload(err, session);
this.broadcast('heteroAgentSessionError', {
error: sessionError,
sessionId: session.sessionId,
});
stdout.on('end', () => {
broadcastPipelineBatch(() => pipeline.flush());
reject(new Error(typeof sessionError === 'string' ? sessionError : sessionError.message));
});
// In stdin mode, write the prepared payload and close stdin.
if (useStdin && spawnPlan.stdinPayload !== undefined && proc.stdin) {
void this.writeCliTraceFile(traceSession, 'stdin.txt', spawnPlan.stdinPayload);
const stdin = proc.stdin as Writable;
stdin.write(spawnPlan.stdinPayload, () => {
stdin.end();
});
}
// Capture stderr
const stderrChunks: string[] = [];
const stderr = proc.stderr as Readable;
stderr.on('data', (chunk: Buffer) => {
void this.appendCliTraceFile(traceSession, 'stderr.log', chunk);
stderrChunks.push(chunk.toString('utf8'));
});
session.process = proc;
proc.on('error', (err) => {
logger.error('Agent process error:', err);
void this.writeCliTraceJson(traceSession, 'process-error.json', {
message: err.message,
name: err.name,
});
void this.flushCliTrace(traceSession);
const sessionError = this.getSessionErrorPayload(err, session);
this.broadcast('heteroAgentSessionError', {
error: sessionError,
sessionId: session.sessionId,
});
reject(new Error(typeof sessionError === 'string' ? sessionError : sessionError.message));
});
// 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();
proc.on('exit', (code, signal) => {
// 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 () => {
// Tear down the AskUserQuestion bridge / temp `mcp.json` for this
// op. Pending MCP handlers get a `session_ended` cancellation so
// they return cleanly even if CC was killed mid-tool-call.
if (intervention) {
await intervention.cleanup().catch((err) => {
logger.warn('AskUserQuestion cleanup error:', err);
});
}
void this.writeCliTraceJson(traceSession, 'exit.json', {
code,
finishedAt: new Date().toISOString(),
signal,
const broadcastPipelineBatch = (produce: () => ReturnType<AgentStreamPipeline['push']>) => {
stdoutBroadcastQueue = stdoutBroadcastQueue
.then(async () => {
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,
});
await this.flushCliTrace(traceSession);
}
})
.catch((error) => {
logger.error('Failed to broadcast agent stream batch:', error);
});
};
logger.info('Agent process exited:', { code, sessionId: session.sessionId, signal });
session.process = undefined;
// 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);
broadcastPipelineBatch(() => pipeline.push(chunk));
});
stdout.on('end', () => {
broadcastPipelineBatch(() => pipeline.flush());
});
// 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;
}
// Capture stderr
const stderrChunks: string[] = [];
const stderr = proc.stderr as Readable;
stderr.on('data', (chunk: Buffer) => {
void this.appendCliTraceFile(traceSession, 'stderr.log', chunk);
stderrChunks.push(chunk.toString('utf8'));
});
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),
);
}
});
proc.on('exit', (code, signal) => {
// 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 () => {
// Tear down the AskUserQuestion bridge / temp `mcp.json` for this
// op. Pending MCP handlers get a `session_ended` cancellation so
// they return cleanly even if CC was killed mid-tool-call.
if (intervention) {
await intervention.cleanup().catch((err) => {
logger.warn('AskUserQuestion cleanup error:', err);
});
}
void this.writeCliTraceJson(traceSession, 'exit.json', {
code,
finishedAt: new Date().toISOString(),
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, stat, writeFile } from 'node:fs/promises';
import { access, mkdir, readdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import {
@@ -12,6 +12,10 @@ import {
type GrepContentParams,
type GrepContentResult,
type ListLocalFileParams,
type ListProjectSkillsParams,
type ListProjectSkillsResult,
type LocalFilePreviewUrlParams,
type LocalFilePreviewUrlResult,
type LocalMoveFilesResultItem,
type LocalReadFileParams,
type LocalReadFileResult,
@@ -118,6 +122,62 @@ const collectProjectDirectories = (files: string[], root: string): ProjectFileIn
return [...directories].map((directory) => createProjectFileEntry(root, directory, true));
};
const SKILL_FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
// Cap recursion to guard against pathological directory trees.
const MAX_SKILL_FILE_COUNT = 1000;
const listSkillFilesRecursive = async (dir: string): Promise<string[]> => {
const results: string[] = [];
const stack: string[] = [dir];
while (stack.length > 0 && results.length < MAX_SKILL_FILE_COUNT) {
const current = stack.pop()!;
let entries;
try {
entries = await readdir(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(full);
} else if (entry.isFile()) {
results.push(toPosixRelativePath(path.relative(dir, full)));
if (results.length >= MAX_SKILL_FILE_COUNT) break;
}
}
}
return results.sort();
};
// Parse a minimal YAML frontmatter block for SKILL.md files.
// Only handles `key: value` lines; multi-line block scalars fall back to the first line.
const parseSkillFrontmatter = (raw: string): Record<string, string> => {
const match = raw.match(SKILL_FRONTMATTER_RE);
if (!match) return {};
const fields: Record<string, string> = {};
for (const line of match[1].split(/\r?\n/)) {
const colonIdx = line.indexOf(':');
if (colonIdx === -1) continue;
const key = line.slice(0, colonIdx).trim();
if (!key || key.startsWith('#')) continue;
let value = line.slice(colonIdx + 1).trim();
if (value.startsWith('|') || value.startsWith('>')) continue;
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
fields[key] = value;
}
return fields;
};
const createDetectedProjectFileEntry = async (
root: string,
absolutePath: string,
@@ -370,6 +430,28 @@ export default class LocalFileCtr extends ControllerModule {
};
}
@IpcMethod()
async getLocalFilePreviewUrl({
path: filePath,
workingDirectory,
}: LocalFilePreviewUrlParams): Promise<LocalFilePreviewUrlResult> {
try {
const url = await this.app.localFileProtocolManager.createPreviewUrl({
filePath,
workspaceRoot: workingDirectory,
});
if (!url) {
return { error: 'File is outside the approved workspace', success: false };
}
return { success: true, url };
} catch (error) {
logger.error('Failed to create local file preview URL:', error);
return { error: (error as Error).message, success: false };
}
}
@IpcMethod()
async handlePrepareSkillDirectory({
forceRefresh,
@@ -532,6 +614,7 @@ export default class LocalFileCtr extends ControllerModule {
requestedScope,
root,
});
await this.approveProjectRootForPreview(root);
return {
entries,
@@ -560,6 +643,7 @@ export default class LocalFileCtr extends ControllerModule {
engine: fallback.engine,
requestedScope,
});
await this.approveProjectRootForPreview(requestedScope);
return {
entries,
@@ -570,6 +654,61 @@ export default class LocalFileCtr extends ControllerModule {
};
}
/**
* Scan agent skill directories under the project root and return parsed
* frontmatter for each SKILL.md. Used by the hetero agent's working sidebar
* to surface skills available in the current project.
*/
@IpcMethod()
async listProjectSkills(params: ListProjectSkillsParams): Promise<ListProjectSkillsResult> {
const root = params.scope;
const sources = ['.agents/skills', '.claude/skills'] as const;
for (const source of sources) {
const dir = path.join(root, source);
try {
const entries = await readdir(dir, { withFileTypes: true });
const skills = (
await Promise.all(
entries
.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
.map(async (entry) => {
const skillDir = path.join(dir, entry.name);
const skillFile = path.join(skillDir, 'SKILL.md');
try {
const raw = await readFile(skillFile, 'utf8');
const fields = parseSkillFrontmatter(raw);
const files = await listSkillFilesRecursive(skillDir);
return {
description: fields.description || undefined,
fileCount: files.length,
files,
name: fields.name || entry.name,
path: skillFile,
skillDir,
source,
};
} catch {
return null;
}
}),
)
)
.filter((skill): skill is NonNullable<typeof skill> => skill !== null)
.sort((a, b) => a.name.localeCompare(b.name));
if (skills.length > 0) {
await this.approveProjectRootForPreview(root);
return { root, skills, source };
}
} catch {
// Directory does not exist or is not readable; try the next candidate.
}
}
return { root, skills: [], source: null };
}
/**
* Handle IPC event for local file search
*/
@@ -641,4 +780,12 @@ export default class LocalFileCtr extends ControllerModule {
logger.debug(`Editing file ${params.file_path}`, { replace_all: params.replace_all });
return editLocalFile(params);
}
private async approveProjectRootForPreview(root: string) {
try {
await this.app.localFileProtocolManager.approveIndexedProjectRoot(root);
} catch (error) {
logger.error(`Failed to approve project preview root ${root}:`, error);
}
}
}
@@ -0,0 +1,43 @@
import type {
DetectAppsResult,
OpenInAppParams,
OpenInAppResult,
} from '@lobechat/electron-client-ipc';
import { getCachedDetection } from '@/modules/openInApp/cache';
import { detectApp } from '@/modules/openInApp/detectors';
import { launchApp } from '@/modules/openInApp/launchers';
import { createLogger } from '@/utils/logger';
import { ControllerModule, IpcMethod } from './index';
const logger = createLogger('controllers:OpenInAppCtr');
export default class OpenInAppCtr extends ControllerModule {
static override readonly groupName = 'openInApp';
@IpcMethod()
async detectApps(): Promise<DetectAppsResult> {
const apps = await getCachedDetection();
return { apps };
}
@IpcMethod()
async openInApp({ appId, path }: OpenInAppParams): Promise<OpenInAppResult> {
// Re-validate installation status before launching: per spec, the main
// process must reject if the app disappeared between probe and launch.
const installed = await detectApp(appId, process.platform);
if (!installed) {
logger.warn(`openInApp: ${appId} reported not installed`);
return { error: `${appId} is not installed`, success: false };
}
const result = await launchApp(appId, path, process.platform);
if (result.success) {
logger.info(`openInApp: launched ${appId} with path ${path}`);
} else {
logger.error(`openInApp: launch failed for ${appId}: ${result.error}`);
}
return result;
}
}
@@ -186,6 +186,19 @@ export default class SystemController extends ControllerModule {
const folderPath = result.filePaths[0];
const repoType = await detectRepoType(folderPath);
try {
const approvedRoot = await this.app.localFileProtocolManager.approveWorkspaceRoot(folderPath);
if (approvedRoot) {
const storedRoots = this.app.storeManager.get('localFileWorkspaceRoots', []);
if (!storedRoots.includes(approvedRoot)) {
this.app.storeManager.set('localFileWorkspaceRoots', [approvedRoot, ...storedRoots]);
}
}
} catch (error) {
logger.error(`Failed to approve local file workspace root ${folderPath}:`, error);
}
return { path: folderPath, repoType };
}
@@ -1,6 +1,6 @@
import { EventEmitter } from 'node:events';
import { access, mkdtemp, readdir, readFile, rm, unlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import * as os from 'node:os';
import path from 'node:path';
import { PassThrough } from 'node:stream';
@@ -9,6 +9,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import HeterogeneousAgentCtr from '../HeterogeneousAgentCtr';
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof os>('node:os');
return { ...actual, platform: vi.fn(() => 'linux') };
});
const FAKE_DESKTOP_PATH = '/Users/fake/Desktop';
const { mockGetAllWindows } = vi.hoisted(() => ({
@@ -111,7 +116,7 @@ describe('HeterogeneousAgentCtr', () => {
let appStoragePath: string;
beforeEach(async () => {
appStoragePath = await mkdtemp(path.join(tmpdir(), 'lobehub-hetero-'));
appStoragePath = await mkdtemp(path.join(os.tmpdir(), 'lobehub-hetero-'));
});
afterEach(async () => {
@@ -817,7 +822,7 @@ describe('HeterogeneousAgentCtr', () => {
* it like a real pending intervention and tries to unlink it.
*/
const seedPendingIntervention = async (ctr: HeterogeneousAgentCtr, opId: string) => {
const tmpConfigPath = path.join(tmpdir(), `lobe-cc-mcp-test-${opId}.json`);
const tmpConfigPath = path.join(os.tmpdir(), `lobe-cc-mcp-test-${opId}.json`);
await writeFile(tmpConfigPath, '{"mcpServers":{}}');
const slot = {
bridge: {} as any,
@@ -84,6 +84,12 @@ const mockContentSearchService = {
checkToolAvailable: vi.fn(),
};
const mockLocalFileProtocolManager = {
approveIndexedProjectRoot: vi.fn(),
approveProjectRootFromScope: vi.fn(),
createPreviewUrl: vi.fn(),
};
// Mock makeSureDirExist
vi.mock('@/utils/file-system', () => ({
makeSureDirExist: vi.fn(),
@@ -98,6 +104,7 @@ const mockApp = {
}
return mockSearchService;
}),
localFileProtocolManager: mockLocalFileProtocolManager,
toolDetectorManager: {
getBestTool: vi.fn(() => null), // No external tools available, use Node.js fallback
},
@@ -180,6 +187,42 @@ describe('LocalFileCtr', () => {
// they exercise real fs + file-loaders without fighting the heavy mocks
// this suite needs for execa-driven tools, electron, and the like.
describe('getLocalFilePreviewUrl', () => {
it('should return a main-issued preview URL for an approved workspace file', async () => {
mockLocalFileProtocolManager.createPreviewUrl.mockResolvedValue(
'localfile://file/workspace/app.ts?token=abc',
);
const result = await localFileCtr.getLocalFilePreviewUrl({
path: '/workspace/app.ts',
workingDirectory: '/workspace',
});
expect(mockLocalFileProtocolManager.createPreviewUrl).toHaveBeenCalledWith({
filePath: '/workspace/app.ts',
workspaceRoot: '/workspace',
});
expect(result).toEqual({
success: true,
url: 'localfile://file/workspace/app.ts?token=abc',
});
});
it('should reject preview URL creation outside an approved workspace', async () => {
mockLocalFileProtocolManager.createPreviewUrl.mockResolvedValue(null);
const result = await localFileCtr.getLocalFilePreviewUrl({
path: '/Users/alice/.ssh/id_rsa',
workingDirectory: '/workspace',
});
expect(result).toEqual({
error: 'File is outside the approved workspace',
success: false,
});
});
});
describe('handleWriteFile', () => {
it('should write file successfully', async () => {
vi.mocked(mockFsPromises.mkdir).mockResolvedValue(undefined);
@@ -0,0 +1,147 @@
import type { DetectedApp, OpenInAppResult } from '@lobechat/electron-client-ipc';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { App } from '@/core/App';
import type { IpcContext } from '@/utils/ipc';
import { IpcHandler } from '@/utils/ipc/base';
import OpenInAppCtr from '../OpenInAppCtr';
const { getCachedDetectionMock, detectAppMock, launchAppMock, ipcHandlers, ipcMainHandleMock } =
vi.hoisted(() => {
const handlers = new Map<string, (event: any, ...args: any[]) => any>();
const handle = vi.fn((channel: string, handler: any) => {
handlers.set(channel, handler);
});
return {
detectAppMock: vi.fn(),
getCachedDetectionMock: vi.fn(),
ipcHandlers: handlers,
ipcMainHandleMock: handle,
launchAppMock: vi.fn(),
};
});
const invokeIpc = async <T = any>(
channel: string,
payload?: any,
context?: Partial<IpcContext>,
): Promise<T> => {
const handler = ipcHandlers.get(channel);
if (!handler) throw new Error(`IPC handler for ${channel} not found`);
const fakeEvent = {
sender: context?.sender ?? ({ id: 'test' } as any),
};
if (payload === undefined) {
return handler(fakeEvent);
}
return handler(fakeEvent, payload);
};
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
}),
}));
vi.mock('electron', () => ({
ipcMain: {
handle: ipcMainHandleMock,
},
}));
vi.mock('@/modules/openInApp/cache', () => ({
getCachedDetection: getCachedDetectionMock,
}));
vi.mock('@/modules/openInApp/detectors', () => ({
detectApp: detectAppMock,
}));
vi.mock('@/modules/openInApp/launchers', () => ({
launchApp: launchAppMock,
}));
const mockApp = {} as unknown as App;
describe('OpenInAppCtr', () => {
beforeEach(() => {
vi.clearAllMocks();
ipcHandlers.clear();
ipcMainHandleMock.mockClear();
(IpcHandler.getInstance() as any).registeredChannels?.clear();
new OpenInAppCtr(mockApp);
});
describe('detectApps', () => {
it('should call getCachedDetection and return the apps list', async () => {
const apps: DetectedApp[] = [
{ displayName: 'Visual Studio Code', id: 'vscode', installed: true },
{ displayName: 'Cursor', id: 'cursor', installed: false },
];
getCachedDetectionMock.mockResolvedValue(apps);
const result = await invokeIpc('openInApp.detectApps');
expect(getCachedDetectionMock).toHaveBeenCalledTimes(1);
expect(result).toEqual({ apps });
});
});
describe('openInApp', () => {
it('should launch the app when installed', async () => {
detectAppMock.mockResolvedValue(true);
const launchResult: OpenInAppResult = { success: true };
launchAppMock.mockResolvedValue(launchResult);
const result = await invokeIpc('openInApp.openInApp', {
appId: 'vscode',
path: '/tmp/project',
});
expect(detectAppMock).toHaveBeenCalledWith('vscode', process.platform);
expect(launchAppMock).toHaveBeenCalledWith('vscode', '/tmp/project', process.platform);
expect(result).toEqual({ success: true });
});
it('should not launch and return error when app is not installed', async () => {
detectAppMock.mockResolvedValue(false);
const result = await invokeIpc('openInApp.openInApp', {
appId: 'cursor',
path: '/tmp/project',
});
expect(detectAppMock).toHaveBeenCalledWith('cursor', process.platform);
expect(launchAppMock).not.toHaveBeenCalled();
expect(result).toEqual({
error: 'cursor is not installed',
success: false,
});
});
it('should pass through launch errors when launchApp fails', async () => {
detectAppMock.mockResolvedValue(true);
const launchResult: OpenInAppResult = {
error: 'Path not found: /tmp/missing',
success: false,
};
launchAppMock.mockResolvedValue(launchResult);
const result = await invokeIpc('openInApp.openInApp', {
appId: 'vscode',
path: '/tmp/missing',
});
expect(detectAppMock).toHaveBeenCalledWith('vscode', process.platform);
expect(launchAppMock).toHaveBeenCalledWith('vscode', '/tmp/missing', process.platform);
expect(result).toEqual(launchResult);
});
});
});
@@ -13,6 +13,7 @@ import McpInstallCtr from './McpInstallCtr';
import MenuController from './MenuCtr';
import NetworkProxyCtr from './NetworkProxyCtr';
import NotificationCtr from './NotificationCtr';
import OpenInAppCtr from './OpenInAppCtr';
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
import RemoteServerSyncCtr from './RemoteServerSyncCtr';
import ScreenCaptureCtr from './ScreenCaptureCtr';
@@ -37,6 +38,7 @@ export const controllerIpcConstructors = [
MenuController,
NetworkProxyCtr,
NotificationCtr,
OpenInAppCtr,
RemoteServerConfigCtr,
RemoteServerSyncCtr,
ScreenCaptureCtr,
+11
View File
@@ -31,6 +31,7 @@ import { createLogger } from '@/utils/logger';
import { BrowserManager } from './browser/BrowserManager';
import { I18nManager } from './infrastructure/I18nManager';
import { IoCContainer } from './infrastructure/IoCContainer';
import { LocalFileProtocolManager } from './infrastructure/LocalFileProtocolManager';
import { ProtocolManager } from './infrastructure/ProtocolManager';
import { RendererUrlManager } from './infrastructure/RendererUrlManager';
import { StaticFileServerManager } from './infrastructure/StaticFileServerManager';
@@ -62,6 +63,7 @@ export class App {
staticFileServerManager: StaticFileServerManager;
protocolManager: ProtocolManager;
rendererUrlManager: RendererUrlManager;
localFileProtocolManager: LocalFileProtocolManager;
toolDetectorManager: ToolDetectorManager;
screenCaptureManager: ScreenCaptureManager;
chromeFlags: string[] = ['OverlayScrollbar', 'FluentOverlayScrollbar', 'FluentScrollbar'];
@@ -102,6 +104,10 @@ export class App {
this.storeManager = new StoreManager(this);
this.rendererUrlManager = new RendererUrlManager();
this.localFileProtocolManager = new LocalFileProtocolManager();
void this.localFileProtocolManager.approveWorkspaceRoots(
this.storeManager.get('localFileWorkspaceRoots', []),
);
protocol.registerSchemesAsPrivileged([
{
privileges: {
@@ -114,6 +120,7 @@ export class App {
scheme: ELECTRON_BE_PROTOCOL_SCHEME,
},
this.rendererUrlManager.protocolScheme,
this.localFileProtocolManager.protocolScheme,
]);
// load controllers
@@ -152,6 +159,10 @@ export class App {
// should register before app ready
this.rendererUrlManager.configureRendererLoader();
// Serves arbitrary local files (e.g. project file previews) via
// `localfile://` to the renderer. Active in both dev and prod.
this.localFileProtocolManager.registerHandler();
// initialize protocol handlers
this.protocolManager.initialize();
@@ -115,9 +115,9 @@ vi.mock('../infrastructure/I18nManager', () => ({
vi.mock('../infrastructure/StoreManager', () => ({
StoreManager: vi.fn().mockImplementation(() => ({
get: vi.fn((key) => {
if (key === 'storagePath') return '/mock/storage/path';
return undefined;
get: vi.fn((_key, defaultValue) => {
if (_key === 'storagePath') return '/mock/storage/path';
return defaultValue;
}),
set: vi.fn(),
})),
@@ -0,0 +1,327 @@
import { randomUUID } from 'node:crypto';
import { readFile, realpath, stat } from 'node:fs/promises';
import path from 'node:path';
import { app, protocol } from 'electron';
import { LOCAL_FILE_PROTOCOL_HOST, LOCAL_FILE_PROTOCOL_SCHEME } from '@/const/protocol';
import { createLogger } from '@/utils/logger';
import { getExportMimeType } from '../../utils/mime';
const LOCAL_FILE_PROTOCOL_PRIVILEGES = {
allowServiceWorkers: false,
bypassCSP: false,
corsEnabled: true,
secure: true,
standard: true,
stream: true,
supportFetchAPI: true,
} as const;
const logger = createLogger('core:LocalFileProtocolManager');
const PREVIEW_TOKEN_TTL_MS = 5 * 60 * 1000;
const EXTRA_MIME_TYPES: Record<string, string> = {
'.avif': 'image/avif',
'.bmp': 'image/bmp',
'.heic': 'image/heic',
'.heif': 'image/heif',
'.tif': 'image/tiff',
'.tiff': 'image/tiff',
};
const getMimeType = (filePath: string): string => {
const ext = path.extname(filePath).toLowerCase();
return getExportMimeType(filePath) ?? EXTRA_MIME_TYPES[ext] ?? 'application/octet-stream';
};
const normalizeAbsolutePath = (filePath: string): string | null => {
const normalized = path.normalize(filePath);
return path.isAbsolute(normalized) ? normalized : null;
};
const isPathWithinRoot = (targetPath: string, rootPath: string): boolean => {
const relative = path.relative(rootPath, targetPath);
return (
relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative))
);
};
const buildLocalFileUrl = (absolutePath: string, token: string): string => {
const forwardSlashed = absolutePath.replaceAll('\\', '/');
const stripped = forwardSlashed.startsWith('/') ? forwardSlashed.slice(1) : forwardSlashed;
const encoded = stripped.split('/').map(encodeURIComponent).join('/');
const url = new URL(`${LOCAL_FILE_PROTOCOL_SCHEME}://${LOCAL_FILE_PROTOCOL_HOST}/${encoded}`);
url.searchParams.set('token', token);
return url.toString();
};
interface PreviewTokenRecord {
expiresAt: number;
realPath: string;
}
/**
* Custom `localfile://` protocol for project file previews.
*
* URL shape: `localfile://file/<percent-encoded-absolute-path>?token=<main-issued-token>`
* - host is fixed to `file` so the scheme behaves as `standard`
* - the absolute path is encoded in the URL pathname
* - every request must carry a short-lived token minted by the main process
*
* Examples:
* localfile://file//Users/alice/project/cat.png?token=...
* localfile://file/C:/Users/alice/project/cat.png?token=...
*/
export class LocalFileProtocolManager {
private readonly approvedWorkspaceRoots = new Set<string>();
private readonly indexedProjectRoots = new Set<string>();
private handlerRegistered = false;
private readonly previewTokens = new Map<string, PreviewTokenRecord>();
get protocolScheme() {
return {
privileges: LOCAL_FILE_PROTOCOL_PRIVILEGES,
scheme: LOCAL_FILE_PROTOCOL_SCHEME,
};
}
registerHandler() {
if (this.handlerRegistered) return;
const register = () => {
if (this.handlerRegistered) return;
protocol.handle(LOCAL_FILE_PROTOCOL_SCHEME, async (request) => {
try {
const url = new URL(request.url);
if (url.hostname !== LOCAL_FILE_PROTOCOL_HOST) {
return new Response('Not Found', { status: 404 });
}
const resolvedPath = this.resolveFilePath(url.pathname);
if (!resolvedPath) {
return new Response('Invalid path', { status: 400 });
}
const token = url.searchParams.get('token');
if (!token) {
return new Response('Forbidden', { status: 403 });
}
if (!this.hasPreviewToken(token)) {
return new Response('Forbidden', { status: 403 });
}
const realResolvedPath = normalizeAbsolutePath(await realpath(resolvedPath));
if (!realResolvedPath || !this.verifyPreviewToken(token, realResolvedPath)) {
return new Response('Forbidden', { status: 403 });
}
const fileStat = await stat(realResolvedPath);
if (!fileStat.isFile()) {
return new Response('Not a file', { status: 404 });
}
const buffer = await readFile(realResolvedPath);
const headers = new Headers();
headers.set('Content-Type', getMimeType(realResolvedPath));
headers.set('Content-Length', String(buffer.byteLength));
// Local files are immutable from the renderer's perspective for a
// single preview session; allow short-lived caching to avoid
// re-reading large images during scrolling/refresh.
headers.set('Cache-Control', 'private, max-age=60');
return new Response(buffer, { headers, status: 200 });
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT' || code === 'ENOTDIR') {
return new Response('Not Found', { status: 404 });
}
if (code === 'EACCES' || code === 'EPERM') {
return new Response('Forbidden', { status: 403 });
}
logger.error(`Failed to serve localfile request ${request.url}:`, error);
return new Response('Internal Server Error', { status: 500 });
}
});
this.handlerRegistered = true;
logger.debug(`Registered ${LOCAL_FILE_PROTOCOL_SCHEME}:// handler`);
};
if (app.isReady()) {
register();
} else {
app.whenReady().then(register);
}
}
async approveWorkspaceRoot(rootPath: string): Promise<string | null> {
const normalizedRoot = normalizeAbsolutePath(rootPath);
if (!normalizedRoot) return null;
const realRoot = normalizeAbsolutePath(await realpath(normalizedRoot));
if (!realRoot) return null;
this.approvedWorkspaceRoots.add(realRoot);
return realRoot;
}
async approveWorkspaceRoots(rootPaths: string[] = []): Promise<string[]> {
const approvedRoots = await Promise.allSettled(
rootPaths.map((rootPath) => this.approveWorkspaceRoot(rootPath)),
);
return approvedRoots
.map((result) => (result.status === 'fulfilled' ? result.value : null))
.filter((rootPath): rootPath is string => !!rootPath);
}
async approveProjectRootFromScope({
projectRoot,
requestedScope,
}: {
projectRoot: string;
requestedScope: string;
}): Promise<string | null> {
const [realProjectRoot, realRequestedScope] = await Promise.all([
realpath(projectRoot),
realpath(requestedScope),
]);
const normalizedProjectRoot = normalizeAbsolutePath(realProjectRoot);
const normalizedRequestedScope = normalizeAbsolutePath(realRequestedScope);
if (!normalizedProjectRoot || !normalizedRequestedScope) return null;
const scopeIsApproved = [...this.approvedWorkspaceRoots].some(
(approvedRoot) =>
normalizedRequestedScope === approvedRoot ||
isPathWithinRoot(normalizedRequestedScope, approvedRoot),
);
if (!scopeIsApproved) return null;
this.approvedWorkspaceRoots.add(normalizedProjectRoot);
return normalizedProjectRoot;
}
async approveIndexedProjectRoot(projectRoot: string): Promise<string | null> {
const normalizedProjectRoot = normalizeAbsolutePath(projectRoot);
if (!normalizedProjectRoot) return null;
const realProjectRoot = normalizeAbsolutePath(await realpath(normalizedProjectRoot));
if (!realProjectRoot) return null;
this.indexedProjectRoots.add(realProjectRoot);
return realProjectRoot;
}
async createPreviewUrl({
filePath,
workspaceRoot,
}: {
filePath: string;
workspaceRoot: string;
}): Promise<string | null> {
const normalizedFilePath = normalizeAbsolutePath(filePath);
const normalizedWorkspaceRoot = normalizeAbsolutePath(workspaceRoot);
if (!normalizedFilePath || !normalizedWorkspaceRoot) return null;
const [realFilePath, realWorkspaceRoot] = await Promise.all([
realpath(normalizedFilePath),
realpath(normalizedWorkspaceRoot),
]);
const normalizedRealFilePath = normalizeAbsolutePath(realFilePath);
const normalizedRealWorkspaceRoot = normalizeAbsolutePath(realWorkspaceRoot);
if (!normalizedRealFilePath || !normalizedRealWorkspaceRoot) return null;
if (
!this.approvedWorkspaceRoots.has(normalizedRealWorkspaceRoot) &&
!this.indexedProjectRoots.has(normalizedRealWorkspaceRoot)
) {
return null;
}
if (!isPathWithinRoot(normalizedRealFilePath, normalizedRealWorkspaceRoot)) return null;
this.cleanupExpiredTokens();
const token = randomUUID();
this.previewTokens.set(token, {
expiresAt: Date.now() + PREVIEW_TOKEN_TTL_MS,
realPath: normalizedRealFilePath,
});
return buildLocalFileUrl(normalizedFilePath, token);
}
/**
* Decode the URL pathname back into an absolute filesystem path.
*
* Pathname examples produced by `new URL('localfile://file//abs/path')`:
* posix: `//abs/path` -> `/abs/path`
* windows: `/C:/abs/path` -> `C:/abs/path`
*
* Returns null when the path is non-absolute or escapes via segments we
* cannot safely normalize (defense-in-depth, not a sandbox).
*/
private resolveFilePath(pathname: string): string | null {
let decoded: string;
try {
decoded = decodeURIComponent(pathname);
} catch {
return null;
}
// Strip the single leading slash inserted by URL parsing on standard
// schemes; what remains should already be an absolute filesystem path.
let candidate = decoded.startsWith('/') ? decoded.slice(1) : decoded;
if (!candidate) return null;
if (process.platform === 'win32') {
// posix-style absolute path won't have a drive letter; treat as invalid
// on Windows.
candidate = candidate.replaceAll('/', '\\');
} else if (!candidate.startsWith('/')) {
// We expect an absolute POSIX path: `localfile://file//abs/path` yields
// pathname `//abs/path` -> after stripping one slash -> `/abs/path`.
candidate = `/${candidate}`;
}
const normalized = path.normalize(candidate);
if (!path.isAbsolute(normalized)) return null;
return normalized;
}
private cleanupExpiredTokens() {
const now = Date.now();
for (const [token, record] of this.previewTokens) {
if (record.expiresAt <= now) {
this.previewTokens.delete(token);
}
}
}
private hasPreviewToken(token: string): boolean {
const record = this.previewTokens.get(token);
if (!record) return false;
if (record.expiresAt <= Date.now()) {
this.previewTokens.delete(token);
return false;
}
return true;
}
private verifyPreviewToken(token: string, realResolvedPath: string): boolean {
const record = this.previewTokens.get(token);
if (!record) return false;
return record.realPath === realResolvedPath;
}
}
@@ -0,0 +1,298 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { LocalFileProtocolManager } from '../LocalFileProtocolManager';
const { mockApp, mockProtocol, mockReadFile, mockRealpath, mockStat, protocolHandlerRef } =
vi.hoisted(() => {
const protocolHandlerRef = { current: null as any };
return {
mockApp: {
isReady: vi.fn().mockReturnValue(true),
whenReady: vi.fn().mockResolvedValue(undefined),
},
mockProtocol: {
handle: vi.fn((_scheme: string, handler: any) => {
protocolHandlerRef.current = handler;
}),
},
mockReadFile: vi.fn(),
mockRealpath: vi.fn(),
mockStat: vi.fn(),
protocolHandlerRef,
};
});
vi.mock('electron', () => ({
app: mockApp,
protocol: mockProtocol,
}));
vi.mock('node:fs/promises', () => ({
realpath: mockRealpath,
readFile: mockReadFile,
stat: mockStat,
}));
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
}),
}));
describe('LocalFileProtocolManager', () => {
beforeEach(() => {
vi.clearAllMocks();
protocolHandlerRef.current = null;
mockApp.isReady.mockReturnValue(true);
mockRealpath.mockImplementation(async (filePath: string) => filePath);
mockStat.mockImplementation(async () => ({ isFile: () => true, size: 1024 }));
mockReadFile.mockImplementation(async () => Buffer.from('image-bytes'));
});
afterEach(() => {
protocolHandlerRef.current = null;
});
it('exposes scheme metadata for registerSchemesAsPrivileged', () => {
const manager = new LocalFileProtocolManager();
expect(manager.protocolScheme).toEqual({
privileges: expect.objectContaining({
bypassCSP: false,
secure: true,
standard: true,
supportFetchAPI: true,
}),
scheme: 'localfile',
});
});
it('serves a POSIX absolute path with the correct mime type', async () => {
const manager = new LocalFileProtocolManager();
manager.registerHandler();
await manager.approveWorkspaceRoot('/Users/alice');
const url = await manager.createPreviewUrl({
filePath: '/Users/alice/Pictures/cat.png',
workspaceRoot: '/Users/alice',
});
if (!url) throw new Error('Expected local file preview URL');
expect(mockProtocol.handle).toHaveBeenCalledWith('localfile', expect.any(Function));
const handler = protocolHandlerRef.current;
const response = await handler({
headers: new Headers(),
method: 'GET',
url,
});
expect(mockStat).toHaveBeenCalledWith('/Users/alice/Pictures/cat.png');
expect(mockReadFile).toHaveBeenCalledWith('/Users/alice/Pictures/cat.png');
expect(response.status).toBe(200);
expect(response.headers.get('Content-Type')).toBe('image/png');
expect(response.headers.get('Content-Length')).toBe('11'); // 'image-bytes'.length
});
it('serves source files as text through the localfile protocol', async () => {
const manager = new LocalFileProtocolManager();
manager.registerHandler();
await manager.approveWorkspaceRoot('/Users/alice/project');
const url = await manager.createPreviewUrl({
filePath: '/Users/alice/project/App.tsx',
workspaceRoot: '/Users/alice/project',
});
if (!url) throw new Error('Expected local file preview URL');
const handler = protocolHandlerRef.current;
const response = await handler({
headers: new Headers(),
method: 'GET',
url,
});
expect(mockStat).toHaveBeenCalledWith('/Users/alice/project/App.tsx');
expect(mockReadFile).toHaveBeenCalledWith('/Users/alice/project/App.tsx');
expect(response.status).toBe(200);
expect(response.headers.get('Content-Type')).toBe('text/plain; charset=utf-8');
});
it('decodes percent-encoded characters in the path', async () => {
const manager = new LocalFileProtocolManager();
manager.registerHandler();
await manager.approveWorkspaceRoot('/Users/alice');
const url = await manager.createPreviewUrl({
filePath: '/Users/alice/My Pictures/图 #.png',
workspaceRoot: '/Users/alice',
});
if (!url) throw new Error('Expected local file preview URL');
const handler = protocolHandlerRef.current;
await handler({
headers: new Headers(),
method: 'GET',
url,
});
expect(mockStat).toHaveBeenCalledWith('/Users/alice/My Pictures/图 #.png');
});
it('rejects requests to a different host', async () => {
const manager = new LocalFileProtocolManager();
manager.registerHandler();
const handler = protocolHandlerRef.current;
const response = await handler({
headers: new Headers(),
method: 'GET',
url: 'localfile://other/Users/alice/cat.png',
});
expect(response.status).toBe(404);
expect(mockStat).not.toHaveBeenCalled();
});
it('returns 404 when the path is a directory', async () => {
mockStat.mockImplementation(async () => ({ isFile: () => false, size: 0 }));
const manager = new LocalFileProtocolManager();
manager.registerHandler();
await manager.approveWorkspaceRoot('/Users/alice');
const url = await manager.createPreviewUrl({
filePath: '/Users/alice/folder',
workspaceRoot: '/Users/alice',
});
if (!url) throw new Error('Expected local file preview URL');
const handler = protocolHandlerRef.current;
const response = await handler({
headers: new Headers(),
method: 'GET',
url,
});
expect(response.status).toBe(404);
expect(mockReadFile).not.toHaveBeenCalled();
});
it('maps ENOENT errors to a 404 response', async () => {
mockStat.mockImplementation(async () => {
const err: NodeJS.ErrnoException = new Error('no such file');
err.code = 'ENOENT';
throw err;
});
const manager = new LocalFileProtocolManager();
manager.registerHandler();
await manager.approveWorkspaceRoot('/');
const handler = protocolHandlerRef.current;
const url = await manager.createPreviewUrl({
filePath: '/nonexistent.png',
workspaceRoot: '/',
});
if (!url) throw new Error('Expected local file preview URL');
const response = await handler({
headers: new Headers(),
method: 'GET',
url,
});
expect(response.status).toBe(404);
});
it('rejects direct localfile requests without a main-issued preview token', async () => {
const manager = new LocalFileProtocolManager();
manager.registerHandler();
const handler = protocolHandlerRef.current;
const response = await handler({
headers: new Headers(),
method: 'GET',
url: 'localfile://file/Users/alice/.ssh/id_rsa',
});
expect(response.status).toBe(403);
expect(mockStat).not.toHaveBeenCalled();
expect(mockReadFile).not.toHaveBeenCalled();
});
it('rejects forged preview tokens before resolving the requested path', async () => {
const manager = new LocalFileProtocolManager();
manager.registerHandler();
const handler = protocolHandlerRef.current;
const response = await handler({
headers: new Headers(),
method: 'GET',
url: 'localfile://file/Users/alice/.ssh/id_rsa?token=forged',
});
expect(response.status).toBe(403);
expect(mockRealpath).not.toHaveBeenCalled();
expect(mockStat).not.toHaveBeenCalled();
expect(mockReadFile).not.toHaveBeenCalled();
});
it('does not mint preview URLs outside an approved workspace root', async () => {
const manager = new LocalFileProtocolManager();
await manager.approveWorkspaceRoot('/Users/alice/project');
const url = await manager.createPreviewUrl({
filePath: '/Users/alice/.ssh/id_rsa',
workspaceRoot: '/Users/alice/project',
});
expect(url).toBeNull();
});
it('can approve a project root derived from an already approved nested scope', async () => {
const manager = new LocalFileProtocolManager();
await manager.approveWorkspaceRoot('/Users/alice/project/packages/app');
await manager.approveProjectRootFromScope({
projectRoot: '/Users/alice/project',
requestedScope: '/Users/alice/project/packages/app',
});
const url = await manager.createPreviewUrl({
filePath: '/Users/alice/project/root.ts',
workspaceRoot: '/Users/alice/project',
});
if (!url) throw new Error('Expected local file preview URL');
expect(url).toContain('token=');
});
it('can mint preview URLs for roots produced by the main-process project index', async () => {
const manager = new LocalFileProtocolManager();
await manager.approveIndexedProjectRoot('/Users/alice/project');
const url = await manager.createPreviewUrl({
filePath: '/Users/alice/project/App.tsx',
workspaceRoot: '/Users/alice/project',
});
if (!url) throw new Error('Expected local file preview URL');
expect(url).toContain('token=');
});
it('defers registration until app ready when not yet ready', async () => {
mockApp.isReady.mockReturnValue(false);
let resolveReady: () => void = () => undefined;
mockApp.whenReady.mockReturnValue(
new Promise<void>((resolve) => {
resolveReady = resolve;
}),
);
const manager = new LocalFileProtocolManager();
manager.registerHandler();
expect(mockProtocol.handle).not.toHaveBeenCalled();
resolveReady();
await new Promise((r) => setImmediate(r));
expect(mockProtocol.handle).toHaveBeenCalled();
});
});
@@ -49,6 +49,10 @@ class TestContentSearch extends BaseContentSearch {
public testGetDefaultIgnorePatterns(): string[] {
return this.getDefaultIgnorePatterns();
}
public testResolveSearchPath(params: GrepContentParams): string {
return this.resolveSearchPath(params);
}
}
describe('BaseContentSearch', () => {
@@ -255,6 +259,33 @@ describe('BaseContentSearch', () => {
});
});
describe('resolveSearchPath', () => {
it('prefers scope when path is not set', () => {
const resolved = contentSearch.testResolveSearchPath({
pattern: 'x',
scope: '/Users/arvinxx/repo',
});
expect(resolved).toBe('/Users/arvinxx/repo');
});
it('honors legacy path over scope when both are set', () => {
const resolved = contentSearch.testResolveSearchPath({
path: '/legacy/path',
pattern: 'x',
scope: '/scope/path',
});
expect(resolved).toBe('/legacy/path');
});
it('falls back to process.cwd() when neither is provided', () => {
const resolved = contentSearch.testResolveSearchPath({ pattern: 'x' });
expect(resolved).toBe(process.cwd());
});
});
describe('getDefaultIgnorePatterns', () => {
it('should return default ignore patterns', () => {
const patterns = contentSearch.testGetDefaultIgnorePatterns();
@@ -46,6 +46,18 @@ export abstract class BaseContentSearch {
*/
abstract checkToolAvailable(tool: string): Promise<boolean>;
/**
* Resolve the directory to run the search in.
*
* The builtin-tool manifest documents `scope`, while the legacy IPC type also accepts
* `path`. Read both so an agent calling with `scope` (per the manifest) doesn't silently
* fall through to `process.cwd()` — which in a packaged Electron app isn't the project
* root and therefore has no `.gitignore` for ripgrep to honor.
*/
protected resolveSearchPath(params: GrepContentParams): string {
return params.path ?? params.scope ?? process.cwd();
}
/**
* Build command-line arguments for grep tools
*/
@@ -141,11 +153,8 @@ export abstract class BaseContentSearch {
* Grep using Node.js native implementation (fallback)
*/
protected async grepWithNodejs(params: GrepContentParams): Promise<GrepContentResult> {
const {
pattern,
path: searchPath = process.cwd(),
output_mode = 'files_with_matches',
} = params;
const { pattern, output_mode = 'files_with_matches' } = params;
const searchPath = this.resolveSearchPath(params);
const logPrefix = `[grepContent:nodejs]`;
const flags = `${params['-i'] ? 'i' : ''}${params.multiline ? 's' : ''}`;
@@ -1,4 +1,3 @@
import type { GrepContentParams, GrepContentResult } from '@lobechat/electron-client-ipc';
import { execa } from 'execa';
@@ -179,7 +178,8 @@ export abstract class UnixContentSearch extends BaseContentSearch {
tool: 'rg' | 'ag' | 'grep',
params: GrepContentParams,
): Promise<GrepContentResult> {
const { path: searchPath = process.cwd(), output_mode = 'files_with_matches' } = params;
const { output_mode = 'files_with_matches' } = params;
const searchPath = this.resolveSearchPath(params);
const logPrefix = `[grepContent:${tool}]`;
try {
@@ -272,7 +272,7 @@ export abstract class UnixContentSearch extends BaseContentSearch {
try {
const { stdout } = await execa(tool, args, {
cwd: params.path || process.cwd(),
cwd: this.resolveSearchPath(params),
reject: false,
});
@@ -1,4 +1,3 @@
import type { GrepContentParams, GrepContentResult } from '@lobechat/electron-client-ipc';
import { execa } from 'execa';
@@ -146,7 +145,8 @@ export class WindowsContentSearchImpl extends BaseContentSearch {
* Grep using ripgrep (rg) - cross-platform
*/
private async grepWithRipgrep(params: GrepContentParams): Promise<GrepContentResult> {
const { path: searchPath = process.cwd(), output_mode = 'files_with_matches' } = params;
const { output_mode = 'files_with_matches' } = params;
const searchPath = this.resolveSearchPath(params);
const logPrefix = `[grepContent:rg]`;
try {
@@ -230,7 +230,7 @@ export class WindowsContentSearchImpl extends BaseContentSearch {
try {
const { stdout } = await execa('rg', args, {
cwd: params.path || process.cwd(),
cwd: this.resolveSearchPath(params),
reject: false,
});
@@ -252,11 +252,8 @@ export class WindowsContentSearchImpl extends BaseContentSearch {
* Note: findstr has limited functionality compared to ripgrep
*/
private async grepWithFindstr(params: GrepContentParams): Promise<GrepContentResult> {
const {
pattern,
path: searchPath = process.cwd(),
output_mode = 'files_with_matches',
} = params;
const { pattern, output_mode = 'files_with_matches' } = params;
const searchPath = this.resolveSearchPath(params);
const logPrefix = `[grepContent:findstr]`;
try {
@@ -0,0 +1,87 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { clearDetectionCache, getCachedDetection } from '../cache';
import { detectAllApps } from '../detectors';
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
}),
}));
vi.mock('../detectors', () => ({
detectAllApps: vi.fn(),
}));
const mockedDetectAll = vi.mocked(detectAllApps);
beforeEach(() => {
vi.clearAllMocks();
clearDetectionCache();
});
describe('getCachedDetection', () => {
it('invokes detection on first call', async () => {
mockedDetectAll.mockResolvedValueOnce([
{ displayName: 'VS Code', id: 'vscode', installed: true },
]);
const result = await getCachedDetection('darwin');
expect(result).toEqual([{ displayName: 'VS Code', id: 'vscode', installed: true }]);
expect(mockedDetectAll).toHaveBeenCalledTimes(1);
});
it('concurrent callers share a single inflight promise', async () => {
let resolveFn: (value: any) => void = () => {};
const inflight = new Promise<any>((resolve) => {
resolveFn = resolve;
});
mockedDetectAll.mockReturnValueOnce(inflight);
const p1 = getCachedDetection('darwin');
const p2 = getCachedDetection('darwin');
const p3 = getCachedDetection('darwin');
expect(mockedDetectAll).toHaveBeenCalledTimes(1);
resolveFn([{ displayName: 'VS Code', id: 'vscode', installed: true }]);
const results = await Promise.all([p1, p2, p3]);
// all three share the same resolved value
expect(results[0]).toBe(results[1]);
expect(results[1]).toBe(results[2]);
expect(mockedDetectAll).toHaveBeenCalledTimes(1);
});
it('subsequent serial calls reuse the cached promise', async () => {
mockedDetectAll.mockResolvedValueOnce([
{ displayName: 'VS Code', id: 'vscode', installed: true },
]);
await getCachedDetection('darwin');
await getCachedDetection('darwin');
await getCachedDetection('darwin');
expect(mockedDetectAll).toHaveBeenCalledTimes(1);
});
it('re-invokes detection after clearDetectionCache', async () => {
mockedDetectAll.mockResolvedValueOnce([
{ displayName: 'VS Code', id: 'vscode', installed: true },
]);
await getCachedDetection('darwin');
expect(mockedDetectAll).toHaveBeenCalledTimes(1);
clearDetectionCache();
mockedDetectAll.mockResolvedValueOnce([
{ displayName: 'VS Code', id: 'vscode', installed: false },
]);
await getCachedDetection('darwin');
expect(mockedDetectAll).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,274 @@
import { execFile } from 'node:child_process';
import { access } from 'node:fs/promises';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { detectAllApps, detectApp } from '../detectors';
import { extractAllIcons } from '../iconExtractor';
// Mock logger
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
}),
}));
// Mock node:fs/promises
vi.mock('node:fs/promises', () => ({
access: vi.fn(),
}));
// Mock node:child_process - execFile is wrapped via promisify, so the mock must
// expose execFile as the underlying callback-style function we can drive.
vi.mock('node:child_process', () => ({
execFile: vi.fn(),
}));
// Mock the icon extractor — detection tests should not depend on real icon
// extraction. The default returns an empty Map (no icons) which leaves the
// `icon` field absent from all detection results.
vi.mock('../iconExtractor', () => ({
extractAllIcons: vi.fn(async () => new Map<string, string>()),
}));
const mockedAccess = vi.mocked(access);
const mockedExecFile = vi.mocked(execFile) as unknown as ReturnType<typeof vi.fn>;
interface ExecOutcome {
code: number;
error?: NodeJS.ErrnoException;
stderr?: string;
stdout?: string;
}
const respondExec = (outcome: ExecOutcome) => {
mockedExecFile.mockImplementationOnce(
(_file: string, _args: string[], _opts: unknown, cb: any) => {
const callback = typeof _opts === 'function' ? _opts : cb;
if (outcome.code === 0) {
callback(null, outcome.stdout ?? '', outcome.stderr ?? '');
} else {
const err: NodeJS.ErrnoException & { stderr?: string } =
outcome.error ?? new Error('exec failed');
err.stderr = outcome.stderr ?? '';
(err as any).code = outcome.code;
callback(err, '', outcome.stderr ?? '');
}
return undefined as any;
},
);
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('detectApp', () => {
describe('appBundle strategy', () => {
it('returns true when fs.access resolves for any path', async () => {
mockedAccess.mockRejectedValueOnce(new Error('missing'));
mockedAccess.mockResolvedValueOnce(undefined);
const result = await detectApp('terminal', 'darwin');
expect(result).toBe(true);
expect(mockedAccess).toHaveBeenCalledTimes(2);
});
it('returns false when all paths reject', async () => {
mockedAccess.mockRejectedValue(new Error('missing'));
const result = await detectApp('vscode', 'darwin');
expect(result).toBe(false);
});
});
describe('commandV strategy', () => {
it('returns true on exit 0', async () => {
respondExec({ code: 0, stdout: '/usr/bin/zed' });
const result = await detectApp('zed', 'linux');
expect(result).toBe(true);
expect(mockedExecFile).toHaveBeenCalledWith(
'/bin/sh',
['-c', 'command -v "zed"'],
expect.any(Function),
);
});
it('returns false on non-zero exit', async () => {
respondExec({ code: 1, stderr: 'not found' });
const result = await detectApp('zed', 'linux');
expect(result).toBe(false);
});
it('rejects unsafe binary names without spawning a shell', async () => {
// We monkey-patch a registry entry transiently to inject a malicious binary.
const registry = await import('../registry');
const originalGhostty = registry.APP_REGISTRY.ghostty.detect.linux;
registry.APP_REGISTRY.ghostty.detect.linux = {
binary: 'foo; rm -rf /',
type: 'commandV',
};
const result = await detectApp('ghostty', 'linux');
expect(result).toBe(false);
expect(mockedExecFile).not.toHaveBeenCalled();
registry.APP_REGISTRY.ghostty.detect.linux = originalGhostty;
});
});
describe('registryAppPaths strategy', () => {
it('returns true on exit 0', async () => {
respondExec({ code: 0, stdout: 'C:\\Program Files\\code.exe' });
const result = await detectApp('vscode', 'win32');
expect(result).toBe(true);
expect(mockedExecFile).toHaveBeenCalledWith(
'where',
['Code.exe'],
{ windowsHide: true },
expect.any(Function),
);
});
it('returns false on non-zero exit', async () => {
respondExec({ code: 1, stderr: 'not found' });
const result = await detectApp('vscode', 'win32');
expect(result).toBe(false);
});
});
it('returns false when platform has no detect entry for the app', async () => {
const result = await detectApp('xcode', 'linux');
expect(result).toBe(false);
expect(mockedAccess).not.toHaveBeenCalled();
expect(mockedExecFile).not.toHaveBeenCalled();
});
it('returns true for ALWAYS_INSTALLED entries without probing', async () => {
const darwinFinder = await detectApp('finder', 'darwin');
const win32Explorer = await detectApp('explorer', 'win32');
const linuxFiles = await detectApp('files', 'linux');
expect(darwinFinder).toBe(true);
expect(win32Explorer).toBe(true);
expect(linuxFiles).toBe(true);
expect(mockedAccess).not.toHaveBeenCalled();
expect(mockedExecFile).not.toHaveBeenCalled();
});
});
describe('detectAllApps', () => {
it('returns one entry per AppId regardless of platform', async () => {
mockedAccess.mockRejectedValue(new Error('missing'));
mockedExecFile.mockImplementation((_file: string, _args: string[], _opts: unknown, cb: any) => {
const callback = typeof _opts === 'function' ? _opts : cb;
const err: NodeJS.ErrnoException = new Error('fail');
callback(err, '', '');
return undefined as any;
});
const apps = await detectAllApps('linux');
const registry = await import('../registry');
expect(apps.length).toBe(Object.keys(registry.APP_REGISTRY).length);
// every entry has the three required fields
for (const app of apps) {
expect(app).toEqual(
expect.objectContaining({
displayName: expect.any(String),
id: expect.any(String),
installed: expect.any(Boolean),
}),
);
}
});
it('marks unsupported-on-platform apps as not installed', async () => {
mockedAccess.mockRejectedValue(new Error('missing'));
mockedExecFile.mockImplementation((_file: string, _args: string[], _opts: unknown, cb: any) => {
const callback = typeof _opts === 'function' ? _opts : cb;
const err: NodeJS.ErrnoException = new Error('fail');
callback(err, '', '');
return undefined as any;
});
const apps = await detectAllApps('linux');
const xcode = apps.find((a) => a.id === 'xcode');
expect(xcode?.installed).toBe(false);
});
it('marks ALWAYS_INSTALLED platform file manager as installed without probes', async () => {
mockedAccess.mockRejectedValue(new Error('missing'));
mockedExecFile.mockImplementation((_file: string, _args: string[], _opts: unknown, cb: any) => {
const callback = typeof _opts === 'function' ? _opts : cb;
const err: NodeJS.ErrnoException = new Error('fail');
callback(err, '', '');
return undefined as any;
});
const apps = await detectAllApps('darwin');
const finder = apps.find((a) => a.id === 'finder');
expect(finder?.installed).toBe(true);
});
it('merges extracted icons onto installed apps only', async () => {
mockedAccess.mockRejectedValue(new Error('missing'));
mockedExecFile.mockImplementation((_file: string, _args: string[], _opts: unknown, cb: any) => {
const callback = typeof _opts === 'function' ? _opts : cb;
const err: NodeJS.ErrnoException = new Error('fail');
callback(err, '', '');
return undefined as any;
});
vi.mocked(extractAllIcons).mockResolvedValueOnce(
new Map([['finder', 'data:image/png;base64,FAKE']]),
);
const apps = await detectAllApps('darwin');
const finder = apps.find((a) => a.id === 'finder');
expect(finder?.icon).toBe('data:image/png;base64,FAKE');
// not-installed apps must not have an icon field
const xcode = apps.find((a) => a.id === 'xcode');
expect(xcode?.installed).toBe(false);
expect(xcode?.icon).toBeUndefined();
});
it('passes only installed AppIds to extractAllIcons', async () => {
mockedAccess.mockRejectedValue(new Error('missing'));
mockedExecFile.mockImplementation((_file: string, _args: string[], _opts: unknown, cb: any) => {
const callback = typeof _opts === 'function' ? _opts : cb;
const err: NodeJS.ErrnoException = new Error('fail');
callback(err, '', '');
return undefined as any;
});
vi.mocked(extractAllIcons).mockResolvedValueOnce(new Map());
await detectAllApps('darwin');
expect(extractAllIcons).toHaveBeenCalledTimes(1);
const [ids, platform] = vi.mocked(extractAllIcons).mock.calls[0];
expect(platform).toBe('darwin');
// only finder is ALWAYS_INSTALLED on darwin; all others fail probes
expect(ids).toEqual(['finder']);
});
});
@@ -0,0 +1,261 @@
import { execFile } from 'node:child_process';
import { access, mkdtemp, readFile, unlink } from 'node:fs/promises';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { __resetForTest, extractAllIcons, extractAppIcon } from '../iconExtractor';
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
}),
}));
vi.mock('node:fs/promises', () => ({
access: vi.fn(),
mkdtemp: vi.fn(),
readFile: vi.fn(),
unlink: vi.fn(),
}));
vi.mock('node:child_process', () => ({
execFile: vi.fn(),
}));
const mockedAccess = vi.mocked(access);
const mockedMkdtemp = vi.mocked(mkdtemp);
const mockedReadFile = vi.mocked(readFile);
const mockedUnlink = vi.mocked(unlink);
const mockedExecFile = vi.mocked(execFile) as unknown as ReturnType<typeof vi.fn>;
/**
* Drives the next execFile call. The promisified callback signature is
* `(error, stdout, stderr)`; non-error responses resolve with stdout.
*/
const respondExec = (
match: { args?: string[]; binary: string },
outcome: { error?: Error; stderr?: string; stdout?: string },
) => {
mockedExecFile.mockImplementationOnce(
(_file: string, _args: string[], _opts: unknown, cb: any) => {
const callback = typeof _opts === 'function' ? _opts : cb;
if (_file !== match.binary) {
callback(new Error(`unexpected binary: ${_file}`), '', '');
return undefined as any;
}
if (match.args && JSON.stringify(_args) !== JSON.stringify(match.args)) {
callback(new Error(`unexpected args: ${JSON.stringify(_args)}`), '', '');
return undefined as any;
}
if (outcome.error) {
callback(outcome.error, '', outcome.stderr ?? '');
} else {
callback(null, outcome.stdout ?? '', outcome.stderr ?? '');
}
return undefined as any;
},
);
};
// Shorthand: tools-available probe passes (which plutil + which sips both 0).
const respondToolsAvailable = () => {
// /usr/bin/which plutil
respondExec({ binary: '/usr/bin/which' }, { stdout: '/usr/bin/plutil\n' });
// /usr/bin/which sips
respondExec({ binary: '/usr/bin/which' }, { stdout: '/usr/bin/sips\n' });
};
beforeEach(() => {
vi.clearAllMocks();
mockedAccess.mockReset();
mockedMkdtemp.mockReset();
mockedReadFile.mockReset();
mockedUnlink.mockReset();
mockedExecFile.mockReset();
mockedUnlink.mockResolvedValue(undefined);
__resetForTest();
});
describe('extractAppIcon', () => {
it('returns a data URL when plutil + sips succeed on darwin', async () => {
respondToolsAvailable();
mockedAccess.mockResolvedValueOnce(undefined); // bundle exists
// plutil CFBundleIconFile lookup
respondExec({ binary: 'plutil' }, { stdout: 'Code.icns\n' });
mockedAccess.mockResolvedValueOnce(undefined); // .icns exists
mockedMkdtemp.mockResolvedValueOnce('/tmp/lobehub-openinapp-test');
// sips conversion
respondExec({ binary: 'sips' }, { stdout: '' });
mockedReadFile.mockResolvedValueOnce(Buffer.from([0x89, 0x50, 0x4e, 0x47])); // PNG header
const result = await extractAppIcon('vscode', 'darwin');
expect(result).toBe(
`data:image/png;base64,${Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString('base64')}`,
);
});
it('appends .icns suffix when CFBundleIconFile has no extension', async () => {
respondToolsAvailable();
mockedAccess.mockResolvedValueOnce(undefined); // bundle exists
respondExec({ binary: 'plutil' }, { stdout: 'Terminal\n' });
mockedAccess.mockImplementationOnce(async (p: any) => {
// .icns existence check — verify suffix appended
if (typeof p === 'string' && p.endsWith('Terminal.icns')) return undefined;
throw new Error('wrong path: ' + String(p));
});
mockedMkdtemp.mockResolvedValueOnce('/tmp/lobehub-openinapp-test');
respondExec({ binary: 'sips' }, { stdout: '' });
mockedReadFile.mockResolvedValueOnce(Buffer.from([0x89, 0x50]));
const result = await extractAppIcon('terminal', 'darwin');
expect(result).toBeDefined();
expect(result!.startsWith('data:image/png;base64,')).toBe(true);
});
it('falls back to the next path when the first bundle does not exist', async () => {
respondToolsAvailable();
// terminal has two candidate paths; first fails, second succeeds.
mockedAccess.mockRejectedValueOnce(new Error('missing'));
mockedAccess.mockResolvedValueOnce(undefined);
respondExec({ binary: 'plutil' }, { stdout: 'Terminal\n' });
mockedAccess.mockResolvedValueOnce(undefined);
mockedMkdtemp.mockResolvedValueOnce('/tmp/lobehub-openinapp-test');
respondExec({ binary: 'sips' }, { stdout: '' });
mockedReadFile.mockResolvedValueOnce(Buffer.from([0xff]));
const result = await extractAppIcon('terminal', 'darwin');
expect(result).toBeDefined();
});
it('returns undefined when no bundle path exists', async () => {
respondToolsAvailable();
mockedAccess.mockRejectedValue(new Error('missing'));
const result = await extractAppIcon('vscode', 'darwin');
expect(result).toBeUndefined();
});
it('returns undefined when plutil cannot read CFBundleIconFile', async () => {
respondToolsAvailable();
mockedAccess.mockResolvedValueOnce(undefined);
respondExec({ binary: 'plutil' }, { error: new Error('plutil: not found') });
const result = await extractAppIcon('vscode', 'darwin');
expect(result).toBeUndefined();
});
it('returns undefined when the resolved .icns is missing', async () => {
respondToolsAvailable();
mockedAccess.mockResolvedValueOnce(undefined); // bundle exists
respondExec({ binary: 'plutil' }, { stdout: 'Code.icns\n' });
mockedAccess.mockRejectedValueOnce(new Error('missing icns')); // .icns missing
const result = await extractAppIcon('vscode', 'darwin');
expect(result).toBeUndefined();
});
it('returns undefined when sips fails', async () => {
respondToolsAvailable();
mockedAccess.mockResolvedValueOnce(undefined);
respondExec({ binary: 'plutil' }, { stdout: 'Code.icns\n' });
mockedAccess.mockResolvedValueOnce(undefined);
mockedMkdtemp.mockResolvedValueOnce('/tmp/lobehub-openinapp-test');
respondExec({ binary: 'sips' }, { error: new Error('sips error') });
const result = await extractAppIcon('vscode', 'darwin');
expect(result).toBeUndefined();
});
it('returns undefined when the produced PNG is empty', async () => {
respondToolsAvailable();
mockedAccess.mockResolvedValueOnce(undefined);
respondExec({ binary: 'plutil' }, { stdout: 'Code.icns\n' });
mockedAccess.mockResolvedValueOnce(undefined);
mockedMkdtemp.mockResolvedValueOnce('/tmp/lobehub-openinapp-test');
respondExec({ binary: 'sips' }, { stdout: '' });
mockedReadFile.mockResolvedValueOnce(Buffer.alloc(0));
const result = await extractAppIcon('vscode', 'darwin');
expect(result).toBeUndefined();
});
it('returns undefined when registry has no darwin entry for the app', async () => {
respondToolsAvailable();
const result = await extractAppIcon('explorer', 'darwin');
expect(result).toBeUndefined();
expect(mockedAccess).not.toHaveBeenCalled();
});
it('returns undefined on win32 (extractor is macOS-only)', async () => {
const result = await extractAppIcon('vscode', 'win32');
expect(result).toBeUndefined();
expect(mockedExecFile).not.toHaveBeenCalled();
});
it('returns undefined on linux (extractor is macOS-only)', async () => {
const result = await extractAppIcon('vscode', 'linux');
expect(result).toBeUndefined();
expect(mockedExecFile).not.toHaveBeenCalled();
});
});
describe('extractAllIcons', () => {
it('returns a map of only AppIds with successfully extracted icons', async () => {
respondToolsAvailable();
// vscode succeeds
mockedAccess.mockResolvedValueOnce(undefined); // bundle
respondExec({ binary: 'plutil' }, { stdout: 'Code.icns\n' });
mockedAccess.mockResolvedValueOnce(undefined); // .icns
mockedMkdtemp.mockResolvedValueOnce('/tmp/lobehub-openinapp-test');
respondExec({ binary: 'sips' }, { stdout: '' });
mockedReadFile.mockResolvedValueOnce(Buffer.from('vscode'));
// cursor fails at bundle access (try all paths fail)
mockedAccess.mockRejectedValue(new Error('missing'));
// xcode succeeds — reset access for it
// (subsequent calls to mockedAccess will keep returning rejection)
// So this test exercises: success, fail-no-bundle.
const map = await extractAllIcons(['vscode', 'cursor'], 'darwin');
expect(map.has('vscode')).toBe(true);
expect(map.has('cursor')).toBe(false);
});
it('returns empty map when input list is empty', async () => {
const map = await extractAllIcons([], 'darwin');
expect(map.size).toBe(0);
});
it('does not throw when extraction errors', async () => {
respondToolsAvailable();
mockedAccess.mockResolvedValueOnce(undefined);
respondExec({ binary: 'plutil' }, { error: new Error('boom') });
const map = await extractAllIcons(['vscode'], 'darwin');
expect(map.size).toBe(0);
});
it('skips all when tools are unavailable', async () => {
// /usr/bin/which plutil fails
respondExec({ binary: '/usr/bin/which' }, { error: new Error('not found') });
const map = await extractAllIcons(['vscode', 'terminal'], 'darwin');
expect(map.size).toBe(0);
});
});
@@ -0,0 +1,247 @@
import { execFile } from 'node:child_process';
import { access } from 'node:fs/promises';
import { shell } from 'electron';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { launchApp } from '../launchers';
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
}),
}));
vi.mock('node:fs/promises', () => ({
access: vi.fn(),
}));
vi.mock('node:child_process', () => ({
execFile: vi.fn(),
}));
vi.mock('electron', () => ({
shell: {
openPath: vi.fn(),
},
}));
const mockedAccess = vi.mocked(access);
const mockedExecFile = vi.mocked(execFile) as unknown as ReturnType<typeof vi.fn>;
const mockedShell = vi.mocked(shell);
type LastCall = { file: string; args: string[] };
const captureExec = (): LastCall => {
expect(mockedExecFile).toHaveBeenCalled();
const [file, args] = mockedExecFile.mock.calls[0];
return { args: args as string[], file: file as string };
};
interface ExecOutcome {
code: number;
stderr?: string;
stdout?: string;
}
const respondExec = (outcome: ExecOutcome) => {
mockedExecFile.mockImplementationOnce(
(_file: string, _args: string[], _opts: unknown, cb: any) => {
const callback = typeof _opts === 'function' ? _opts : cb;
if (outcome.code === 0) {
callback(null, outcome.stdout ?? '', outcome.stderr ?? '');
} else {
const err: NodeJS.ErrnoException & { stderr?: string } = new Error('exec failed');
err.stderr = outcome.stderr ?? '';
(err as any).code = outcome.code;
callback(err, '', outcome.stderr ?? '');
}
return undefined as any;
},
);
};
beforeEach(() => {
vi.clearAllMocks();
mockedAccess.mockResolvedValue(undefined);
});
describe('launchApp - path validation', () => {
it('rejects relative paths', async () => {
const result = await launchApp('vscode', 'relative/path', 'darwin');
expect(result.success).toBe(false);
expect(result.error).toBe('Path must be absolute');
expect(mockedExecFile).not.toHaveBeenCalled();
});
it('rejects paths that do not exist', async () => {
mockedAccess.mockRejectedValueOnce(new Error('ENOENT'));
const result = await launchApp('vscode', '/missing', 'darwin');
expect(result.success).toBe(false);
expect(result.error).toBe('Path not found: /missing');
expect(mockedExecFile).not.toHaveBeenCalled();
});
it('returns error when app is not available on platform', async () => {
const result = await launchApp('xcode', '/some/path', 'linux');
expect(result.success).toBe(false);
expect(result.error).toContain('Xcode');
expect(result.error).toContain('not available on this platform');
});
});
describe('launchApp - macOpenA strategy', () => {
it('spawns open -a <appName> <path>', async () => {
respondExec({ code: 0 });
const result = await launchApp('vscode', '/work/dir', 'darwin');
expect(result.success).toBe(true);
const call = captureExec();
expect(call.file).toBe('open');
expect(call.args).toEqual(['-a', 'Visual Studio Code', '/work/dir']);
});
it('returns stderr substring on failure', async () => {
respondExec({ code: 1, stderr: ' cannot open Cursor.app ' });
const result = await launchApp('cursor', '/work/dir', 'darwin');
expect(result.success).toBe(false);
expect(result.error).toBe('cannot open Cursor.app');
});
});
describe('launchApp - macOpen strategy', () => {
it('spawns open <path>', async () => {
respondExec({ code: 0 });
const result = await launchApp('finder', '/work/dir', 'darwin');
expect(result.success).toBe(true);
const call = captureExec();
expect(call.file).toBe('open');
expect(call.args).toEqual(['/work/dir']);
});
});
describe('launchApp - exec strategy', () => {
it('spawns <binary> <path>', async () => {
respondExec({ code: 0 });
const result = await launchApp('vscode', '/work/dir', 'linux');
expect(result.success).toBe(true);
const call = captureExec();
expect(call.file).toBe('code');
expect(call.args).toEqual(['/work/dir']);
});
it('appends registry-provided args before path', async () => {
const registry = await import('../registry');
const original = registry.APP_REGISTRY.vscode.launch.linux;
registry.APP_REGISTRY.vscode.launch.linux = {
args: ['--new-window'],
binary: 'code',
type: 'exec',
};
respondExec({ code: 0 });
const result = await launchApp('vscode', '/work/dir', 'linux');
expect(result.success).toBe(true);
const call = captureExec();
expect(call.args).toEqual(['--new-window', '/work/dir']);
registry.APP_REGISTRY.vscode.launch.linux = original;
});
it('rejects suspicious binary names', async () => {
const registry = await import('../registry');
const original = registry.APP_REGISTRY.vscode.launch.linux;
registry.APP_REGISTRY.vscode.launch.linux = {
binary: 'rm; ls',
type: 'exec',
};
const result = await launchApp('vscode', '/work/dir', 'linux');
expect(result.success).toBe(false);
expect(result.error).toBe('Invalid binary name');
expect(mockedExecFile).not.toHaveBeenCalled();
registry.APP_REGISTRY.vscode.launch.linux = original;
});
it('rejects binary names with spaces', async () => {
const registry = await import('../registry');
const original = registry.APP_REGISTRY.vscode.launch.linux;
registry.APP_REGISTRY.vscode.launch.linux = {
binary: 'foo bar',
type: 'exec',
};
const result = await launchApp('vscode', '/work/dir', 'linux');
expect(result.success).toBe(false);
expect(result.error).toBe('Invalid binary name');
registry.APP_REGISTRY.vscode.launch.linux = original;
});
it('accepts absolute-path binary names', async () => {
const registry = await import('../registry');
const original = registry.APP_REGISTRY.vscode.launch.linux;
registry.APP_REGISTRY.vscode.launch.linux = {
binary: '/usr/local/bin/code',
type: 'exec',
};
respondExec({ code: 0 });
const result = await launchApp('vscode', '/work/dir', 'linux');
expect(result.success).toBe(true);
const call = captureExec();
expect(call.file).toBe('/usr/local/bin/code');
registry.APP_REGISTRY.vscode.launch.linux = original;
});
it('returns stderr substring on non-zero exit', async () => {
respondExec({ code: 1, stderr: 'command not found' });
const result = await launchApp('vscode', '/work/dir', 'linux');
expect(result.success).toBe(false);
expect(result.error).toBe('command not found');
});
});
describe('launchApp - shellOpenPath strategy', () => {
it('delegates to shell.openPath', async () => {
mockedShell.openPath.mockResolvedValueOnce('');
const result = await launchApp('explorer', '/abs/work-dir', 'win32');
expect(result.success).toBe(true);
expect(mockedShell.openPath).toHaveBeenCalledWith('/abs/work-dir');
});
it('returns error string from shell.openPath as error', async () => {
mockedShell.openPath.mockResolvedValueOnce('cannot open');
const result = await launchApp('files', '/some/dir', 'linux');
expect(result.success).toBe(false);
expect(result.error).toBe('cannot open');
});
});
@@ -0,0 +1,18 @@
import type { DetectedApp } from '@lobechat/electron-client-ipc';
import { detectAllApps } from './detectors';
let cachedPromise: Promise<DetectedApp[]> | null = null;
export const getCachedDetection = (
platform: NodeJS.Platform = process.platform,
): Promise<DetectedApp[]> => {
if (!cachedPromise) {
cachedPromise = detectAllApps(platform);
}
return cachedPromise;
};
export const clearDetectionCache = (): void => {
cachedPromise = null;
};
@@ -0,0 +1,109 @@
import { execFile } from 'node:child_process';
import { access } from 'node:fs/promises';
import { promisify } from 'node:util';
import type { DetectedApp, OpenInAppId } from '@lobechat/electron-client-ipc';
import { createLogger } from '@/utils/logger';
import { extractAllIcons } from './iconExtractor';
import type { DetectStrategy } from './registry';
import { ALWAYS_INSTALLED, APP_REGISTRY } from './registry';
// Icon extraction shells out to plutil + sips on macOS (see iconExtractor.ts)
// so Electron itself cannot crash on `app.getFileIcon` regressions. Renderer
// falls back to lucide if extraction returns undefined.
const logger = createLogger('modules:openInApp:detectors');
const execFileAsync = promisify(execFile);
const SAFE_BINARY_REGEX = /^[\w.-]+$/;
const probeAppBundle = async (paths: string[]): Promise<boolean> => {
for (const path of paths) {
try {
await access(path);
return true;
} catch {
// try next
}
}
return false;
};
const probeCommandV = async (binary: string): Promise<boolean> => {
if (!SAFE_BINARY_REGEX.test(binary)) {
logger.debug(`rejecting unsafe binary name for commandV: ${binary}`);
return false;
}
try {
await execFileAsync('/bin/sh', ['-c', `command -v "${binary}"`]);
return true;
} catch (error) {
logger.debug(`commandV probe failed for ${binary}: ${(error as Error).message}`);
return false;
}
};
const probeRegistryAppPaths = async (exeName: string): Promise<boolean> => {
try {
await execFileAsync('where', [exeName], { windowsHide: true });
return true;
} catch (error) {
logger.debug(`where probe failed for ${exeName}: ${(error as Error).message}`);
return false;
}
};
const runDetectStrategy = (strategy: DetectStrategy): Promise<boolean> => {
switch (strategy.type) {
case 'appBundle': {
return probeAppBundle(strategy.paths);
}
case 'commandV': {
return probeCommandV(strategy.binary);
}
case 'registryAppPaths': {
return probeRegistryAppPaths(strategy.exeName);
}
}
};
export const detectApp = async (id: OpenInAppId, platform: NodeJS.Platform): Promise<boolean> => {
if (ALWAYS_INSTALLED[platform] === id) {
return true;
}
const descriptor = APP_REGISTRY[id];
const strategy = descriptor?.detect[platform];
if (!strategy) {
return false;
}
return runDetectStrategy(strategy);
};
export const detectAllApps = async (
platform: NodeJS.Platform = process.platform,
): Promise<DetectedApp[]> => {
const entries = Object.entries(APP_REGISTRY) as Array<
[OpenInAppId, (typeof APP_REGISTRY)[OpenInAppId]]
>;
const installedFlags = await Promise.all(entries.map(([id]) => detectApp(id, platform)));
// Extract icons for installed apps only. Extraction shells out to plutil +
// sips (see iconExtractor.ts) so it cannot crash the renderer; failures
// resolve to undefined and the renderer falls back to lucide icons.
const installedIds = entries.filter((_entry, i) => installedFlags[i]).map(([id]) => id);
const icons = await extractAllIcons(installedIds, platform);
return entries.map(([id, descriptor], i) => {
const installed = installedFlags[i];
const icon = installed ? icons.get(id) : undefined;
return {
displayName: descriptor.displayName,
id,
installed,
...(icon ? { icon } : {}),
} satisfies DetectedApp;
});
};
@@ -0,0 +1,210 @@
import { execFile } from 'node:child_process';
import { access, mkdtemp, readFile, unlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import type { OpenInAppId } from '@lobechat/electron-client-ipc';
import { createLogger } from '@/utils/logger';
import { APP_REGISTRY } from './registry';
const logger = createLogger('modules:openInApp:iconExtractor');
// Manual promise wrapper rather than util.promisify(execFile): the latter
// relies on execFile's custom `util.promisify.custom` symbol to return
// `{ stdout, stderr }`, which vi.fn() mocks don't carry — so destructuring
// silently yields `undefined` under test. This wrapper resolves directly to
// the stdout string and is mock-friendly.
const execFileToString = (
file: string,
args: string[],
opts?: { timeout?: number },
): Promise<string> =>
new Promise((resolve, reject) => {
const cb = (err: Error | null, stdout: string, stderr: string) => {
if (err) {
(err as Error & { stderr?: string }).stderr = stderr;
reject(err);
} else {
resolve(stdout);
}
};
if (opts) execFile(file, args, opts, cb);
else execFile(file, args, cb);
});
/** Render dimensions for the extracted PNG. 64 keeps the payload tiny while
* staying crisp at the renderer's 16-20 px display size on retina. */
const ICON_SIZE = 64;
/** Per-extraction bound. plutil and sips are local file ops; tens of ms is
* typical, so a generous timeout still catches real hangs. */
const EXEC_TIMEOUT_MS = 5000;
let tmpDirPromise: Promise<string | undefined> | undefined;
const ensureTmpDir = async (): Promise<string | undefined> => {
if (tmpDirPromise) return tmpDirPromise;
tmpDirPromise = (async () => {
try {
return await mkdtemp(path.join(tmpdir(), 'lobehub-openinapp-'));
} catch (error) {
logger.debug(`failed to create tmp dir: ${(error as Error).message}`);
return undefined;
}
})();
return tmpDirPromise;
};
let toolsAvailablePromise: Promise<boolean> | undefined;
/**
* Confirm `plutil` and `sips` are both on PATH. Both ship with every macOS
* install so this is effectively a sanity check; cached for the process lifetime.
*/
const areToolsAvailable = (): Promise<boolean> => {
if (toolsAvailablePromise) return toolsAvailablePromise;
toolsAvailablePromise = (async () => {
try {
await execFileToString('/usr/bin/which', ['plutil']);
await execFileToString('/usr/bin/which', ['sips']);
return true;
} catch {
logger.debug('plutil or sips missing from PATH; falling back to renderer icons');
return false;
}
})();
return toolsAvailablePromise;
};
const resolveDarwinBundlePath = async (id: OpenInAppId): Promise<string | undefined> => {
const strategy = APP_REGISTRY[id]?.detect.darwin;
if (!strategy || strategy.type !== 'appBundle') return undefined;
for (const candidate of strategy.paths) {
try {
await access(candidate);
return candidate;
} catch {
// try next
}
}
return undefined;
};
/**
* Look up the bundle's icon file name via Info.plist (`CFBundleIconFile`).
* Returns the resolved absolute .icns path, or undefined if not derivable.
*/
const resolveIcnsPath = async (bundlePath: string): Promise<string | undefined> => {
const plistPath = path.join(bundlePath, 'Contents', 'Info.plist');
try {
const stdout = await execFileToString(
'plutil',
['-extract', 'CFBundleIconFile', 'raw', plistPath],
{ timeout: EXEC_TIMEOUT_MS },
);
const iconName = stdout.trim();
if (!iconName) return undefined;
const fileName = iconName.endsWith('.icns') ? iconName : `${iconName}.icns`;
const icnsPath = path.join(bundlePath, 'Contents', 'Resources', fileName);
await access(icnsPath);
return icnsPath;
} catch (error) {
logger.debug(`resolveIcnsPath failed for ${bundlePath}: ${(error as Error).message}`);
return undefined;
}
};
/**
* Resize/convert the given .icns to a 64×64 PNG using sips, then return the
* base64 data URL. The PNG file is unlinked after read.
*/
const renderIcnsToDataUrl = async (
icnsPath: string,
tmpDir: string,
filename: string,
): Promise<string | undefined> => {
const outPath = path.join(tmpDir, filename);
try {
await execFileToString(
'sips',
[
'-z',
String(ICON_SIZE),
String(ICON_SIZE),
'-s',
'format',
'png',
icnsPath,
'--out',
outPath,
],
{ timeout: EXEC_TIMEOUT_MS },
);
const buf = await readFile(outPath);
if (buf.length === 0) return undefined;
return `data:image/png;base64,${buf.toString('base64')}`;
} catch (error) {
logger.debug(`sips failed for ${icnsPath}: ${(error as Error).message}`);
return undefined;
} finally {
unlink(outPath).catch(() => undefined);
}
};
/**
* Extract the real macOS app icon for the given AppId by reading the bundle's
* Info.plist (`CFBundleIconFile`) and rendering the resolved .icns via `sips`.
* Both `plutil` and `sips` ship with every macOS install — no Xcode, swift, or
* electron-builder bundling required, and no JXA / NSImage drawing path
* (which is broken in JXA: lockFocus and NSGraphicsContext class methods are
* not exposed). macOS only; other platforms return undefined.
*/
export const extractAppIcon = async (
id: OpenInAppId,
platform: NodeJS.Platform = process.platform,
): Promise<string | undefined> => {
if (platform !== 'darwin') return undefined;
try {
if (!(await areToolsAvailable())) return undefined;
const bundlePath = await resolveDarwinBundlePath(id);
if (!bundlePath) return undefined;
const icnsPath = await resolveIcnsPath(bundlePath);
if (!icnsPath) return undefined;
const tmpDir = await ensureTmpDir();
if (!tmpDir) return undefined;
return await renderIcnsToDataUrl(icnsPath, tmpDir, `${id}.png`);
} catch (error) {
logger.debug(`extractAppIcon error for ${id}: ${(error as Error).message}`);
return undefined;
}
};
/**
* Resolve icons for a list of installed AppIds. Sequential — keeps spawn
* pressure low and matches the underlying single-thread tools.
*/
export const extractAllIcons = async (
installedIds: OpenInAppId[],
platform: NodeJS.Platform = process.platform,
): Promise<Map<OpenInAppId, string>> => {
const map = new Map<OpenInAppId, string>();
for (const id of installedIds) {
try {
const icon = await extractAppIcon(id, platform);
if (icon) map.set(id, icon);
} catch (error) {
logger.debug(`extractAllIcons: skipping ${id} after error: ${(error as Error).message}`);
}
}
return map;
};
/**
* Test-only: reset the module-level caches so each test starts fresh.
*/
export const __resetForTest = () => {
tmpDirPromise = undefined;
toolsAvailablePromise = undefined;
};
@@ -0,0 +1,106 @@
import { execFile } from 'node:child_process';
import { access } from 'node:fs/promises';
import path from 'node:path';
import { promisify } from 'node:util';
import type { OpenInAppId, OpenInAppResult } from '@lobechat/electron-client-ipc';
import { shell } from 'electron';
import { createLogger } from '@/utils/logger';
import type { LaunchStrategy } from './registry';
import { APP_REGISTRY } from './registry';
const logger = createLogger('modules:openInApp:launchers');
const execFileAsync = promisify(execFile);
const SAFE_BINARY_REGEX = /^[\w.-]+$/;
const isAllowedBinary = (binary: string): boolean =>
SAFE_BINARY_REGEX.test(binary) || path.isAbsolute(binary);
interface ExecError extends Error {
stderr?: string;
}
const formatExecError = (error: unknown): string => {
const err = error as ExecError;
const stderr = typeof err?.stderr === 'string' ? err.stderr.trim() : '';
const fallback = err?.message ?? 'Launch failed';
return (stderr || fallback).slice(0, 200);
};
const runLaunchStrategy = async (
strategy: LaunchStrategy,
absolutePath: string,
): Promise<OpenInAppResult> => {
switch (strategy.type) {
case 'macOpenA': {
try {
await execFileAsync('open', ['-a', strategy.appName, absolutePath]);
return { success: true };
} catch (error) {
return { error: formatExecError(error), success: false };
}
}
case 'macOpen': {
try {
await execFileAsync('open', [absolutePath]);
return { success: true };
} catch (error) {
return { error: formatExecError(error), success: false };
}
}
case 'exec': {
if (!isAllowedBinary(strategy.binary)) {
return { error: 'Invalid binary name', success: false };
}
const extraArgs = strategy.args ?? [];
try {
await execFileAsync(strategy.binary, [...extraArgs, absolutePath]);
return { success: true };
} catch (error) {
return { error: formatExecError(error), success: false };
}
}
case 'shellOpenPath': {
const result = await shell.openPath(absolutePath);
return result ? { error: result, success: false } : { success: true };
}
}
};
export const launchApp = async (
id: OpenInAppId,
absolutePath: string,
platform: NodeJS.Platform = process.platform,
): Promise<OpenInAppResult> => {
const descriptor = APP_REGISTRY[id];
const strategy = descriptor?.launch[platform];
if (!descriptor || !strategy) {
const displayName = descriptor?.displayName ?? id;
return {
error: `${displayName} is not available on this platform`,
success: false,
};
}
if (!path.isAbsolute(absolutePath)) {
return { error: 'Path must be absolute', success: false };
}
try {
await access(absolutePath);
} catch {
return { error: `Path not found: ${absolutePath}`, success: false };
}
const result = await runLaunchStrategy(strategy, absolutePath);
if (result.success) {
logger.info(`launched ${id} at ${absolutePath}`);
} else {
logger.error(`failed to launch ${id} at ${absolutePath}: ${result.error}`);
}
return result;
};
@@ -0,0 +1,129 @@
import type { OpenInAppId } from '@lobechat/electron-client-ipc';
export type DetectStrategy =
| { paths: string[]; type: 'appBundle' }
| { exeName: string; type: 'registryAppPaths' }
| { binary: string; type: 'commandV' };
export type LaunchStrategy =
| { appName: string; type: 'macOpenA' }
| { type: 'macOpen' }
| { args?: string[]; binary: string; type: 'exec' }
| { type: 'shellOpenPath' };
export interface AppDescriptor {
detect: Partial<Record<NodeJS.Platform, DetectStrategy>>;
displayName: string;
launch: Partial<Record<NodeJS.Platform, LaunchStrategy>>;
}
export const APP_REGISTRY: Record<OpenInAppId, AppDescriptor> = {
vscode: {
detect: {
darwin: { paths: ['/Applications/Visual Studio Code.app'], type: 'appBundle' },
linux: { binary: 'code', type: 'commandV' },
win32: { exeName: 'Code.exe', type: 'registryAppPaths' },
},
displayName: 'VS Code',
launch: {
darwin: { appName: 'Visual Studio Code', type: 'macOpenA' },
linux: { binary: 'code', type: 'exec' },
win32: { binary: 'code', type: 'exec' },
},
},
cursor: {
detect: {
darwin: { paths: ['/Applications/Cursor.app'], type: 'appBundle' },
linux: { binary: 'cursor', type: 'commandV' },
win32: { exeName: 'Cursor.exe', type: 'registryAppPaths' },
},
displayName: 'Cursor',
launch: {
darwin: { appName: 'Cursor', type: 'macOpenA' },
linux: { binary: 'cursor', type: 'exec' },
win32: { binary: 'cursor', type: 'exec' },
},
},
zed: {
detect: {
darwin: { paths: ['/Applications/Zed.app'], type: 'appBundle' },
linux: { binary: 'zed', type: 'commandV' },
},
displayName: 'Zed',
launch: {
darwin: { appName: 'Zed', type: 'macOpenA' },
linux: { binary: 'zed', type: 'exec' },
},
},
webstorm: {
detect: {
darwin: { paths: ['/Applications/WebStorm.app'], type: 'appBundle' },
linux: { binary: 'webstorm', type: 'commandV' },
win32: { exeName: 'webstorm64.exe', type: 'registryAppPaths' },
},
displayName: 'WebStorm',
launch: {
darwin: { appName: 'WebStorm', type: 'macOpenA' },
linux: { binary: 'webstorm', type: 'exec' },
win32: { binary: 'webstorm', type: 'exec' },
},
},
xcode: {
detect: { darwin: { paths: ['/Applications/Xcode.app'], type: 'appBundle' } },
displayName: 'Xcode',
launch: { darwin: { appName: 'Xcode', type: 'macOpenA' } },
},
finder: {
detect: {
darwin: { paths: ['/System/Library/CoreServices/Finder.app'], type: 'appBundle' },
},
displayName: 'Finder',
launch: { darwin: { type: 'macOpen' } },
},
explorer: {
detect: { win32: { exeName: 'explorer.exe', type: 'registryAppPaths' } },
displayName: 'Explorer',
launch: { win32: { type: 'shellOpenPath' } },
},
files: {
detect: { linux: { binary: 'xdg-open', type: 'commandV' } },
displayName: 'Files',
launch: { linux: { type: 'shellOpenPath' } },
},
terminal: {
detect: {
darwin: {
paths: [
'/System/Applications/Utilities/Terminal.app',
'/Applications/Utilities/Terminal.app',
],
type: 'appBundle',
},
},
displayName: 'Terminal',
launch: { darwin: { appName: 'Terminal', type: 'macOpenA' } },
},
iterm2: {
detect: { darwin: { paths: ['/Applications/iTerm.app'], type: 'appBundle' } },
displayName: 'iTerm2',
launch: { darwin: { appName: 'iTerm', type: 'macOpenA' } },
},
ghostty: {
detect: {
darwin: { paths: ['/Applications/Ghostty.app'], type: 'appBundle' },
linux: { binary: 'ghostty', type: 'commandV' },
},
displayName: 'Ghostty',
launch: {
darwin: { appName: 'Ghostty', type: 'macOpenA' },
linux: { binary: 'ghostty', type: 'exec' },
},
},
};
/** AppIds that are always considered "installed" — file managers, which we treat as platform-provided. */
export const ALWAYS_INSTALLED: Partial<Record<NodeJS.Platform, OpenInAppId>> = {
darwin: 'finder',
linux: 'files',
win32: 'explorer',
};
+1
View File
@@ -19,6 +19,7 @@ export interface ElectronMainStore {
gatewayEnabled: boolean;
gatewayUrl: string;
locale: string;
localFileWorkspaceRoots: string[];
networkProxy: NetworkProxySettings;
shortcuts: Record<string, string>;
storagePath: string;
+24
View File
@@ -4,22 +4,46 @@ export const getExportMimeType = (filePath: string) => {
const ext = path.extname(filePath).toLowerCase();
const map: Record<string, string> = {
'.bash': 'text/plain; charset=utf-8',
'.c': 'text/plain; charset=utf-8',
'.cpp': 'text/plain; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.csv': 'text/csv; charset=utf-8',
'.dockerfile': 'text/plain; charset=utf-8',
'.fish': 'text/plain; charset=utf-8',
'.gif': 'image/gif',
'.go': 'text/plain; charset=utf-8',
'.graphql': 'application/graphql; charset=utf-8',
'.h': 'text/plain; charset=utf-8',
'.hpp': 'text/plain; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.ico': 'image/x-icon',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.js': 'application/javascript; charset=utf-8',
'.jsx': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.log': 'text/plain; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.md': 'text/markdown; charset=utf-8',
'.mdx': 'text/markdown; charset=utf-8',
'.mp4': 'video/mp4',
'.png': 'image/png',
'.py': 'text/plain; charset=utf-8',
'.rs': 'text/plain; charset=utf-8',
'.sh': 'text/plain; charset=utf-8',
'.svg': 'image/svg+xml; charset=utf-8',
'.toml': 'application/toml; charset=utf-8',
'.ts': 'text/plain; charset=utf-8',
'.tsx': 'text/plain; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.xml': 'application/xml; charset=utf-8',
'.yaml': 'application/yaml; charset=utf-8',
'.yml': 'application/yaml; charset=utf-8',
'.webp': 'image/webp',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.zsh': 'text/plain; charset=utf-8',
};
return map[ext];
+54
View File
@@ -1,4 +1,58 @@
[
{
"children": {},
"date": "2026-05-18",
"version": "2.2.0"
},
{
"children": {
"features": [
"support slack mpim and fix discord dm problem.",
"add service model assignments settings.",
"inline skill auth in recommended task templates.",
"add user activity business hook.",
"add Gemini 3.1 Flash-Lite provider cards.",
"home daily brief with linkable welcome + paired input hint.",
"add signOperationJwt with 4h expiry for hetero-agent operations.",
"migrate Notion to LobeHub Market.",
"Cloud Claude Code V3 — repo picker, GitHub token, sandbox context."
],
"fixes": [
"Docs image.",
"update Task page placeholder copy.",
"sidebar add agent.",
"replace ScrollShadow with ScrollArea to fix React #185 infinite render loop.",
"reject inactive OIDC access.",
"drop unreachable aihubmix empty-apiKey test.",
"consume visual content parts in server runtime.",
"store onboarding interests as keys.",
"remove the old cron job from lobehub.",
"refresh content baseline from DB on every ingest call.",
"gateway client-tool pluginState + drop redundant Exit code: 0 tail.",
"first inject the cloudecc runtime session should use the existingStatus.",
"slack connect error & slash commands.",
"polish task agent manager.",
"remove signin captcha flow.",
"add temporary email auth error locale.",
"add bot callback service.",
"sanitize sensitive comments and examples from production JS bundle.",
"multiple account link."
],
"improvements": [
"use @lobehub/ui built-in HtmlPreview instead of custom component.",
"polish desktop header icons, sidebar density, and task menus.",
"standardize header action icon sizes.",
"add reasoning_effort support for Grok 4.3.",
"increase chat topic title length.",
"format tool execution time as Xmin Ys instead of X.Y min.",
"Add new DeepSeek-V4 models.",
"use visible divider between queued messages.",
"update auth captcha retry copy."
]
},
"date": "2026-05-13",
"version": "2.1.58"
},
{
"children": {
"fixes": ["hide runtime-only model aliases."],
+4 -4
View File
@@ -19,10 +19,10 @@ tags:
## 连接模式
LobeHub 持两种 QQ 机器人连接模式:
LobeHub 持两种 QQ 机器人连接模式:
- **WebSocket(推荐)** — 持久连接。事件通过 WebSocket 实时推送,无需配置回调地址。这是新机器人的默认模式。
- **Webhook** — 无状态 HTTP 调。如果您的机器人已在 QQ 开放平台配置了回调地址且无法切换,请使用此模式。
- **Webhook** — 无状态 HTTP 调。如果您的机器人已在 QQ 开放平台配置了回调地址且无法切换,请使用此模式。
> **注意:** 在 QQ 开放平台上,一旦机器人配置了 Webhook 回调地址,就无法切换到 WebSocket 模式。尚未配置回调地址的新机器人应使用 WebSocket 模式。
@@ -60,7 +60,7 @@ LobeHub 持两种 QQ 机器人连接模式:
<Steps>
### 打开渠道设置
在 LobeHub 中,导航到您的代理设置,然后选择 **渠道** 标签页。平台列表中点击 **QQ**。
在 LobeHub 中,导航到您的代理设置,然后选择 **渠道** 标签页。平台列表中点击 **QQ**。
### 输入应用凭证
@@ -199,7 +199,7 @@ LobeHub 持两种 QQ 机器人连接模式:
## 故障排除
- **机器人无法连接(WebSocket 模式):** 验证 App ID 和 App Secret 是否正确。确保机人在 QQ 开放平台上未配置回调地址 — 一旦设置了回调地址,WebSocket 模式将不可用。
- **机器人无法连接(WebSocket 模式):** 验证 App ID 和 App Secret 是否正确。确保机人在 QQ 开放平台上未配置回调地址 — 一旦设置了回调地址,WebSocket 模式将不可用。
- **回调地址验证失败(Webhook 模式):** 确保您已在 LobeHub 中保存配置,并正确复制了 URL。LobeHub 会自动处理 Ed25519 验证。
- **机器人未响应:** 验证 App ID 和 App Secret 是否正确,机器人是否已发布(或您是沙盒测试用户),以及是否订阅了所需的消息事件。
- **群聊问题:** 确保机器人已被添加到群聊中。@提及机器人以触发响应。
+2 -1
View File
@@ -85,6 +85,7 @@ LobeHub supports two connection modes for Slack:
event_subscriptions:
bot_events:
- app_mention
- app_home_opened
- message.channels
- message.groups
- message.im
@@ -195,7 +196,7 @@ Use this method if your Slack app already has Event Subscriptions configured wit
### Configure Event Subscriptions
In the Slack API Dashboard → **Event Subscriptions**, enable events, paste the Webhook URL as the **Request URL**, and subscribe to bot events: `app_mention`, `message.channels`, `message.groups`, `message.im`, `message.mpim`, `member_joined_channel`.
In the Slack API Dashboard → **Event Subscriptions**, enable events, paste the Webhook URL as the **Request URL**, and subscribe to bot events: `app_mention`, `app_home_opened`, `message.channels`, `message.groups`, `message.im`, `message.mpim`, `member_joined_channel`.
![](/blog/assets8f3657f3785fc04c42b0f53c17daa72e.webp)
+2 -1
View File
@@ -82,6 +82,7 @@ LobeHub 支持两种 Slack 连接模式:
event_subscriptions:
bot_events:
- app_mention
- app_home_opened
- message.channels
- message.groups
- message.im
@@ -192,7 +193,7 @@ LobeHub 支持两种 Slack 连接模式:
### 配置事件订阅
在 Slack API 控制台 → **Event Subscriptions** 中,启用事件,将 Webhook URL 粘贴为 **Request URL**,订阅事件:`app_mention`、`message.channels`、`message.groups`、`message.im`、`message.mpim`、`member_joined_channel`。
在 Slack API 控制台 → **Event Subscriptions** 中,启用事件,将 Webhook URL 粘贴为 **Request URL**,订阅事件:`app_mention`、`app_home_opened`、`message.channels`、`message.groups`、`message.im`、`message.mpim`、`member_joined_channel`。
![](/blog/assets8f3657f3785fc04c42b0f53c17daa72e.webp)
+105
View File
@@ -0,0 +1,105 @@
---
title: Use LobeHub on Discord
description: >-
Add the official LobeHub bot to a Discord server, then link your Discord
account to LobeHub. Pick a default agent and chat with your AI assistants in
Discord DMs — no bot setup required.
tags:
- Messenger
- Discord
- LobeHub Bot
- Account Linking
- Server Install
---
# Use LobeHub on Discord
Discord works in two phases: a **server admin adds** the official LobeHub bot to a Discord server once, and then **each member links** their personal Discord account to LobeHub. Both phases happen from **Settings → Messenger → Discord**.
> The Discord install audit is per-server, but your **personal link is global** to your Discord account — you only link once, and the same link works in every server that has the bot.
## Prerequisites
- A LobeHub account
- A Discord account
- For the install step: **Manage Server** permission on the target Discord server
## Phase A — Add the LobeHub bot to a server (server admin)
<Steps>
### Open Settings → Messenger → Discord
In LobeHub, open **Settings → Messenger** and click the **Discord** card. Click **Connect** in the top-right.
{/* TODO: screenshot — Discord detail page with empty Connections list and the Connect button */}
### Authorise in Discord
You'll be redirected to Discord's bot-add screen. Pick the server you want to add the bot to and click **Authorise**.
{/* TODO: screenshot — Discord OAuth consent screen with the server picker */}
### Server appears under Connections
After approval you're redirected back to LobeHub. The server appears as a **server** row in **Connections**.
{/* TODO: screenshot — Discord detail page showing one connected server row */}
> **Server already connected by someone else?** LobeHub shows a "Server already connected" notice. You don't need to add the bot again — just DM the LobeHub bot in Discord to link your personal account.
</Steps>
## Phase B — Link your personal account (each member)
<Steps>
### Open the LobeHub bot in Discord
Open the LobeHub bot in Discord — the **Open in Discord** button on the Discord detail page (or the pending-link row) takes you straight there.
{/* TODO: screenshot — Discord detail page with the pending user row + Open in Discord button */}
### Send any message
In the bot DM, send any message to trigger the linking flow. The bot replies with a one-time confirmation link.
{/* TODO: screenshot — Discord DM with the bot's reply containing the link button */}
### Confirm the link in your browser
Tap the link, sign in to LobeHub if asked, and choose a **default agent**. Every message you DM the LobeHub bot in Discord (across all servers) will route to this agent.
{/* TODO: screenshot — confirm-link page in LobeHub with the agent picker */}
Your link appears as a **user** row in **Connections**.
{/* TODO: screenshot — Discord detail page showing server row + connected user row */}
</Steps>
## Switching the Active Agent
Two equivalent ways:
- **In Discord** — DM the bot `/agents` and pick a different agent.
- **In LobeHub** — open **Settings → Messenger → Discord** and use the agent picker on your link row.
The change takes effect on the next message you send.
## Disconnecting
Discord has two distinct disconnect actions, with one important difference from Slack:
| Action | Effect |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Disconnect** on a *user* row | Unlinks **your** LobeHub account from your Discord account. The bot stops accepting your DMs until you message it and link again. |
| **Disconnect** on a *server* row | Removes the install **audit entry only**. **The bot stays in the Discord server** until a server admin manually kicks it. Other people's personal links are unaffected. |
You can re-add the bot to a server (or re-link personally) at any time by repeating the relevant phase.
## Troubleshooting
- **"Server already connected"** — Another LobeHub user already added the bot to this server. DM the LobeHub bot in Discord to link your personal account; you do not need to add the bot again.
- **Discord install failed (`<reason>`)** — Common reasons: authorisation cancelled, install session expired (re-open the modal and try again), Discord returned incomplete data (retry; if persistent, contact support).
- **Bot is in the server but doesn't reply** — Check that you have a personal link under **Settings → Messenger → Discord**. The bot only answers users with a confirmed personal link.
- **Removed the audit row but the bot is still in my server** — That's expected. Disconnecting in LobeHub only removes the audit entry; a Discord server admin must kick the bot from Discord itself.
- **"This link is already used"** — One-time confirmation links expire after one use. DM the bot again to get a new link.
- **"This account is already linked"** — Your Discord account is bound to a different LobeHub account. Sign in to that LobeHub account to manage the link, or unlink there before retrying.
- **"Another Discord account is already linked"** — Your LobeHub account already has a Discord link. Disconnect it in **Settings → Messenger → Discord** before linking a new Discord account.
+104
View File
@@ -0,0 +1,104 @@
---
title: 在 Discord 使用 LobeHub
description: >-
把官方 LobeHub 机器人加入 Discord 服务器,再把自己的 Discord 账号关联到
LobeHub;选择默认 Agent,就能在 Discord DM 里直接和 AI 助手对话,无需自建机器人。
tags:
- Messenger
- Discord
- LobeHub 机器人
- 账号关联
- 服务器安装
---
# 在 Discord 使用 LobeHub
Discord 接入分两步:**服务器管理员把 LobeHub 机器人加入 Discord 服务器**(每个服务器一次),然后 **每位成员把自己的 Discord 账号关联到 LobeHub**。两步都在 **设置 → Messenger → Discord** 里完成。
> Discord 安装审计是按服务器记录的,但你的 **个人关联是全局的**,挂在你的 Discord 账号下 —— 只需关联一次,所有装了机器人的服务器里它都生效。
## 前置条件
- 一个 LobeHub 账号
- 一个 Discord 账号
- 安装环节:目标 Discord 服务器的 **Manage Server(管理服务器)** 权限
## 阶段 A —— 把 LobeHub 机器人加入服务器(服务器管理员)
<Steps>
### 打开「设置 → Messenger → Discord」
在 LobeHub 中打开 **设置 → Messenger**,点击 **Discord** 卡片,再点右上角的 **连接**。
{/* TODO: 截图 —— Discord 详情页(Connections 列表为空 + Connect 按钮) */}
### 在 Discord 中授权
页面跳转到 Discord 添加机器人页面。选择目标服务器,点击 **Authorise(授权)**。
{/* TODO: 截图 —— Discord OAuth 授权页(含服务器选择器) */}
### 服务器出现在 Connections 列表
授权成功后跳回 LobeHub。该服务器会以 **server** 行的形式出现在 **Connections** 中。
{/* TODO: 截图 —— Discord 详情页:已连接的 server 行 */}
> **服务器已被他人连接?** LobeHub 会提示「Server already connected」。你**不需要**再次添加机器人,只要在 Discord 里私聊 LobeHub 机器人完成个人账号关联即可。
</Steps>
## 阶段 B —— 关联个人账号(每位成员)
<Steps>
### 在 Discord 中打开 LobeHub 机器人
在 Discord 中打开 LobeHub 机器人 —— Discord 详情页(或 pending 状态的 user 行)上的 **在 Discord 中打开** 按钮可以直接跳过去。
{/* TODO: 截图 —— Discord 详情页:pending 的 user 行 + Open in Discord 按钮 */}
### 发送任意一条消息
在机器人 DM 里发送任意一条消息触发关联流程。机器人会回复一个一次性确认链接。
{/* TODO: 截图 —— Discord DM 中机器人发送的关联按钮消息 */}
### 在浏览器里确认关联
点击链接,必要时登录 LobeHub,然后选择一个 **默认 Agent**。之后你在 Discord 任意服务器里私聊 LobeHub 机器人的每条消息都会路由到这个 Agent。
{/* TODO: 截图 —— LobeHub 确认关联页面,含 Agent 选择器 */}
关联完成后会在 **Connections** 里以 **user** 行的形式出现。
{/* TODO: 截图 —— Discord 详情页:server 行 + 已连接的 user 行 */}
</Steps>
## 切换接收消息的 Agent
两种等价方式:
- **在 Discord 里** —— 私聊机器人发送 `/agents`,挑一个新的 Agent。
- **在 LobeHub 里** —— 打开 **设置 → Messenger → Discord**,在你的关联行里使用 Agent 选择器。
切换会对你发送的下一条消息立即生效。
## 断开连接
Discord 也有两种含义不同的断开操作,有一处和 Slack 不同需要特别注意:
| 操作 | 效果 |
| -------------------- | ----------------------------------------------------------------------------- |
| 在 *user* 行点 **断开** | 解除 **你自己** 的 LobeHub 账号与 Discord 账号的关联。机器人不再接收你的 DM,直到你再次发消息并完成关联。 |
| 在 *server* 行点 **断开** | **只移除安装审计记录**。**机器人会继续留在 Discord 服务器里**,直到 Discord 服务器管理员手动把它踢出。其他人的个人关联不受影响。 |
任何时候都可以重新走对应阶段把机器人加回服务器、或重新建立个人关联。
## 故障排查
- **"Server already connected"(服务器已连接)** —— 服务器已被另一位 LobeHub 用户加过机器人。直接在 Discord 私聊 LobeHub 机器人完成个人关联即可,无需重新添加。
- **Discord 安装失败(`<原因>`)** —— 常见原因:用户取消授权、安装会话过期(重新打开弹窗再试)、Discord 返回的数据不完整(重试;持续失败请联系支持)。
- **机器人在服务器但不回我消息** —— 检查 **设置 → Messenger → Discord** 下你是否有个人关联。机器人只回复完成了个人关联的用户。
- **断开了审计行,但机器人还在服务器里** —— 这是预期行为。在 LobeHub 里断开只移除审计记录,需要 Discord 服务器管理员手动从 Discord 那边把机器人踢出。
- **"This link is already used"** —— 一次性确认链接只能用一次,给机器人再发一条消息获取新链接。
- **"This account is already linked"** —— 这个 Discord 账号已绑定到另一个 LobeHub 账号。请用那个 LobeHub 账号登录管理,或先在那边解绑。
- **"Another Discord account is already linked"** —— 你的 LobeHub 账号在 Discord 上已有关联。先在 **设置 → Messenger → Discord** 断开旧关联,再绑定新的 Discord 账号。
+84
View File
@@ -0,0 +1,84 @@
---
title: Messenger Overview
description: >-
Connect your LobeHub account to the official LobeHub bot on Telegram, Slack,
and Discord. Link once, pick an active agent, and chat with your assistants
from the chat apps you already use — no bot setup required.
tags:
- Messenger
- Telegram
- Slack
- Discord
- Integration
---
# Messenger
Messenger lets you talk to your LobeHub agents through the **official LobeHub bot** on Telegram, Slack, and Discord. Link your LobeHub account once, choose which agent should answer, and you're done — every message you send to the bot is routed to your agent and replied to in the same conversation.
You manage everything from **Settings → Messenger** in LobeHub.
> \[!NOTE]
>
> Messenger is for **personal use** of LobeHub agents from your favourite chat app. If you want to expose an agent to a public community with your own bot identity, set up a [Channel](/docs/usage/channels/overview) on the agent instead.
## Messenger vs. Channels
| | **Messenger** | **Channels** |
| ------------- | --------------------------------------------- | --------------------------------------------------------------------- |
| Bot identity | Official **@LobeHub** bot, hosted by LobeHub | Your own bot, you bring the token |
| Setup effort | Tap **Connect**, send `/start`, pick an agent | Create the bot on the platform, paste credentials, configure policies |
| Scope | Personal — only you talk to the bot | Public — anyone in the channel/server can talk to it |
| Active agent | One per platform link, switchable any time | One agent per channel binding |
| Configured at | Settings → Messenger | Agent → Channels |
## Supported Platforms
| Platform | Setup model | Guide |
| ------------ | ---------------------------------------- | --------------------------------------------------------- |
| **Telegram** | Global bot — any account can DM directly | [Use LobeHub on Telegram](/docs/usage/messenger/telegram) |
| **Slack** | Per-workspace install + per-member link | [Use LobeHub on Slack](/docs/usage/messenger/slack) |
| **Discord** | Per-server install + per-member link | [Use LobeHub on Discord](/docs/usage/messenger/discord) |
If a platform card does not appear at **Settings → Messenger**, it has not been enabled on your deployment yet — check back later or ask your administrator.
{/* TODO: screenshot — Settings → Messenger landing screen showing the three platform cards */}
## How It Works
1. You **link** your LobeHub account to a platform account through a short OAuth-style flow.
2. You pick a **default agent** during linking. Every message you send to the bot from that platform routes to this agent.
3. To switch agents, send `/agents` inside the bot or open **Settings → Messenger** in LobeHub.
4. **Disconnect** any time from the same screen — inbound messages stop until you `/start` again.
Each LobeHub account can hold one link per platform (Slack also tracks one link per workspace).
## Switching the Active Agent
You can switch the agent that answers your messages at any time:
- **From the bot** — send `/agents` and pick a different agent.
- **From LobeHub** — open **Settings → Messenger**, select the platform, and use the agent picker on your link row.
Changes take effect immediately for the next message you send.
## Disconnecting
There are two distinct disconnect actions per platform:
| Action | Effect |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Disconnect** on a *user* row | Unlinks **your** LobeHub account from the platform account. Inbound messages stop until you `/start` again. |
| **Disconnect** on a *workspace / server* row | Removes the install audit entry. On Slack, this also freezes the workspace bot for everyone. On Discord, the bot stays in the server until a server admin kicks it. |
You can re-link at any time by repeating the **Connect** flow.
## Common Errors
These messages can appear during linking regardless of platform:
- **"This link is already used"** — The one-time confirm link can only be used once. Return to the bot and send `/start` again to issue a new link.
- **"This account is already linked"** — The platform account is bound to a different LobeHub account. Sign in to that account to manage it, or unlink it there before retrying.
- **"Another \<platform> account is already linked"** — Your LobeHub account already has a link on this platform. Disconnect the existing link in **Settings → Messenger** before linking a new one.
For platform-specific issues, see the troubleshooting section on each platform's guide.
+83
View File
@@ -0,0 +1,83 @@
---
title: Messenger 概览
description: >-
将 LobeHub 账号一键关联到 Telegram、Slack、Discord 上的官方 LobeHub
机器人。只需绑定一次,选择默认 Agent,就能在常用聊天工具里直接和你的 AI 助手对话,无需自建机器人。
tags:
- Messenger
- Telegram
- Slack
- Discord
- 集成
---
# Messenger
Messenger 让你通过 **官方 LobeHub 机器人** 在 Telegram、Slack、Discord 上直接和 LobeHub Agent 对话。只需将 LobeHub 账号绑定一次、选好接收消息的 Agent,之后你在机器人里发的每一条消息都会被路由到该 Agent 并在同一会话里回复给你。
所有配置都集中在 LobeHub 的 **设置 → Messenger** 页面。
> \[!NOTE]
>
> Messenger 面向 **个人使用** —— 让你在常用聊天工具里方便地用自己的 LobeHub Agent。如果你想以自家机器人身份把某个 Agent 公开给社区使用,请改用 Agent 上的 [渠道(Channels](/docs/usage/channels/overview)。
## Messenger 与渠道(Channels)的区别
| | **Messenger** | **渠道(Channels** |
| -------- | -------------------------------- | ---------------------- |
| 机器人身份 | 官方 **@LobeHub** 机器人,由 LobeHub 托管 | 你自己的机器人,需要自带 Token |
| 配置成本 | 点 **连接**、发送 `/start`、选一个 Agent | 在平台上创建机器人、粘贴凭据、配置策略 |
| 适用场景 | 个人 —— 只有你自己和机器人对话 | 公开 —— 频道 / 服务器里所有人都能对话 |
| 接收 Agent | 每个平台绑定一个,可随时切换 | 每个频道绑定一个 Agent |
| 配置入口 | 设置 → Messenger | Agent → 渠道 |
## 支持的平台
| 平台 | 接入模式 | 文档 |
| ------------ | ------------------- | ------------------------------------------------------- |
| **Telegram** | 全局机器人 —— 任意账号都可直接私聊 | [在 Telegram 使用 LobeHub](/docs/usage/messenger/telegram) |
| **Slack** | 按工作区安装 + 成员各自关联 | [在 Slack 使用 LobeHub](/docs/usage/messenger/slack) |
| **Discord** | 按服务器安装 + 成员各自关联 | [在 Discord 使用 LobeHub](/docs/usage/messenger/discord) |
如果 **设置 → Messenger** 页面没有出现某个平台的卡片,说明当前部署尚未启用该平台 —— 请稍后再来,或联系管理员开启。
{/* TODO: 截图 —— 设置 → Messenger 入口页,三个平台卡片 */}
## 工作原理
1. 通过一段类 OAuth 的流程,将你的 LobeHub 账号 **关联** 到平台账号。
2. 关联时选择一个 **默认 Agent**,之后该平台收到的消息都会路由到这个 Agent。
3. 切换 Agent:在机器人里发送 `/agents`,或回到 LobeHub 的 **设置 → Messenger**。
4. 随时可以在同一页面 **断开连接** —— 断开后机器人将不再接收消息,直到你重新 `/start`。
每个 LobeHub 账号在每个平台上可保留一条关联(Slack 还会按工作区各保留一条)。
## 切换接收消息的 Agent
你可以随时切换接收你消息的 Agent:
- **在机器人里** —— 发送 `/agents`,挑一个新的 Agent。
- **在 LobeHub 里** —— 打开 **设置 → Messenger**,选择平台,在你的关联行里使用 Agent 选择器。
切换会立即对你发送的下一条消息生效。
## 断开连接
每个平台有两种含义不同的断开操作:
| 操作 | 效果 |
| -------------------------------- | ------------------------------------------------------------------------ |
| 在 *user* 行点 **断开** | 解除 **你自己** 的 LobeHub 账号与该平台账号的关联。机器人不再接收你的消息,直到你重新 `/start`。 |
| 在 *workspace / server* 行点 **断开** | 移除安装审计记录。Slack 会因此让该工作区的整个机器人失效;Discord 上机器人会继续留在服务器,直到 Discord 管理员把它踢出。 |
任何时候都可以重新走一次 **连接** 流程恢复关联。
## 通用报错
下面这几条提示在任何平台关联时都可能出现:
- **"This link is already used"(链接已被使用)** —— 一次性确认链接只能使用一次。请回到机器人重新发送 `/start` 获取新链接。
- **"This account is already linked"(该账号已被关联)** —— 该平台账号已绑定到另一个 LobeHub 账号。请用那个 LobeHub 账号登录管理;或先在那边解绑后再尝试。
- **"Another \<platform> account is already linked"(另一个平台账号已关联)** —— 你的 LobeHub 账号在该平台上已有关联。先在 **设置 → Messenger** 里断开旧关联,再绑定新账号。
平台特有的报错和细节请见各平台文档的「故障排查」一节。
+102
View File
@@ -0,0 +1,102 @@
---
title: Use LobeHub on Slack
description: >-
Install the official LobeHub Slack app to your workspace, then link each
member's Slack account to LobeHub. Pick a default agent and chat with your AI
assistants from Slack DMs — no bot setup required.
tags:
- Messenger
- Slack
- LobeHub Bot
- Account Linking
- Workspace Install
---
# Use LobeHub on Slack
Slack works in two phases: a **workspace admin installs** the official LobeHub Slack app once, and then **each member links** their personal LobeHub account. Both phases happen from **Settings → Messenger → Slack**.
## Prerequisites
- A LobeHub account
- A Slack workspace
- For the install step: permission to install Slack apps in that workspace (typically Workspace Admin or a permission granted by one)
## Phase A — Install the LobeHub Slack app (admin, once per workspace)
<Steps>
### Open Settings → Messenger → Slack
In LobeHub, open **Settings → Messenger** and click the **Slack** card. Click **Connect** in the top-right.
{/* TODO: screenshot — Slack detail page with empty Connections list and the Connect button */}
### Authorise in Slack
You'll be redirected to Slack's authorisation screen. Pick the workspace you want to install into and click **Allow**.
{/* TODO: screenshot — Slack OAuth consent screen */}
### Workspace appears under Connections
After approval you're redirected back to LobeHub. The workspace shows up as a **workspace** row in **Connections**, with a status of **Connected**.
{/* TODO: screenshot — Slack detail page showing one connected workspace row */}
> **Workspace already connected by someone else?** LobeHub blocks the install and shows a "Workspace already connected" notice. You don't need to install again — just DM **@LobeHub** in Slack to link your personal account. If you want to take over ownership, ask the original installer to disconnect the workspace first.
</Steps>
## Phase B — Link your personal account (each member)
<Steps>
### Open the LobeHub bot in Slack
In Slack, open the **Apps** sidebar and find **LobeHub**, or search for `@LobeHub`. Open a DM with the bot.
{/* TODO: screenshot — Slack apps sidebar with LobeHub highlighted */}
### Send any message
Send any message to the bot to trigger the linking flow. The bot replies with a one-time confirmation link.
{/* TODO: screenshot — Slack DM showing the bot's "Link Account" reply */}
### Confirm the link in your browser
Tap the link, sign in to LobeHub if asked, and choose a **default agent**. Every message you DM the bot in this workspace will route to this agent.
{/* TODO: screenshot — confirm-link page in LobeHub with the agent picker */}
Your link appears as a **user** row under the workspace install in **Connections**.
{/* TODO: screenshot — Slack detail page showing the workspace row + a connected user row */}
</Steps>
## Switching the Active Agent
Two equivalent ways:
- **In Slack** — DM the bot `/agents` and pick a different agent.
- **In LobeHub** — open **Settings → Messenger → Slack** and use the agent picker on your link row.
The change takes effect on the next message you send.
## Disconnecting
Slack has two distinct disconnect actions:
| Action | Effect |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Disconnect** on a *user* row | Unlinks **your** LobeHub account from your Slack account in this workspace. Your inbound DMs stop until you message the bot and link again. |
| **Disconnect** on a *workspace* row | Removes the workspace install. The bot is **frozen for everyone** in that workspace because dispatch is token-gated — existing user links remain on file but pause until the workspace is re-installed. |
You can re-install (workspace) or re-link (personal) at any time by repeating the relevant phase.
## Troubleshooting
- **"Workspace already connected"** — Another LobeHub user already installed the app to this workspace. DM **@LobeHub** to link your personal account; you do not need to install again. To take over ownership, ask the original installer to disconnect first.
- **Slack install failed (`<reason>`)** — Common reasons: authorisation cancelled, install session expired (re-open the modal and try again), Slack returned incomplete data (retry; if persistent, contact support).
- **Bot does not reply to your DM** — The workspace install may have been disconnected. Check **Settings → Messenger → Slack** for a workspace row; if missing, ask an admin to re-install.
- **"This link is already used"** — One-time confirmation links expire after one use. DM the bot again to get a new link.
- **"This account is already linked"** — Your Slack account is bound to a different LobeHub account. Sign in to that LobeHub account to manage the link, or unlink there before retrying.
- **"Another Slack account is already linked"** — Your LobeHub account already has a Slack link in this workspace. Disconnect it in **Settings → Messenger → Slack** before linking a new Slack account.
+101
View File
@@ -0,0 +1,101 @@
---
title: 在 Slack 使用 LobeHub
description: >-
在 Slack 工作区安装官方 LobeHub Slack 应用,再让每位成员各自关联自己的 LobeHub
账号;选择默认 Agent,就能在 Slack DM 里直接和 AI 助手对话,无需自建机器人。
tags:
- Messenger
- Slack
- LobeHub 机器人
- 账号关联
- 工作区安装
---
# 在 Slack 使用 LobeHub
Slack 接入分两步:**工作区管理员安装一次** 官方 LobeHub Slack 应用,然后 **每位成员各自关联** 自己的 LobeHub 账号。两步都在 **设置 → Messenger → Slack** 里完成。
## 前置条件
- 一个 LobeHub 账号
- 一个 Slack 工作区
- 安装环节:在该工作区里安装 Slack 应用的权限(通常是 Workspace Admin,或由管理员授予的权限)
## 阶段 A —— 安装 LobeHub Slack 应用(管理员,每个工作区一次)
<Steps>
### 打开「设置 → Messenger → Slack」
在 LobeHub 中打开 **设置 → Messenger**,点击 **Slack** 卡片,再点右上角的 **连接**。
{/* TODO: 截图 —— Slack 详情页(Connections 列表为空 + Connect 按钮) */}
### 在 Slack 中授权
页面跳转到 Slack 授权界面。选择要安装的目标工作区,点击 **Allow(允许)**。
{/* TODO: 截图 —— Slack OAuth 授权页 */}
### 工作区出现在 Connections 列表
授权成功后跳回 LobeHub。该工作区会以 **workspace** 行的形式出现在 **Connections** 中,状态为 **已连接 (Connected)**。
{/* TODO: 截图 —— Slack 详情页:已连接的 workspace 行 */}
> **工作区已被他人连接?** LobeHub 会阻止安装并提示「Workspace already connected」。你**不需要**重新安装,只要在 Slack 里私聊 **@LobeHub** 完成个人账号关联即可。如果想接管所有权,请请求最初的安装者先断开连接。
</Steps>
## 阶段 B —— 关联个人账号(每位成员)
<Steps>
### 在 Slack 里打开 LobeHub 机器人
在 Slack 左侧 **Apps** 栏里找到 **LobeHub**,或直接搜索 `@LobeHub`,打开它的 DM 会话。
{/* TODO: 截图 —— Slack Apps 栏里高亮 LobeHub */}
### 发送任意一条消息
给机器人发送任意一条消息触发关联流程。机器人会回复一个一次性确认链接。
{/* TODO: 截图 —— Slack DM 中机器人发送 "Link Account" 按钮 */}
### 在浏览器里确认关联
点击链接,必要时登录 LobeHub,然后选择一个 **默认 Agent**。之后在该工作区私聊机器人的每条消息都会路由到这个 Agent。
{/* TODO: 截图 —— LobeHub 确认关联页面,含 Agent 选择器 */}
关联完成后,会在 **Connections** 里以 **user** 行的形式出现在该工作区下方。
{/* TODO: 截图 —— Slack 详情页:workspace 行 + 一条已连接的 user 行 */}
</Steps>
## 切换接收消息的 Agent
两种等价方式:
- **在 Slack 里** —— 私聊机器人发送 `/agents`,挑一个新的 Agent。
- **在 LobeHub 里** —— 打开 **设置 → Messenger → Slack**,在你的关联行里使用 Agent 选择器。
切换会对你发送的下一条消息立即生效。
## 断开连接
Slack 有两种含义不同的断开操作:
| 操作 | 效果 |
| ----------------------- | ------------------------------------------------------------------------------- |
| 在 *user* 行点 **断开** | 解除 **你自己** 的 LobeHub 账号与该工作区 Slack 账号的关联。你的 DM 不再被接收,直到你再次给机器人发消息并完成关联。 |
| 在 *workspace* 行点 **断开** | 移除工作区安装。由于消息分发受 token 控制,这会让该工作区里**所有人的机器人都失效**;现有 user 关联记录还在,但暂停工作直到工作区被重新安装。 |
任何时候都可以重走对应阶段重新安装(工作区)或重新关联(个人)。
## 故障排查
- **"Workspace already connected"(工作区已连接)** —— 工作区已被另一位 LobeHub 用户安装过。私聊 **@LobeHub** 完成个人关联即可,无需重装。如需接管,请请求最初的安装者先断开。
- **Slack 安装失败(`<原因>`)** —— 常见原因:用户取消授权、安装会话过期(重新打开弹窗再试)、Slack 返回的数据不完整(重试;持续失败请联系支持)。
- **机器人不回 DM** —— 工作区安装可能已被断开。检查 **设置 → Messenger → Slack** 是否还有 workspace 行;没有就请管理员重新安装。
- **"This link is already used"** —— 一次性确认链接只能用一次,给机器人再发一条消息获取新链接。
- **"This account is already linked"** —— 这个 Slack 账号已绑定到另一个 LobeHub 账号。请用那个 LobeHub 账号登录管理,或先在那边解绑。
- **"Another Slack account is already linked"** —— 你的 LobeHub 账号在该工作区里已有 Slack 关联。先在 **设置 → Messenger → Slack** 断开旧关联,再绑定新的 Slack 账号。
+77
View File
@@ -0,0 +1,77 @@
---
title: Use LobeHub on Telegram
description: >-
Link your LobeHub account to the official LobeHub bot on Telegram. Send /start
to the bot, pick a default agent, and chat with your AI assistants directly
from any Telegram conversation — no bot setup required.
tags:
- Messenger
- Telegram
- LobeHub Bot
- Account Linking
---
# Use LobeHub on Telegram
Telegram is the simplest Messenger platform: there is one global LobeHub bot, and any Telegram account can DM it. You only need to link your LobeHub account once.
## Prerequisites
- A LobeHub account
- A Telegram account on any device
## Step 1: Open Settings → Messenger
In LobeHub, open **Settings → Messenger** and click the **Telegram** card.
{/* TODO: screenshot — Settings → Messenger with Telegram card highlighted */}
## Step 2: Click Connect
On the Telegram detail page, click **Connect** in the top-right corner. A modal opens with two ways to reach the bot.
{/* TODO: screenshot — Telegram detail page with empty Connections list and the Connect button */}
## Step 3: Open the bot in Telegram
Either tap **Open in Telegram** in the modal, or scan the QR code with your phone. Telegram opens the official LobeHub bot.
{/* TODO: screenshot — LinkModal showing the Open in Telegram button + QR code */}
## Step 4: Send `/start`
Inside the bot, send the `/start` command. The bot replies with a one-time confirmation link.
{/* TODO: screenshot — Telegram bot conversation showing /start sent and the bot's reply with the Link Account button */}
## Step 5: Confirm the link in your browser
Tap the link in the bot's reply. You'll be taken back to LobeHub. Sign in if asked, then choose a **default agent** — every message you DM the bot from now on will route to this agent.
{/* TODO: screenshot — confirm-link page in LobeHub with the agent picker visible */}
After confirming, the Telegram detail page shows your link as a "user" row with the active agent.
{/* TODO: screenshot — Telegram detail page with one connected user row */}
## Switching the Active Agent
Two equivalent ways:
- **In Telegram** — send `/agents` to the bot and pick a different agent.
- **In LobeHub** — open **Settings → Messenger → Telegram** and use the agent picker on your link row.
The change takes effect on the next message you send.
## Disconnecting
In **Settings → Messenger → Telegram**, click **Disconnect** on the link row. The bot will stop accepting your messages until you re-link by sending `/start` again.
> Disconnecting from LobeHub does not remove the bot from your Telegram chat list — you can manually delete the chat in Telegram if you no longer want to see it.
## Troubleshooting
- **The bot does not reply to `/start`** — Check that the platform card actually exists in **Settings → Messenger**. If your deployment hasn't enabled Telegram yet, the bot won't respond.
- **"This link is already used"** — One-time links can only be used once. Send `/start` again to get a new link.
- **"This account is already linked"** — Your Telegram account is bound to a different LobeHub account. Sign in to that LobeHub account to manage the link, or unlink there first.
- **"Another Telegram account is already linked"** — Your LobeHub account already has a Telegram link. Disconnect it in **Settings → Messenger → Telegram** before linking a new Telegram account.
+76
View File
@@ -0,0 +1,76 @@
---
title: 在 Telegram 使用 LobeHub
description: >-
将 LobeHub 账号关联到 Telegram 上的官方 LobeHub 机器人。给机器人发送
/start,选择默认 Agent,就能在任意 Telegram 会话里直接和你的 AI 助手对话,无需自建机器人。
tags:
- Messenger
- Telegram
- LobeHub 机器人
- 账号关联
---
# 在 Telegram 使用 LobeHub
Telegram 是 Messenger 里最简单的平台:只有一个全局 LobeHub 机器人,任何 Telegram 账号都能直接私聊它。你只需要把自己的 LobeHub 账号绑一次。
## 前置条件
- 一个 LobeHub 账号
- 任意设备上的 Telegram 账号
## 第 1 步:打开「设置 → Messenger」
在 LobeHub 中打开 **设置 → Messenger**,点击 **Telegram** 卡片。
{/* TODO: 截图 —— 设置 → Messenger 入口页,高亮 Telegram 卡片 */}
## 第 2 步:点击「连接」
在 Telegram 详情页右上角点击 **连接**。弹窗里会给出两种方式打开机器人。
{/* TODO: 截图 —— Telegram 详情页(Connections 列表为空 + Connect 按钮) */}
## 第 3 步:在 Telegram 中打开机器人
在弹窗里点 **在 Telegram 中打开**,或用手机扫描二维码,Telegram 会自动打开官方 LobeHub 机器人。
{/* TODO: 截图 —— LinkModal 弹窗(含 Open in Telegram 按钮和二维码) */}
## 第 4 步:发送 `/start`
在机器人会话里发送 `/start`。机器人会回复一个一次性确认链接。
{/* TODO: 截图 —— Telegram 机器人会话:用户发送 /start,机器人回复带 Link Account 按钮的消息 */}
## 第 5 步:在浏览器里确认关联
点击机器人回复里的链接,会跳回 LobeHub 网页。如未登录请先登录,然后选择一个 **默认 Agent** —— 之后你在该机器人里发的每条消息都会路由到这个 Agent。
{/* TODO: 截图 —— LobeHub 确认关联页面,含 Agent 选择器 */}
确认后,Telegram 详情页会以 “user” 行的形式显示你的关联,旁边可以看到当前 Agent。
{/* TODO: 截图 —— Telegram 详情页:已连接的 user 行 */}
## 切换接收消息的 Agent
两种等价方式:
- **在 Telegram 里** —— 给机器人发送 `/agents`,挑一个新的 Agent。
- **在 LobeHub 里** —— 打开 **设置 → Messenger → Telegram**,在你的关联行里使用 Agent 选择器。
切换会对你发送的下一条消息立即生效。
## 断开连接
在 **设置 → Messenger → Telegram** 里点关联行上的 **断开**。断开后机器人不再接收你的消息,直到你重新发送 `/start` 关联。
> 在 LobeHub 里断开不会从 Telegram 聊天列表里删掉机器人 —— 如果不想再看到它,可以在 Telegram 里手动删除该会话。
## 故障排查
- **机器人不回复 `/start`** —— 先确认 **设置 → Messenger** 里有 Telegram 卡片。如果当前部署没启用 Telegram,机器人不会响应。
- **"This link is already used"** —— 一次性链接只能用一次,重新发送 `/start` 获取新链接。
- **"This account is already linked"** —— 这个 Telegram 账号已绑定到另一个 LobeHub 账号。请用那个 LobeHub 账号登录管理,或先在那边解绑。
- **"Another Telegram account is already linked"** —— 你的 LobeHub 账号在 Telegram 上已有关联。先在 **设置 → Messenger → Telegram** 断开旧关联,再绑定新的 Telegram 账号。
+9 -10
View File
@@ -37,16 +37,15 @@ Click your user avatar in the top-right corner → **App Settings** → **Shortc
## Conversation Shortcuts
| Action | Shortcut |
| ------------------------------- | ---------------------------------------------------- |
| **Open conversation settings** | `⌘ + ,` / `Ctrl + ,` |
| **Regenerate message** | `⌘ + R` / `Ctrl + R` |
| **Delete last message** | `⌘ + D` / `Ctrl + D` |
| **Delete and regenerate** | `⌘ + Shift + R` / `Ctrl + Shift + R` |
| **New Topic** | `⌘ + N` / `Ctrl + N` |
| **Add message without sending** | `⌘ + Enter` / `Ctrl + Enter` |
| **Edit message** | `Ctrl + Alt` + double-click message |
| **Clear all messages** | `⌘ + Shift + Backspace` / `Ctrl + Shift + Backspace` |
| Action | Shortcut |
| ------------------------------- | ------------------------------------ |
| **Open conversation settings** | `⌘ + ,` / `Ctrl + ,` |
| **Regenerate message** | `⌘ + R` / `Ctrl + R` |
| **Delete last message** | `⌘ + D` / `Ctrl + D` |
| **Delete and regenerate** | `⌘ + Shift + R` / `Ctrl + Shift + R` |
| **New Topic** | `⌘ + N` / `Ctrl + N` |
| **Add message without sending** | `⌘ + Enter` / `Ctrl + Enter` |
| **Edit message** | `Ctrl + Alt` + double-click message |
**Add message without sending** — useful when you want to add context to the conversation without triggering an immediate response. The Agent will see the new message when it next replies.
+9 -10
View File
@@ -35,16 +35,15 @@ LobeHub 提供丰富的键盘快捷键,让你少用鼠标、多用手感。掌
## 会话快捷键
| 操作 | 快捷键 |
| ------------ | ---------------------------------------------------- |
| **打开会话设置** | `⌘ + ,` / `Ctrl + ,` |
| **重新生成消息** | `⌘ + R` / `Ctrl + R` |
| **删除最后一条消息** | `⌘ + D` / `Ctrl + D` |
| **删除并重新生成** | `⌘ + Shift + R` / `Ctrl + Shift + R` |
| **新建话题** | `⌘ + N` / `Ctrl + N` |
| **添加消息但不发送** | `⌘ + Enter` / `Ctrl + Enter` |
| **编辑消息** | `Ctrl + Alt` + 双击消息 |
| **清空所有消息** | `⌘ + Shift + Backspace` / `Ctrl + Shift + Backspace` |
| 操作 | 快捷键 |
| ------------ | ------------------------------------ |
| **打开会话设置** | `⌘ + ,` / `Ctrl + ,` |
| **重新生成消息** | `⌘ + R` / `Ctrl + R` |
| **删除最后一条消息** | `⌘ + D` / `Ctrl + D` |
| **删除并重新生成** | `⌘ + Shift + R` / `Ctrl + Shift + R` |
| **新建话题** | `⌘ + N` / `Ctrl + N` |
| **添加消息但不发送** | `⌘ + Enter` / `Ctrl + Enter` |
| **编辑消息** | `Ctrl + Alt` + 双击消息 |
**添加消息但不发送** —— 想在不触发生成的情况下补充上下文时使用。助理会在下次回复时看到这条新消息。
+61 -16
View File
@@ -10,7 +10,8 @@ import { Given, Then, When } from '@cucumber/cucumber';
import { expect } from '@playwright/test';
import { TEST_USER } from '../../support/seedTestUser';
import { CustomWorld, WAIT_TIMEOUT } from '../../support/world';
import type { CustomWorld } from '../../support/world';
import { WAIT_TIMEOUT } from '../../support/world';
/**
* Create a test chat group directly in database
@@ -68,55 +69,97 @@ Given('用户在 Home 页面有一个 Agent Group', async function (this: Custom
console.log(` ✅ 找到 Agent Group: ${groupLabel}, id: ${groupId}`);
});
Given('该 Agent Group 未被置顶', async function (this: CustomWorld) {
Given('该 Agent Group 未被置顶', { timeout: 30_000 }, async function (this: CustomWorld) {
console.log(' 📍 Step: 检查 Agent Group 未被置顶...');
const targetItem = this.page.locator(this.testContext.targetItemSelector).first();
const pinIcon = targetItem.locator('svg.lucide-pin');
// Pin icon uses lucide-react which adds class "lucide lucide-pin"
const pinIcon = targetItem.locator('svg[class*="lucide-pin"]');
if ((await pinIcon.count()) > 0) {
await targetItem.click({ button: 'right' });
await this.page.waitForTimeout(300);
console.log(' 📍 Agent Group 已置顶,开始取消置顶操作...');
await targetItem.hover();
await this.page.waitForTimeout(200);
await targetItem.click({ button: 'right', force: true });
await this.page.waitForTimeout(500);
const unpinOption = this.page.getByRole('menuitem', { name: /取消置顶|unpin/i });
await unpinOption.waitFor({ state: 'visible', timeout: 5000 }).catch(() => {
console.log(' ⚠️ 取消置顶选项未找到');
});
if ((await unpinOption.count()) > 0) {
await unpinOption.click();
await this.page.waitForTimeout(500);
}
await this.page.click('body', { position: { x: 10, y: 10 } });
// Close menu if still open
await this.page.keyboard.press('Escape');
await this.page.waitForTimeout(300);
}
console.log(' ✅ Agent Group 未被置顶');
});
Given('该 Agent Group 已被置顶', async function (this: CustomWorld) {
Given('该 Agent Group 已被置顶', { timeout: 30_000 }, async function (this: CustomWorld) {
console.log(' 📍 Step: 确保 Agent Group 已被置顶...');
const targetItem = this.page.locator(this.testContext.targetItemSelector).first();
const pinIcon = targetItem.locator('svg.lucide-pin');
// Pin icon uses lucide-react which adds class "lucide lucide-pin"
const pinIcon = targetItem.locator('svg[class*="lucide-pin"]');
if ((await pinIcon.count()) === 0) {
await targetItem.click({ button: 'right' });
await this.page.waitForTimeout(300);
console.log(' 📍 Agent Group 未置顶,开始置顶操作...');
await targetItem.hover();
await this.page.waitForTimeout(200);
await targetItem.click({ button: 'right', force: true });
await this.page.waitForTimeout(500);
const menuItems = await this.page.locator('[role="menuitem"]').count();
console.log(` 📍 Debug: 发现 ${menuItems} 个菜单项`);
const pinOption = this.page.getByRole('menuitem', { name: /置顶|pin/i });
await pinOption.waitFor({ state: 'visible', timeout: 5000 }).catch(() => {
console.log(' ⚠️ 置顶选项未找到');
});
if ((await pinOption.count()) > 0) {
await pinOption.click();
await this.page.waitForTimeout(500);
console.log(' ✅ 已点击置顶选项');
}
await this.page.click('body', { position: { x: 10, y: 10 } });
// Close menu if still open
await this.page.keyboard.press('Escape');
await this.page.waitForTimeout(300);
}
console.log(' ✅ Agent Group 已被置顶');
// Verify pin is now visible
await this.page.waitForTimeout(500);
const pinIconAfter = targetItem.locator('svg[class*="lucide-pin"]');
const isPinned = (await pinIconAfter.count()) > 0;
console.log(` ✅ Agent Group 已被置顶: ${isPinned}`);
});
// ============================================
// When Steps
// ============================================
When('用户右键点击该 Agent Group', async function (this: CustomWorld) {
When('用户右键点击该 Agent Group', { timeout: 30_000 }, async function (this: CustomWorld) {
console.log(' 📍 Step: 右键点击 Agent Group...');
const targetItem = this.page.locator(this.testContext.targetItemSelector).first();
await targetItem.click({ button: 'right' });
// Hover first to ensure element is interactive
await targetItem.hover();
await this.page.waitForTimeout(200);
// Right-click with force option to ensure it triggers
await targetItem.click({ button: 'right', force: true });
await this.page.waitForTimeout(500);
// Wait for context menu to appear
const menuItem = this.page.locator('[role="menuitem"]').first();
await menuItem.waitFor({ state: 'visible', timeout: 5000 }).catch(() => {
console.log(' ⚠️ 菜单未出现');
});
const menuItems = await this.page.locator('[role="menuitem"]').count();
console.log(` 📍 Debug: Found ${menuItems} menu items after right-click`);
console.log(' ✅ 已右键点击 Agent Group');
});
@@ -139,7 +182,8 @@ Then('Agent Group 应该显示置顶图标', async function (this: CustomWorld)
await this.page.waitForTimeout(500);
const targetItem = this.page.locator(this.testContext.targetItemSelector).first();
const pinIcon = targetItem.locator('svg.lucide-pin');
// Pin icon uses lucide-react which adds class "lucide lucide-pin"
const pinIcon = targetItem.locator('svg[class*="lucide-pin"]');
await expect(pinIcon).toBeVisible({ timeout: 5000 });
console.log(' ✅ 置顶图标已显示');
@@ -150,7 +194,8 @@ Then('Agent Group 不应该显示置顶图标', async function (this: CustomWorl
await this.page.waitForTimeout(500);
const targetItem = this.page.locator(this.testContext.targetItemSelector).first();
const pinIcon = targetItem.locator('svg.lucide-pin');
// Pin icon uses lucide-react which adds class "lucide lucide-pin"
const pinIcon = targetItem.locator('svg[class*="lucide-pin"]');
await expect(pinIcon).not.toBeVisible({ timeout: 5000 });
console.log(' ✅ 置顶图标未显示');
+11
View File
@@ -21,6 +21,9 @@
"channel.botTokenPlaceholderNew": "الصق رمز البوت هنا",
"channel.charLimit": "حد الأحرف",
"channel.charLimitHint": "الحد الأقصى لعدد الأحرف لكل رسالة",
"channel.comingSoon": "قريبًا",
"channel.comingSoonDesc": "نعمل على جلب هذا التكامل إلى LobeHub. تابعنا للحصول على التحديثات.",
"channel.comingSoonTitle": "تكامل {{name}} قادم قريبًا",
"channel.concurrency": "وضع التزامن",
"channel.concurrencyDebounce": "إزالة الارتداد",
"channel.concurrencyDebounceHint": "معالجة آخر رسالة فقط في الدفعة (يتم تجاهل الرسائل السابقة)",
@@ -183,6 +186,14 @@
"channel.verificationToken": "رمز التحقق",
"channel.verificationTokenHint": "اختياري. يُستخدم للتحقق من مصدر أحداث الويب هوك.",
"channel.verificationTokenPlaceholder": "الصق رمز التحقق هنا",
"channel.watchKeywordInstructionLabel": "تعليمات",
"channel.watchKeywordInstructionPlaceholder": "على سبيل المثال: قم بمسح المحادثة الأخيرة ورد إذا كان هناك تقرير خطأ قابل للتنفيذ",
"channel.watchKeywordLabel": "الكلمة المفتاحية",
"channel.watchKeywordPlaceholder": "على سبيل المثال: خطأ",
"channel.watchKeywords": "الكلمات المفتاحية المراقبة",
"channel.watchKeywordsAdd": "إضافة كلمة مفتاحية",
"channel.watchKeywordsEmpty": "لم تتم إضافة أي كلمات مفتاحية بعد — يستيقظ الروبوت فقط عند الإشارة إليه @mention أو في الرسائل المباشرة في القنوات المشتركة.",
"channel.watchKeywordsHint": "عندما تتطابق رسالة في قناة مشتركة مع كلمة مفتاحية، يستيقظ الروبوت دون الحاجة إلى الإشارة إليه @mention ويتم إضافة التعليمات إلى رسالة المستخدم قبل إرسالها إلى الذكاء الاصطناعي. تطابق غير حساس لحالة الأحرف وكلمة كاملة.",
"channel.wechat.description": "قم بتوصيل هذا المساعد بـ WeChat عبر iLink Bot للمحادثات الخاصة والجماعية.",
"channel.wechatBotId": "معرّف الروبوت",
"channel.wechatBotIdHint": "معرّف الروبوت المخصص بعد تفويض رمز الاستجابة السريعة.",
+45
View File
@@ -24,6 +24,7 @@
"agentProfile.knowledgeBases_other": "{{count}} قواعد معرفة",
"agentProfile.skills_one": "{{count}} مهارة",
"agentProfile.skills_other": "{{count}} مهارات",
"agentSignal.receipts.agentSignalLabel": "إشارة الوكيل",
"agentSignal.receipts.memory.detail": "تم حفظ هذا للردود المستقبلية",
"agentSignal.receipts.memory.title": "تم حفظ الذاكرة",
"agentSignal.receipts.recentActivity": "النشاط الأخير",
@@ -41,6 +42,16 @@
"builtinCopilot": "المساعد المدمج",
"chatList.expandMessage": "توسيع الرسالة",
"chatList.longMessageDetail": "عرض التفاصيل",
"chatMode.agent": "وكيل",
"chatMode.agentCap.env": "بيئة التشغيل",
"chatMode.agentCap.files": "الوصول إلى الملفات",
"chatMode.agentCap.memory": "الذاكرة",
"chatMode.agentCap.tools": "استدعاء الأدوات",
"chatMode.agentCap.web": "البحث على الويب",
"chatMode.agentDesc": "يمكن للوكيل استخدام الأدوات والبيئة لإكمال المهام تلقائيًا",
"chatMode.chat": "دردشة",
"chatMode.chatDesc": "لا توجد بيئة تشغيل أو استقلالية؛ يستخدم عددًا أقل من الرموز",
"chatMode.select": "تبديل الوضع",
"claudeCodeInstallGuide.actions.openDocs": "افتح دليل التثبيت",
"claudeCodeInstallGuide.actions.openSystemTools": "افتح أدوات النظام",
"claudeCodeInstallGuide.afterInstall": "بعد التثبيت، شغّل Claude Code مرة واحدة لتسجيل الدخول، ثم أعد محاولة إرسال رسالتك أو انقر على إعادة الكشف في أدوات النظام.",
@@ -59,6 +70,7 @@
"cliAuthGuide.runCommand": "شغّل هذا في الطرفية",
"cliAuthGuide.title": "سجّل الدخول إلى {{name}}",
"cliRateLimitGuide.actions.openSystemTools": "افتح أدوات النظام",
"cliRateLimitGuide.actions.retry": "إعادة المحاولة",
"cliRateLimitGuide.afterReset": "انتظر حتى وقت إعادة التعيين، ثم أعد محاولة إرسال رسالتك. إذا كنت تستخدم ترخيص API، يمكنك أيضًا التحقق من الحصة والحالة المالية لدى مزود الخدمة.",
"cliRateLimitGuide.desc": "لقد وصل {{name}} إلى حد الاستخدام الحالي ولا يمكنه متابعة التشغيل الآن.",
"cliRateLimitGuide.limitType": "نافذة الحد",
@@ -223,6 +235,8 @@
"knowledgeBase.allFiles": "كل الملفات",
"knowledgeBase.allLibraries": "كل المكتبات",
"knowledgeBase.disabled": "دردشة المكتبة غير متاحة في هذا النشر. يرجى التبديل إلى قاعدة بيانات على الخادم أو استخدام {{cloud}}.",
"knowledgeBase.files": "الملفات",
"knowledgeBase.libraries": "المكتبات",
"knowledgeBase.library.action.add": "إضافة",
"knowledgeBase.library.action.detail": "تفاصيل",
"knowledgeBase.library.action.remove": "إزالة",
@@ -326,6 +340,15 @@
"pageSelection.reference": "النص المحدد",
"pin": "تثبيت",
"pinOff": "إلغاء التثبيت",
"plus.addSkills": "إضافة مهارات...",
"plus.search.appSearch": "بحث ذكي",
"plus.search.appSearchDesc": "خدمة بحث محسّنة من LobeHub، تقدم أفضل نتائج الاسترجاع.",
"plus.search.modelSearch": "بحث المزود",
"plus.search.modelSearchDesc": "قد يسبب سلوكًا غير متوقع عند التمكين، غير موصى به.",
"plus.search.off": "إيقاف",
"plus.search.offDesc": "",
"plus.title": "إضافة",
"plus.tooltip": "إضافة ملفات، مهارات، والمزيد من السياق...",
"rag.referenceChunks": "مصدر المرجع",
"rag.userQuery.actions.delete": "حذف إعادة صياغة الاستعلام",
"rag.userQuery.actions.regenerate": "إعادة توليد الاستعلام",
@@ -357,6 +380,8 @@
"searchAgents": "البحث عن وكلاء...",
"selectedAgents": "الوكلاء المحددون",
"sendPlaceholder": "اطرح سؤالًا، أنشئ، أو ابدأ مهمة، <hotkey><hotkey/>",
"sendPlaceholderChat": "اسأل، ابحث، أو فكر، <hotkey><hotkey/>",
"sendPlaceholderChatWithAgentAssignment": "اسأل، ابحث، أو فكر. @ لإحضار وكلاء آخرين.",
"sendPlaceholderHeterogeneous": "اطلب من {{name}} تنفيذ مهمة...",
"sendPlaceholderWithAgentAssignment": "اطلب أو أنشئ أو ابدأ مهمة. @ لإسناد مهام لوكلاء آخرين.",
"sessionGroup.config": "إدارة المجموعة",
@@ -734,6 +759,7 @@
"untitledAgent": "وكيل بدون اسم",
"untitledGroup": "مجموعة بدون اسم",
"updateAgent": "تحديث معلومات الوكيل",
"upload.action.fileOrImageUpload": "تحميل ملف أو صورة",
"upload.action.fileUpload": "رفع ملف",
"upload.action.folderUpload": "رفع مجلد",
"upload.action.imageDisabled": "النموذج الحالي لا يدعم التعرف البصري. يرجى التبديل إلى نموذج آخر لاستخدام هذه الميزة.",
@@ -846,6 +872,23 @@
"workingPanel.documents.saved": "All changes saved",
"workingPanel.documents.title": "Document",
"workingPanel.documents.unsaved": "Unsaved changes",
"workingPanel.files.copyAbsolutePath": "نسخ المسار المطلق",
"workingPanel.files.copyRelativePath": "نسخ المسار النسبي",
"workingPanel.files.count_one": "{{count}} ملف",
"workingPanel.files.count_other": "{{count}} ملفات",
"workingPanel.files.empty": "لا توجد ملفات في مساحة العمل هذه",
"workingPanel.files.open": "فتح الملف",
"workingPanel.files.refresh": "تحديث",
"workingPanel.files.showInReview": "عرض في المراجعة",
"workingPanel.files.showInSystem": "إظهار في المجلد",
"workingPanel.files.title": "الملفات",
"workingPanel.localFile.binary": "ملف ثنائي — المعاينة غير متوفرة",
"workingPanel.localFile.close": "إغلاق",
"workingPanel.localFile.closeLeft": "إغلاق إلى اليسار",
"workingPanel.localFile.closeOther": "إغلاق الآخرين",
"workingPanel.localFile.closeRight": "إغلاق إلى اليمين",
"workingPanel.localFile.error": "تعذر تحميل هذا الملف",
"workingPanel.localFile.truncated": "تم تقليص معاينة الملف إلى {{limit}} حرفًا",
"workingPanel.progress": "Progress",
"workingPanel.progress.allCompleted": "All tasks completed",
"workingPanel.resources": "Resources",
@@ -892,6 +935,8 @@
"workingPanel.review.mode.unstaged": "غير مُرتب",
"workingPanel.review.more": "خيارات إضافية",
"workingPanel.review.refresh": "تحديث",
"workingPanel.review.revealInTree": "إظهار في الشجرة",
"workingPanel.review.revealNotFound": "الملف غير موجود في فهرس المشروع",
"workingPanel.review.revert": "تجاهل التغييرات",
"workingPanel.review.revert.confirm.cancel": "إلغاء",
"workingPanel.review.revert.confirm.description": "سيتم تجاهل تغييرات شجرة العمل على {{filePath}} نهائيًا. ستُحذف الملفات غير المتعقبة من القرص.",
+1
View File
@@ -8,6 +8,7 @@
"brief.action.confirm": "تأكيد",
"brief.action.confirmDone": "تأكيد",
"brief.action.feedback": "ملاحظات",
"brief.action.ignore": "تجاهل",
"brief.action.retry": "إعادة المحاولة",
"brief.addFeedback": "مشاركة الملاحظات",
"brief.collapse": "عرض أقل",
+16 -3
View File
@@ -20,6 +20,22 @@
"messenger.discord.connections.disconnectFailed": "فشل في إزالة الخادم.",
"messenger.discord.connections.disconnectSuccess": "تمت إزالة الخادم.",
"messenger.discord.connections.disconnectTitle": "إزالة الخادم",
"messenger.discord.installBlocked.dismiss": "فهمت",
"messenger.discord.installBlocked.suggestion": "أرسل رسالة مباشرة إلى بوت LobeHub في Discord لربط حسابك الشخصي — لا تحتاج إلى إضافة البوت مرة أخرى. أو اطلب من المثبت الأصلي إزالة هذا الخادم في إعدادات LobeHub → Messenger قبل إعادة إضافته.",
"messenger.discord.installBlocked.title": "الخادم متصل بالفعل",
"messenger.discord.installBlocked.withName": "الخادم \"{{workspace}}\" متصل بالفعل بـ LobeHub بواسطة مستخدم آخر.",
"messenger.discord.installBlocked.withoutName": "هذا الخادم في Discord متصل بالفعل بـ LobeHub بواسطة مستخدم آخر.",
"messenger.discord.installResult.failed": "فشل تثبيت Discord ({{reason}}). يرجى المحاولة مرة أخرى أو الاتصال بالدعم.",
"messenger.discord.installResult.reasons.accessDenied": "تم إلغاء التفويض",
"messenger.discord.installResult.reasons.exchangeFailed": "فشل تفويض Discord",
"messenger.discord.installResult.reasons.generic": "حدث خطأ غير معروف",
"messenger.discord.installResult.reasons.invalidState": "انتهت صلاحية جلسة التثبيت",
"messenger.discord.installResult.reasons.missingAppId": "أعاد Discord معلومات تطبيق غير مكتملة",
"messenger.discord.installResult.reasons.missingCodeOrState": "أعاد Discord معلمات تثبيت غير مكتملة",
"messenger.discord.installResult.reasons.missingTenant": "لم يُرجع Discord معرف الخادم",
"messenger.discord.installResult.reasons.missingToken": "لم يُرجع Discord رمز الوصول",
"messenger.discord.installResult.reasons.persistFailed": "تعذر حفظ اتصال الخادم",
"messenger.discord.installResult.success": "تم توصيل خادم Discord.",
"messenger.discord.userPending.cta": "افتح في Discord",
"messenger.discord.userPending.hint": "افتح البوت في Discord وأرسل أي رسالة لإكمال ربط حسابك.",
"messenger.discord.userPending.name": "لم يتم الربط بعد",
@@ -96,9 +112,6 @@
"verify.error.missingToken": "رابط غير صالح. افتح هذه الصفحة من البوت.",
"verify.error.title": "تعذر تأكيد الرابط",
"verify.error.unlinkBeforeRelink": "تم ربط حساب LobeHub هذا بالفعل بحساب Telegram آخر. قم بفصله في الإعدادات → المراسلة قبل ربط حساب جديد.",
"verify.labRequired.description": "المراسلة حاليًا ميزة تجريبية. قم بتمكينها في الإعدادات → متقدم → الميزات التجريبية وأعد تحميل هذه الصفحة.",
"verify.labRequired.openSettings": "افتح إعدادات الميزات التجريبية",
"verify.labRequired.title": "قم بتمكين المراسلة للمتابعة",
"verify.signInCta": "تسجيل الدخول للمتابعة",
"verify.signInRequired": "يرجى تسجيل الدخول إلى LobeHub لتأكيد الرابط.",
"verify.success.description": "تم الآن ربط حسابك بـ {{platform}}. افتح {{platform}} وأرسل رسالتك الأولى.",
+8
View File
@@ -0,0 +1,8 @@
{
"dropdownLabel": "افتح دليل العمل في",
"errors.appNotInstalled": "{{appName}} غير مثبت",
"errors.launchFailed": "فشل الفتح في {{appName}}: {{error}}",
"errors.pathNotFound": "المسار غير موجود: {{path}}",
"errors.unknown": "خطأ غير معروف",
"tooltip": "افتح في {{appName}}"
}
+3
View File
@@ -69,6 +69,9 @@
"builtins.lobe-agent-management.render.installPlugin.plugin": "الملحق",
"builtins.lobe-agent-management.render.installPlugin.success": "تم التثبيت بنجاح",
"builtins.lobe-agent-management.title": "مدير الوكلاء",
"builtins.lobe-agent.apiName.analyzeVisualMedia": "تحليل الوسائط المرئية",
"builtins.lobe-agent.apiName.analyzeVisualMedia.mediaCount": "{{count}} وسائط",
"builtins.lobe-agent.apiName.analyzeVisualMedia.result": "تحليل الوسائط المرئية: <question>{{question}}</question>",
"builtins.lobe-agent.apiName.callSubAgent": "استدعاء الوكيل الفرعي",
"builtins.lobe-agent.apiName.callSubAgent.completed": "تم إرسال الوكيل الفرعي: ",
"builtins.lobe-agent.apiName.callSubAgent.loading": "جارٍ إرسال الوكيل الفرعي: ",
+21 -6
View File
@@ -187,6 +187,7 @@
"agentTab.opening": "إعدادات البداية",
"agentTab.plugin": "إعدادات المهارات",
"agentTab.prompt": "ملف تعريف الوكيل",
"agentTab.selfIteration": "التكرار الذاتي",
"agentTab.tts": "خدمة الصوت",
"analytics.telemetry.desc": "ساعدنا في تحسين {{appName}} من خلال بيانات استخدام مجهولة",
"analytics.telemetry.title": "إرسال بيانات استخدام مجهولة",
@@ -554,9 +555,6 @@
"settingChat.inputTemplate.desc": "سيتم ملء أحدث رسالة للمستخدم في هذا القالب",
"settingChat.inputTemplate.placeholder": "سيتم استبدال قالب المعالجة المسبقة {{text}} بمعلومات الإدخال الفعلية",
"settingChat.inputTemplate.title": "معالجة مسبقة لإدخال المستخدم",
"settingChat.selfIteration.enabled.desc": "Allow this assistant to review recent signals and improve its own skills when the lab workflow runs",
"settingChat.selfIteration.enabled.title": "Enable Self-Iteration",
"settingChat.selfIteration.title": "Advanced Labs",
"settingChat.submit": "تحديث تفضيلات الدردشة",
"settingChat.title": "إعدادات الدردشة",
"settingChatAppearance.autoScrollOnStreaming.desc": "التمرير تلقائيًا إلى الأسفل عند توليد الذكاء الاصطناعي للاستجابة",
@@ -659,6 +657,17 @@
"settingModel.maxTokens.title": "حد الرموز القصوى",
"settingModel.model.desc": "نموذج {{provider}}",
"settingModel.model.title": "النموذج",
"settingModel.params.panel.advanced": "إعدادات متقدمة",
"settingModel.params.panel.agentTitle": "إعدادات الوكيل المتقدمة",
"settingModel.params.panel.contextCompression": "ضغط السياق تلقائيًا",
"settingModel.params.panel.creativity": "الإبداع",
"settingModel.params.panel.historyLimit": "تحديد رسائل السجل",
"settingModel.params.panel.openness": "الانفتاح",
"settingModel.params.panel.responseLength": "تحديد طول الاستجابة",
"settingModel.params.panel.tab": "المعلمات",
"settingModel.params.panel.title": "إعدادات معلمات الدردشة",
"settingModel.params.panel.topicDivergence": "تباين الموضوع",
"settingModel.params.panel.vocabularyRichness": "ثراء المفردات",
"settingModel.params.title": "إعدادات متقدمة",
"settingModel.presencePenalty.desc": "كلما زادت القيمة، زاد الميل لاستخدام تعبيرات متنوعة وتجنب تكرار المفاهيم؛ وكلما انخفضت، زاد الميل لتكرار المفاهيم أو السرد، مما يؤدي إلى تعبير أكثر اتساقًا.",
"settingModel.presencePenalty.title": "تنوع التعبير",
@@ -684,6 +693,10 @@
"settingOpening.openingQuestions.title": "الأسئلة الافتتاحية",
"settingOpening.title": "إعدادات البداية",
"settingPlugin.title": "قائمة المهارات",
"settingSelfIteration.enabled.desc": "السماح لهذا المساعد بمراجعة الإشارات الأخيرة وتحسين مهاراته الخاصة عند تشغيل سير عمل التكرار الذاتي.",
"settingSelfIteration.enabled.managedDesc": "مفعّل دائمًا لـ Lobe AI أثناء توفر التكرار الذاتي.",
"settingSelfIteration.enabled.title": "تفعيل التكرار الذاتي",
"settingSelfIteration.title": "التكرار الذاتي",
"settingSystem.oauth.info.desc": "تم تسجيل الدخول",
"settingSystem.oauth.info.title": "معلومات الحساب",
"settingSystem.oauth.signin.action": "تسجيل الدخول",
@@ -897,7 +910,12 @@
"tab.uploadZip": "رفع ملف مضغوط",
"tab.uploadZip.desc": "رفع ملف .zip أو .skill محلي",
"tab.usage": "إحصائيات الاستخدام",
"tools.activation.auto": "تلقائي",
"tools.activation.auto.desc": "ذكي",
"tools.activation.pinned": "مثبت",
"tools.activation.pinned.desc": "دائمًا قيد التشغيل",
"tools.add": "إضافة مهارة",
"tools.builtins.configure": "تهيئة",
"tools.builtins.find-skills.description": "يساعد المستخدمين في اكتشاف وتثبيت مهارات الوكلاء عند سؤالهم \"كيف أفعل كذا\" أو \"اعثر على مهارة لكذا\" أو عند رغبتهم في توسيع القدرات",
"tools.builtins.find-skills.title": "العثور على المهارات",
"tools.builtins.groupName": "المهارات المدمجة",
@@ -932,9 +950,6 @@
"tools.builtins.lobe-group-agent-builder.title": "منشئ وكيل المجموعة",
"tools.builtins.lobe-group-management.description": "تنظيم وإدارة المحادثات الجماعية لوكلاء متعددين",
"tools.builtins.lobe-group-management.title": "إدارة المجموعات",
"tools.builtins.lobe-gtd.description": "خطط للأهداف وتابع التقدم باستخدام منهجية GTD. أنشئ خططًا استراتيجية، وأدر قوائم المهام مع تتبع الحالة، ونفّذ مهام غير متزامنة طويلة الأمد.",
"tools.builtins.lobe-gtd.readme": "خطط لأهدافك وتابع تقدمك باستخدام منهجية GTD. أنشئ خططًا استراتيجية، وأدر قوائم المهام مع تتبع الحالة، ونفّذ المهام غير المتزامنة طويلة الأمد.",
"tools.builtins.lobe-gtd.title": "أدوات GTD",
"tools.builtins.lobe-knowledge-base.description": "البحث في المستندات المرفوعة والمعرفة المتخصصة عبر البحث الدلالي — للرجوع الدائم والقابل لإعادة الاستخدام",
"tools.builtins.lobe-knowledge-base.title": "قاعدة المعرفة",
"tools.builtins.lobe-local-system.description": "الوصول إلى نظام الملفات المحلي على سطح المكتب. قراءة، وكتابة، والبحث، وتنظيم الملفات. تنفيذ أوامر الصدفة مع دعم المهام الخلفية والبحث في المحتوى باستخدام تعبيرات regex.",
+4
View File
@@ -16,11 +16,15 @@
"table.columns.trigger.enums.api": "استدعاء API",
"table.columns.trigger.enums.bot": "رسالة بوت",
"table.columns.trigger.enums.chat": "رسالة دردشة",
"table.columns.trigger.enums.cli": "واجهة سطر الأوامر",
"table.columns.trigger.enums.cron": "مهمة مجدولة",
"table.columns.trigger.enums.eval": "تقييم الأداء",
"table.columns.trigger.enums.file_embedding": "تضمين ملف",
"table.columns.trigger.enums.image": "توليد الصور",
"table.columns.trigger.enums.memory": "استخراج الذاكرة",
"table.columns.trigger.enums.notify": "إشعار",
"table.columns.trigger.enums.onboarding": "التسجيل",
"table.columns.trigger.enums.openapi": "واجهة برمجة التطبيقات المفتوحة",
"table.columns.trigger.enums.semantic_search": "بحث المعرفة",
"table.columns.trigger.enums.topic": "ملخص الموضوع",
"table.columns.trigger.enums.video": "توليد الفيديو",
+11
View File
@@ -21,6 +21,9 @@
"channel.botTokenPlaceholderNew": "Поставете вашия токен на бота тук",
"channel.charLimit": "Ограничение на символите",
"channel.charLimitHint": "Максимален брой символи на съобщение",
"channel.comingSoon": "Очаквайте скоро",
"channel.comingSoonDesc": "Работим върху интеграцията на това в LobeHub. Следете за актуализации.",
"channel.comingSoonTitle": "Интеграцията на {{name}} идва скоро",
"channel.concurrency": "Режим на едновременност",
"channel.concurrencyDebounce": "Забавяне",
"channel.concurrencyDebounceHint": "Обработва само последното съобщение от серия (по-ранните се игнорират)",
@@ -183,6 +186,14 @@
"channel.verificationToken": "Токен за проверка",
"channel.verificationTokenHint": "По избор. Използва се за проверка на източника на събития за уебхук.",
"channel.verificationTokenPlaceholder": "Поставете вашия токен за проверка тук",
"channel.watchKeywordInstructionLabel": "Инструкция",
"channel.watchKeywordInstructionPlaceholder": "напр. Сканирай последната тема и отговори, ако има доклад за грешка, който изисква действие",
"channel.watchKeywordLabel": "Ключова дума",
"channel.watchKeywordPlaceholder": "напр. грешка",
"channel.watchKeywords": "Наблюдавани ключови думи",
"channel.watchKeywordsAdd": "Добави ключова дума",
"channel.watchKeywordsEmpty": "Все още няма добавени ключови думи — ботът се активира само при @споменаване или директно съобщение в абонираните канали.",
"channel.watchKeywordsHint": "Когато съобщение в абониран канал съвпадне с ключова дума, ботът се активира без @споменаване и инструкцията се добавя към съобщението на потребителя преди да бъде изпратено към AI. Без значение от главни/малки букви, съвпадение на цяла дума.",
"channel.wechat.description": "Свържете този асистент с WeChat чрез iLink Bot за лични и групови чатове.",
"channel.wechatBotId": "ID на бота",
"channel.wechatBotIdHint": "Идентификатор на бота, присвоен след оторизация чрез QR код.",
+45
View File
@@ -24,6 +24,7 @@
"agentProfile.knowledgeBases_other": "{{count}} бази знания",
"agentProfile.skills_one": "{{count}} умение",
"agentProfile.skills_other": "{{count}} умения",
"agentSignal.receipts.agentSignalLabel": "Сигнал на агент",
"agentSignal.receipts.memory.detail": "Запазено за бъдещи отговори",
"agentSignal.receipts.memory.title": "Паметта е запазена",
"agentSignal.receipts.recentActivity": "Скорошна активност",
@@ -41,6 +42,16 @@
"builtinCopilot": "Вграден Копилот",
"chatList.expandMessage": "Разгъни съобщението",
"chatList.longMessageDetail": "Прегледай подробности",
"chatMode.agent": "Агент",
"chatMode.agentCap.env": "Работна среда",
"chatMode.agentCap.files": "Достъп до файлове",
"chatMode.agentCap.memory": "Памет",
"chatMode.agentCap.tools": "Използване на инструменти",
"chatMode.agentCap.web": "Уеб търсене",
"chatMode.agentDesc": "Агентът може да използва инструменти и среда за автоматично изпълнение на задачи",
"chatMode.chat": "Чат",
"chatMode.chatDesc": "Без работна среда или автономност; използва по-малко токени",
"chatMode.select": "Смяна на режим",
"claudeCodeInstallGuide.actions.openDocs": "Отвори ръководството за инсталиране",
"claudeCodeInstallGuide.actions.openSystemTools": "Отвори системните инструменти",
"claudeCodeInstallGuide.afterInstall": "След инсталиране стартирайте Claude Code веднъж, за да влезете, след което опитайте отново или натиснете „Повторно откриване“ в Системни инструменти.",
@@ -59,6 +70,7 @@
"cliAuthGuide.runCommand": "Изпълнете това в терминала",
"cliAuthGuide.title": "Влезте в {{name}}",
"cliRateLimitGuide.actions.openSystemTools": "Отвори системните инструменти",
"cliRateLimitGuide.actions.retry": "Опитай отново",
"cliRateLimitGuide.afterReset": "Изчакайте до времето за нулиране, след което опитайте отново. Ако използвате API удостоверяване, проверете квотата и фактурирането при вашия доставчик.",
"cliRateLimitGuide.desc": "{{name}} достигна текущия си лимит на употреба и не може да продължи в момента.",
"cliRateLimitGuide.limitType": "Период на лимит",
@@ -223,6 +235,8 @@
"knowledgeBase.allFiles": "Всички файлове",
"knowledgeBase.allLibraries": "Всички библиотеки",
"knowledgeBase.disabled": "Чатът с библиотеката не е наличен в тази инсталация. Превключете към сървърна база данни или използвайте {{cloud}}.",
"knowledgeBase.files": "Файлове",
"knowledgeBase.libraries": "Библиотеки",
"knowledgeBase.library.action.add": "Добави",
"knowledgeBase.library.action.detail": "Детайли",
"knowledgeBase.library.action.remove": "Премахни",
@@ -326,6 +340,15 @@
"pageSelection.reference": "Избран текст",
"pin": "Закачи",
"pinOff": "Откачи",
"plus.addSkills": "Добавяне на умения...",
"plus.search.appSearch": "Интелигентно търсене",
"plus.search.appSearchDesc": "Оптимизирана търсачка на LobeHub, предоставяща най-добри резултати от търсенето.",
"plus.search.modelSearch": "Търсене по доставчик",
"plus.search.modelSearchDesc": "Може да предизвика неочаквано поведение при активиране, не се препоръчва.",
"plus.search.off": "Изключено",
"plus.search.offDesc": "",
"plus.title": "Добавяне",
"plus.tooltip": "Добавяне на файлове, умения и повече контекст...",
"rag.referenceChunks": "Източник на препратки",
"rag.userQuery.actions.delete": "Изтрий пренаписаното запитване",
"rag.userQuery.actions.regenerate": "Генерирай запитване отново",
@@ -357,6 +380,8 @@
"searchAgents": "Търсене на агенти...",
"selectedAgents": "Избрани агенти",
"sendPlaceholder": "Попитай, създай или започни задача, <hotkey><hotkey/>",
"sendPlaceholderChat": "Попитай, търси или генерирай идеи, <hotkey><hotkey/>",
"sendPlaceholderChatWithAgentAssignment": "Попитай, търси или генерирай идеи. @ за включване на други агенти.",
"sendPlaceholderHeterogeneous": "Помолете {{name}} да изпълни задача...",
"sendPlaceholderWithAgentAssignment": "Питайте, създайте или започнете задача. Използвайте @, за да възлагате задачи на други агенти.",
"sessionGroup.config": "Управление на групата",
@@ -734,6 +759,7 @@
"untitledAgent": "Агент без име",
"untitledGroup": "Група без име",
"updateAgent": "Актуализирай информацията за агента",
"upload.action.fileOrImageUpload": "Качване на файл или изображение",
"upload.action.fileUpload": "Качи файл",
"upload.action.folderUpload": "Качи папка",
"upload.action.imageDisabled": "Текущият модел не поддържа визуално разпознаване. Моля, сменете модела, за да използвате тази функция.",
@@ -846,6 +872,23 @@
"workingPanel.documents.saved": "All changes saved",
"workingPanel.documents.title": "Document",
"workingPanel.documents.unsaved": "Unsaved changes",
"workingPanel.files.copyAbsolutePath": "Копирай пътя",
"workingPanel.files.copyRelativePath": "Копирай относителния път",
"workingPanel.files.count_one": "{{count}} файл",
"workingPanel.files.count_other": "{{count}} файла",
"workingPanel.files.empty": "Няма файлове в това работно пространство",
"workingPanel.files.open": "Отвори файл",
"workingPanel.files.refresh": "Обнови",
"workingPanel.files.showInReview": "Покажи в преглед",
"workingPanel.files.showInSystem": "Покажи в папка",
"workingPanel.files.title": "Файлове",
"workingPanel.localFile.binary": "Бинарен файл — прегледът не е наличен",
"workingPanel.localFile.close": "Затвори",
"workingPanel.localFile.closeLeft": "Затвори наляво",
"workingPanel.localFile.closeOther": "Затвори другите",
"workingPanel.localFile.closeRight": "Затвори надясно",
"workingPanel.localFile.error": "Не може да се зареди този файл",
"workingPanel.localFile.truncated": "Прегледът на файла е съкратен до {{limit}} символа",
"workingPanel.progress": "Progress",
"workingPanel.progress.allCompleted": "All tasks completed",
"workingPanel.resources": "Resources",
@@ -892,6 +935,8 @@
"workingPanel.review.mode.unstaged": "Неинсценирано",
"workingPanel.review.more": "Още опции",
"workingPanel.review.refresh": "Обнови",
"workingPanel.review.revealInTree": "Покажи в дървото",
"workingPanel.review.revealNotFound": "Файлът не е намерен в индекса на проекта",
"workingPanel.review.revert": "Отхвърли промените",
"workingPanel.review.revert.confirm.cancel": "Отказ",
"workingPanel.review.revert.confirm.description": "Промените в работното дърво за {{filePath}} ще бъдат изтрити окончателно. Неследените файлове ще бъдат изтрити от диска.",
+1
View File
@@ -8,6 +8,7 @@
"brief.action.confirm": "Потвърди",
"brief.action.confirmDone": "Потвърди",
"brief.action.feedback": "Обратна връзка",
"brief.action.ignore": "Игнорирай",
"brief.action.retry": "Опит отново",
"brief.addFeedback": "Споделяне на обратна връзка",
"brief.collapse": "Покажи по-малко",
+16 -3
View File
@@ -20,6 +20,22 @@
"messenger.discord.connections.disconnectFailed": "Неуспешно премахване на сървъра.",
"messenger.discord.connections.disconnectSuccess": "Сървърът е премахнат.",
"messenger.discord.connections.disconnectTitle": "Премахване на сървър",
"messenger.discord.installBlocked.dismiss": "Разбрах",
"messenger.discord.installBlocked.suggestion": "Изпратете лично съобщение на бота LobeHub в Discord, за да свържете личния си акаунт — не е необходимо да добавяте бота отново. Или помолете първоначалния инсталатор да премахне този сървър в LobeHub Настройки → Messenger, преди да го добавите отново.",
"messenger.discord.installBlocked.title": "Сървърът вече е свързан",
"messenger.discord.installBlocked.withName": "\"{{workspace}}\" вече е свързан с LobeHub от друг потребител.",
"messenger.discord.installBlocked.withoutName": "Този Discord сървър вече е свързан с LobeHub от друг потребител.",
"messenger.discord.installResult.failed": "Инсталирането на Discord не бе успешно ({{reason}}). Моля, опитайте отново или се свържете с поддръжката.",
"messenger.discord.installResult.reasons.accessDenied": "авторизацията беше отменена",
"messenger.discord.installResult.reasons.exchangeFailed": "авторизацията в Discord не бе успешна",
"messenger.discord.installResult.reasons.generic": "възникна неизвестна грешка",
"messenger.discord.installResult.reasons.invalidState": "сесията за инсталиране изтече",
"messenger.discord.installResult.reasons.missingAppId": "Discord върна непълна информация за приложението",
"messenger.discord.installResult.reasons.missingCodeOrState": "Discord върна непълни параметри за инсталиране",
"messenger.discord.installResult.reasons.missingTenant": "Discord не върна идентификатор на сървъра",
"messenger.discord.installResult.reasons.missingToken": "Discord не върна токен за достъп",
"messenger.discord.installResult.reasons.persistFailed": "връзката със сървъра не можа да бъде запазена",
"messenger.discord.installResult.success": "Discord сървърът е свързан.",
"messenger.discord.userPending.cta": "Отворете в Discord",
"messenger.discord.userPending.hint": "Отворете бота в Discord и изпратете съобщение, за да завършите свързването на акаунта си.",
"messenger.discord.userPending.name": "Все още не е свързан",
@@ -96,9 +112,6 @@
"verify.error.missingToken": "Невалидна връзка. Отворете тази страница от бота.",
"verify.error.title": "Неуспешно потвърждаване на връзката",
"verify.error.unlinkBeforeRelink": "Този LobeHub акаунт вече е свързан с друг Telegram акаунт. Прекъснете връзката в Настройки → Messenger, преди да свържете нов.",
"verify.labRequired.description": "Messenger в момента е функция в Labs. Активирайте я в Настройки → Разширени → Labs и презаредете тази страница.",
"verify.labRequired.openSettings": "Отворете настройките на Labs",
"verify.labRequired.title": "Активирайте Messenger, за да продължите",
"verify.signInCta": "Влезте, за да продължите",
"verify.signInRequired": "Моля, влезте в LobeHub, за да потвърдите връзката.",
"verify.success.description": "Вашият акаунт вече е свързан с {{platform}}. Отворете {{platform}} и изпратете първото си съобщение.",
+8
View File
@@ -0,0 +1,8 @@
{
"dropdownLabel": "Отвори работната директория в",
"errors.appNotInstalled": "{{appName}} не е инсталиран",
"errors.launchFailed": "Неуспешно отваряне в {{appName}}: {{error}}",
"errors.pathNotFound": "Пътят не е намерен: {{path}}",
"errors.unknown": "неизвестна грешка",
"tooltip": "Отвори в {{appName}}"
}
+3
View File
@@ -69,6 +69,9 @@
"builtins.lobe-agent-management.render.installPlugin.plugin": "Плъгин",
"builtins.lobe-agent-management.render.installPlugin.success": "Успешно инсталиран",
"builtins.lobe-agent-management.title": "Мениджър на агенти",
"builtins.lobe-agent.apiName.analyzeVisualMedia": "Анализиране на визуални медии",
"builtins.lobe-agent.apiName.analyzeVisualMedia.mediaCount": "{{count}} медии",
"builtins.lobe-agent.apiName.analyzeVisualMedia.result": "Анализиране на визуални медии: <question>{{question}}</question>",
"builtins.lobe-agent.apiName.callSubAgent": "Извикай под-агент",
"builtins.lobe-agent.apiName.callSubAgent.completed": "Под-агент изпратен: ",
"builtins.lobe-agent.apiName.callSubAgent.loading": "Изпращане на под-агент: ",
+21 -6
View File
@@ -187,6 +187,7 @@
"agentTab.opening": "Начални настройки",
"agentTab.plugin": "Настройки на уменията",
"agentTab.prompt": "Профил на агента",
"agentTab.selfIteration": "Само-итерация",
"agentTab.tts": "Гласова услуга",
"analytics.telemetry.desc": "Помогнете ни да подобрим {{appName}} с анонимни данни за използване",
"analytics.telemetry.title": "Изпращане на анонимни данни за използване",
@@ -554,9 +555,6 @@
"settingChat.inputTemplate.desc": "Последното съобщение на потребителя ще бъде вмъкнато в този шаблон",
"settingChat.inputTemplate.placeholder": "Шаблон за предварителна обработка {{text}} ще бъде заменен с реално въведена информация",
"settingChat.inputTemplate.title": "Предварителна обработка на входа",
"settingChat.selfIteration.enabled.desc": "Allow this assistant to review recent signals and improve its own skills when the lab workflow runs",
"settingChat.selfIteration.enabled.title": "Enable Self-Iteration",
"settingChat.selfIteration.title": "Advanced Labs",
"settingChat.submit": "Актуализирай предпочитанията за чат",
"settingChat.title": "Настройки на чата",
"settingChatAppearance.autoScrollOnStreaming.desc": "Автоматично превъртане до дъното, когато ИИ генерира отговор",
@@ -659,6 +657,17 @@
"settingModel.maxTokens.title": "Лимит на токени",
"settingModel.model.desc": "Модел на {{provider}}",
"settingModel.model.title": "Модел",
"settingModel.params.panel.advanced": "Разширени настройки",
"settingModel.params.panel.agentTitle": "Разширени настройки на агент",
"settingModel.params.panel.contextCompression": "Автоматично компресиране на контекста",
"settingModel.params.panel.creativity": "Креативност",
"settingModel.params.panel.historyLimit": "Ограничаване на съобщенията в историята",
"settingModel.params.panel.openness": "Отвореност",
"settingModel.params.panel.responseLength": "Ограничаване на дължината на отговора",
"settingModel.params.panel.tab": "Параметри",
"settingModel.params.panel.title": "Настройки на параметрите на чата",
"settingModel.params.panel.topicDivergence": "Отклонение на темата",
"settingModel.params.panel.vocabularyRichness": "Богатство на речника",
"settingModel.params.title": "Разширени параметри",
"settingModel.presencePenalty.desc": "Колкото по-висока е стойността, толкова по-склонен е моделът да използва различни изрази и да избягва повторения; по-ниска стойност води до по-последователно, но повтарящо се изразяване.",
"settingModel.presencePenalty.title": "Разнообразие на изразяване",
@@ -684,6 +693,10 @@
"settingOpening.openingQuestions.title": "Начални въпроси",
"settingOpening.title": "Настройки за начало",
"settingPlugin.title": "Списък с умения",
"settingSelfIteration.enabled.desc": "Позволете на този асистент да преглежда последните сигнали и да подобрява собствените си умения, когато работният процес за само-итерация се изпълнява.",
"settingSelfIteration.enabled.managedDesc": "Винаги включено за Lobe AI, докато само-итерацията е налична.",
"settingSelfIteration.enabled.title": "Разрешаване на само-итерация",
"settingSelfIteration.title": "Само-итерация",
"settingSystem.oauth.info.desc": "Вход изпълнен",
"settingSystem.oauth.info.title": "Информация за акаунта",
"settingSystem.oauth.signin.action": "Вход",
@@ -897,7 +910,12 @@
"tab.uploadZip": "Качване на Zip",
"tab.uploadZip.desc": "Качване на локален .zip или .skill файл",
"tab.usage": "Статистика на използване",
"tools.activation.auto": "Автоматично",
"tools.activation.auto.desc": "Интелигентно",
"tools.activation.pinned": "Закрепено",
"tools.activation.pinned.desc": "Винаги включено",
"tools.add": "Добави умение",
"tools.builtins.configure": "Конфигуриране",
"tools.builtins.find-skills.description": "Помага на потребителите да откриват и инсталират умения за агенти, когато питат „как да направя X“, „намери умение за X“ или когато искат да разширят възможностите",
"tools.builtins.find-skills.title": "Намиране на умения",
"tools.builtins.groupName": "Вградени",
@@ -932,9 +950,6 @@
"tools.builtins.lobe-group-agent-builder.title": "Създател на групови агенти",
"tools.builtins.lobe-group-management.description": "Оркестрирайте и управлявайте разговори в групи от агенти",
"tools.builtins.lobe-group-management.title": "Групово управление",
"tools.builtins.lobe-gtd.description": "Планирайте цели и следете напредъка с помощта на методологията GTD. Създавайте стратегически планове, управлявайте списъци със задачи със следене на статус и изпълнявайте дълготрайни асинхронни задачи.",
"tools.builtins.lobe-gtd.readme": "Планирайте цели и следете напредъка си с помощта на методологията GTD. Създавайте стратегически планове, управлявайте списъци със задачи със следене на статус и изпълнявайте дълготрайни асинхронни задачи.",
"tools.builtins.lobe-gtd.title": "GTD Инструменти",
"tools.builtins.lobe-knowledge-base.description": "Търсене в качени документи и специализирани знания чрез семантично векторно търсене — за постоянно и многократно използване",
"tools.builtins.lobe-knowledge-base.title": "База знания",
"tools.builtins.lobe-local-system.description": "Достъп до локалната файлова система на настолния компютър. Четете, записвайте, търсете и организирайте файлове. Изпълнявайте shell команди с поддръжка на фонови задачи и търсете съдържание с regex шаблони.",
+4
View File
@@ -16,11 +16,15 @@
"table.columns.trigger.enums.api": "API Обаждане",
"table.columns.trigger.enums.bot": "Съобщение от бот",
"table.columns.trigger.enums.chat": "Съобщение в чат",
"table.columns.trigger.enums.cli": "CLI",
"table.columns.trigger.enums.cron": "Планирана задача",
"table.columns.trigger.enums.eval": "Оценка на производителност",
"table.columns.trigger.enums.file_embedding": "Вграждане на файл",
"table.columns.trigger.enums.image": "Генериране на изображения",
"table.columns.trigger.enums.memory": "Извличане на памет",
"table.columns.trigger.enums.notify": "Известие",
"table.columns.trigger.enums.onboarding": "Въвеждане",
"table.columns.trigger.enums.openapi": "OpenAPI",
"table.columns.trigger.enums.semantic_search": "Търсене на знания",
"table.columns.trigger.enums.topic": "Резюме на тема",
"table.columns.trigger.enums.video": "Генериране на видеа",
+11
View File
@@ -21,6 +21,9 @@
"channel.botTokenPlaceholderNew": "Fügen Sie hier Ihr Bot-Token ein",
"channel.charLimit": "Zeichenlimit",
"channel.charLimitHint": "Maximale Anzahl von Zeichen pro Nachricht",
"channel.comingSoon": "Demnächst verfügbar",
"channel.comingSoonDesc": "Wir arbeiten daran, diese Integration in LobeHub zu bringen. Bleiben Sie dran für Updates.",
"channel.comingSoonTitle": "{{name}}-Integration kommt bald",
"channel.concurrency": "Konkurrenzmodus",
"channel.concurrencyDebounce": "Entprellen",
"channel.concurrencyDebounceHint": "Nur die letzte Nachricht in einer Serie verarbeiten (frühere werden verworfen)",
@@ -183,6 +186,14 @@
"channel.verificationToken": "Verifizierungstoken",
"channel.verificationTokenHint": "Optional. Wird verwendet, um die Quelle von Webhook-Ereignissen zu überprüfen.",
"channel.verificationTokenPlaceholder": "Fügen Sie hier Ihr Verifizierungstoken ein",
"channel.watchKeywordInstructionLabel": "Anweisung",
"channel.watchKeywordInstructionPlaceholder": "z. B. Scannen Sie den aktuellen Thread und antworten Sie, wenn ein umsetzbarer Fehlerbericht vorliegt",
"channel.watchKeywordLabel": "Schlüsselwort",
"channel.watchKeywordPlaceholder": "z. B. Fehler",
"channel.watchKeywords": "Schlüsselwörter überwachen",
"channel.watchKeywordsAdd": "Schlüsselwort hinzufügen",
"channel.watchKeywordsEmpty": "Noch keine Schlüsselwörter hinzugefügt — der Bot reagiert nur auf @Erwähnungen oder Direktnachrichten in abonnierten Kanälen.",
"channel.watchKeywordsHint": "Wenn eine Nachricht in einem abonnierten Kanal mit einem Schlüsselwort übereinstimmt, reagiert der Bot ohne @Erwähnung und die Anweisung wird der Benutzernachricht vorangestellt, bevor sie an die KI gesendet wird. Groß-/Kleinschreibung wird ignoriert, Übereinstimmung ganzer Wörter.",
"channel.wechat.description": "Verbinden Sie diesen Assistenten mit WeChat über iLink Bot für private und Gruppenchats.",
"channel.wechatBotId": "Bot-ID",
"channel.wechatBotIdHint": "Bot-Kennung, die nach der QR-Code-Autorisierung zugewiesen wurde.",
+45
View File
@@ -24,6 +24,7 @@
"agentProfile.knowledgeBases_other": "{{count}} Wissensbasen",
"agentProfile.skills_one": "{{count}} Fähigkeit",
"agentProfile.skills_other": "{{count}} Fähigkeiten",
"agentSignal.receipts.agentSignalLabel": "Agentensignal",
"agentSignal.receipts.memory.detail": "Für zukünftige Antworten gespeichert",
"agentSignal.receipts.memory.title": "Erinnerung gespeichert",
"agentSignal.receipts.recentActivity": "Letzte Aktivität",
@@ -41,6 +42,16 @@
"builtinCopilot": "Integrierter Copilot",
"chatList.expandMessage": "Nachricht erweitern",
"chatList.longMessageDetail": "Details anzeigen",
"chatMode.agent": "Agent",
"chatMode.agentCap.env": "Laufzeitumgebung",
"chatMode.agentCap.files": "Dateizugriff",
"chatMode.agentCap.memory": "Speicher",
"chatMode.agentCap.tools": "Werkzeugaufrufe",
"chatMode.agentCap.web": "Websuche",
"chatMode.agentDesc": "Agent kann Werkzeuge und Umgebung nutzen, um Aufgaben automatisch zu erledigen",
"chatMode.chat": "Chat",
"chatMode.chatDesc": "Keine Laufzeitumgebung oder Autonomie; verwendet weniger Tokens",
"chatMode.select": "Modus wechseln",
"claudeCodeInstallGuide.actions.openDocs": "Installationsanleitung öffnen",
"claudeCodeInstallGuide.actions.openSystemTools": "Systemwerkzeuge öffnen",
"claudeCodeInstallGuide.afterInstall": "Führen Sie nach der Installation Claude Code einmal aus, um sich anzumelden. Versuchen Sie danach erneut Ihre Nachricht zu senden oder klicken Sie in den Systemwerkzeugen auf „Erneut erkennen“.",
@@ -59,6 +70,7 @@
"cliAuthGuide.runCommand": "Führen Sie dies im Terminal aus",
"cliAuthGuide.title": "Bei {{name}} anmelden",
"cliRateLimitGuide.actions.openSystemTools": "Systemwerkzeuge öffnen",
"cliRateLimitGuide.actions.retry": "Erneut versuchen",
"cliRateLimitGuide.afterReset": "Warten Sie bis zum Reset-Zeitpunkt und versuchen Sie dann erneut, Ihre Nachricht zu senden. Wenn Sie eine API-Autorisierung verwenden, können Sie außerdem Ihr Kontingent und Ihren Abrechnungsstatus prüfen.",
"cliRateLimitGuide.desc": "{{name}} hat das aktuelle Nutzungslimit erreicht und kann diesen Vorgang momentan nicht fortsetzen.",
"cliRateLimitGuide.limitType": "Limit-Zeitraum",
@@ -223,6 +235,8 @@
"knowledgeBase.allFiles": "Alle Dateien",
"knowledgeBase.allLibraries": "Alle Bibliotheken",
"knowledgeBase.disabled": "Bibliotheks-Chat ist in dieser Bereitstellung nicht verfügbar. Wechseln Sie zu einer serverseitigen Datenbank oder verwenden Sie {{cloud}}.",
"knowledgeBase.files": "Dateien",
"knowledgeBase.libraries": "Bibliotheken",
"knowledgeBase.library.action.add": "Hinzufügen",
"knowledgeBase.library.action.detail": "Details",
"knowledgeBase.library.action.remove": "Entfernen",
@@ -326,6 +340,15 @@
"pageSelection.reference": "Ausgewählter Text",
"pin": "Anheften",
"pinOff": "Lösen",
"plus.addSkills": "Fähigkeiten hinzufügen...",
"plus.search.appSearch": "Intelligente Suche",
"plus.search.appSearchDesc": "LobeHub-optimierter Suchdienst, der die besten Suchergebnisse liefert.",
"plus.search.modelSearch": "Anbietersuche",
"plus.search.modelSearchDesc": "Kann unerwartetes Verhalten verursachen, nicht empfohlen.",
"plus.search.off": "Aus",
"plus.search.offDesc": "",
"plus.title": "Hinzufügen",
"plus.tooltip": "Dateien, Fähigkeiten und mehr Kontext hinzufügen...",
"rag.referenceChunks": "Referenzquelle",
"rag.userQuery.actions.delete": "Abfrage-Neuschreibung löschen",
"rag.userQuery.actions.regenerate": "Abfrage neu generieren",
@@ -357,6 +380,8 @@
"searchAgents": "Agenten suchen...",
"selectedAgents": "Ausgewählte Agenten",
"sendPlaceholder": "Fragen, erstellen oder Aufgabe starten, <hotkey><hotkey/>",
"sendPlaceholderChat": "Fragen, suchen oder brainstormen, <hotkey><hotkey/>",
"sendPlaceholderChatWithAgentAssignment": "Fragen, suchen oder brainstormen. @, um andere Agenten hinzuzuziehen.",
"sendPlaceholderHeterogeneous": "Bitte {{name}} eine Aufgabe erledigen...",
"sendPlaceholderWithAgentAssignment": "Fragen, erstellen oder eine Aufgabe starten. @, um Aufgaben anderen Agenten zuzuweisen.",
"sessionGroup.config": "Gruppenverwaltung",
@@ -734,6 +759,7 @@
"untitledAgent": "Unbenannter Agent",
"untitledGroup": "Unbenannte Gruppe",
"updateAgent": "Agenteninformationen aktualisieren",
"upload.action.fileOrImageUpload": "Datei oder Bild hochladen",
"upload.action.fileUpload": "Datei hochladen",
"upload.action.folderUpload": "Ordner hochladen",
"upload.action.imageDisabled": "Das aktuelle Modell unterstützt keine visuelle Erkennung. Bitte wechsle das Modell, um diese Funktion zu nutzen.",
@@ -846,6 +872,23 @@
"workingPanel.documents.saved": "All changes saved",
"workingPanel.documents.title": "Document",
"workingPanel.documents.unsaved": "Unsaved changes",
"workingPanel.files.copyAbsolutePath": "Pfad kopieren",
"workingPanel.files.copyRelativePath": "Relativen Pfad kopieren",
"workingPanel.files.count_one": "{{count}} Datei",
"workingPanel.files.count_other": "{{count}} Dateien",
"workingPanel.files.empty": "Keine Dateien in diesem Arbeitsbereich",
"workingPanel.files.open": "Datei öffnen",
"workingPanel.files.refresh": "Aktualisieren",
"workingPanel.files.showInReview": "Im Review anzeigen",
"workingPanel.files.showInSystem": "Im Ordner anzeigen",
"workingPanel.files.title": "Dateien",
"workingPanel.localFile.binary": "Binärdatei — Vorschau nicht verfügbar",
"workingPanel.localFile.close": "Schließen",
"workingPanel.localFile.closeLeft": "Links schließen",
"workingPanel.localFile.closeOther": "Andere schließen",
"workingPanel.localFile.closeRight": "Rechts schließen",
"workingPanel.localFile.error": "Diese Datei konnte nicht geladen werden",
"workingPanel.localFile.truncated": "Dateivorschau auf {{limit}} Zeichen gekürzt",
"workingPanel.progress": "Progress",
"workingPanel.progress.allCompleted": "All tasks completed",
"workingPanel.resources": "Resources",
@@ -892,6 +935,8 @@
"workingPanel.review.mode.unstaged": "Nicht gestaged",
"workingPanel.review.more": "Weitere Optionen",
"workingPanel.review.refresh": "Aktualisieren",
"workingPanel.review.revealInTree": "Im Baum anzeigen",
"workingPanel.review.revealNotFound": "Datei im Projektindex nicht gefunden",
"workingPanel.review.revert": "Änderungen verwerfen",
"workingPanel.review.revert.confirm.cancel": "Abbrechen",
"workingPanel.review.revert.confirm.description": "Die Änderungen in der Arbeitskopie an {{filePath}} werden dauerhaft verworfen. Nicht verfolgte Dateien werden von der Festplatte gelöscht.",
+1
View File
@@ -8,6 +8,7 @@
"brief.action.confirm": "Bestätigen",
"brief.action.confirmDone": "Bestätigen",
"brief.action.feedback": "Feedback",
"brief.action.ignore": "Ignorieren",
"brief.action.retry": "Erneut versuchen",
"brief.addFeedback": "Feedback teilen",
"brief.collapse": "Weniger anzeigen",
+16 -3
View File
@@ -20,6 +20,22 @@
"messenger.discord.connections.disconnectFailed": "Server konnte nicht entfernt werden.",
"messenger.discord.connections.disconnectSuccess": "Server entfernt.",
"messenger.discord.connections.disconnectTitle": "Server entfernen",
"messenger.discord.installBlocked.dismiss": "Verstanden",
"messenger.discord.installBlocked.suggestion": "Senden Sie dem LobeHub-Bot in Discord eine Direktnachricht, um Ihr persönliches Konto zu verknüpfen Sie müssen den Bot nicht erneut hinzufügen. Oder bitten Sie den ursprünglichen Installateur, diesen Server in den LobeHub-Einstellungen → Messenger zu entfernen, bevor Sie ihn erneut hinzufügen.",
"messenger.discord.installBlocked.title": "Server bereits verbunden",
"messenger.discord.installBlocked.withName": "\"{{workspace}}\" ist bereits von einem anderen Benutzer mit LobeHub verbunden.",
"messenger.discord.installBlocked.withoutName": "Dieser Discord-Server ist bereits von einem anderen Benutzer mit LobeHub verbunden.",
"messenger.discord.installResult.failed": "Discord-Installation fehlgeschlagen ({{reason}}). Bitte versuchen Sie es erneut oder kontaktieren Sie den Support.",
"messenger.discord.installResult.reasons.accessDenied": "Die Autorisierung wurde abgebrochen",
"messenger.discord.installResult.reasons.exchangeFailed": "Discord-Autorisierung fehlgeschlagen",
"messenger.discord.installResult.reasons.generic": "Ein unbekannter Fehler ist aufgetreten",
"messenger.discord.installResult.reasons.invalidState": "Die Installationssitzung ist abgelaufen",
"messenger.discord.installResult.reasons.missingAppId": "Discord hat unvollständige App-Informationen zurückgegeben",
"messenger.discord.installResult.reasons.missingCodeOrState": "Discord hat unvollständige Installationsparameter zurückgegeben",
"messenger.discord.installResult.reasons.missingTenant": "Discord hat keine Serverkennung zurückgegeben",
"messenger.discord.installResult.reasons.missingToken": "Discord hat kein Zugriffstoken zurückgegeben",
"messenger.discord.installResult.reasons.persistFailed": "Die Serververbindung konnte nicht gespeichert werden",
"messenger.discord.installResult.success": "Discord-Server verbunden.",
"messenger.discord.userPending.cta": "In Discord öffnen",
"messenger.discord.userPending.hint": "Öffnen Sie den Bot in Discord und senden Sie eine Nachricht, um die Verknüpfung Ihres Kontos abzuschließen.",
"messenger.discord.userPending.name": "Noch nicht verknüpft",
@@ -96,9 +112,6 @@
"verify.error.missingToken": "Ungültiger Link. Öffnen Sie diese Seite über den Bot.",
"verify.error.title": "Verknüpfung konnte nicht bestätigt werden",
"verify.error.unlinkBeforeRelink": "Dieses LobeHub-Konto ist bereits mit einem anderen Telegram-Konto verknüpft. Trennen Sie es in Einstellungen → Messenger, bevor Sie ein neues verknüpfen.",
"verify.labRequired.description": "Messenger ist derzeit eine Labs-Funktion. Aktivieren Sie sie unter Einstellungen → Erweitert → Labs und laden Sie diese Seite neu.",
"verify.labRequired.openSettings": "Labs-Einstellungen öffnen",
"verify.labRequired.title": "Messenger aktivieren, um fortzufahren",
"verify.signInCta": "Anmelden, um fortzufahren",
"verify.signInRequired": "Bitte melden Sie sich bei LobeHub an, um die Verknüpfung zu bestätigen.",
"verify.success.description": "Ihr Konto ist jetzt mit {{platform}} verbunden. Öffnen Sie {{platform}} und senden Sie Ihre erste Nachricht.",
+8
View File
@@ -0,0 +1,8 @@
{
"dropdownLabel": "Arbeitsverzeichnis öffnen in",
"errors.appNotInstalled": "{{appName}} ist nicht installiert",
"errors.launchFailed": "Fehler beim Öffnen in {{appName}}: {{error}}",
"errors.pathNotFound": "Pfad nicht gefunden: {{path}}",
"errors.unknown": "Unbekannter Fehler",
"tooltip": "Öffnen in {{appName}}"
}
+3
View File
@@ -69,6 +69,9 @@
"builtins.lobe-agent-management.render.installPlugin.plugin": "Plugin",
"builtins.lobe-agent-management.render.installPlugin.success": "Erfolgreich installiert",
"builtins.lobe-agent-management.title": "Agenten-Manager",
"builtins.lobe-agent.apiName.analyzeVisualMedia": "Visuelle Medien analysieren",
"builtins.lobe-agent.apiName.analyzeVisualMedia.mediaCount": "{{count}} Medien",
"builtins.lobe-agent.apiName.analyzeVisualMedia.result": "Visuelle Medien analysieren: <question>{{question}}</question>",
"builtins.lobe-agent.apiName.callSubAgent": "Sub-Agent aufrufen",
"builtins.lobe-agent.apiName.callSubAgent.completed": "Sub-Agent entsendet: ",
"builtins.lobe-agent.apiName.callSubAgent.loading": "Sub-Agent wird entsendet: ",
+21 -6
View File
@@ -187,6 +187,7 @@
"agentTab.opening": "Startnachricht",
"agentTab.plugin": "Fähigkeitseinstellungen",
"agentTab.prompt": "Agentenprofil",
"agentTab.selfIteration": "Selbstiteration",
"agentTab.tts": "Sprachdienst",
"analytics.telemetry.desc": "Hilf uns, {{appName}} mit anonymen Nutzungsdaten zu verbessern",
"analytics.telemetry.title": "Anonyme Nutzungsdaten senden",
@@ -554,9 +555,6 @@
"settingChat.inputTemplate.desc": "Die neueste Nachricht des Benutzers wird in diese Vorlage eingefügt",
"settingChat.inputTemplate.placeholder": "Vorverarbeitungsvorlage {{text}} wird durch Echtzeiteingabe ersetzt",
"settingChat.inputTemplate.title": "Benutzereingabe-Vorverarbeitung",
"settingChat.selfIteration.enabled.desc": "Allow this assistant to review recent signals and improve its own skills when the lab workflow runs",
"settingChat.selfIteration.enabled.title": "Enable Self-Iteration",
"settingChat.selfIteration.title": "Advanced Labs",
"settingChat.submit": "Chat-Einstellungen aktualisieren",
"settingChat.title": "Chat-Einstellungen",
"settingChatAppearance.autoScrollOnStreaming.desc": "Automatisch nach unten scrollen, wenn die KI eine Antwort generiert",
@@ -659,6 +657,17 @@
"settingModel.maxTokens.title": "Maximale Tokens",
"settingModel.model.desc": "{{provider}} Modell",
"settingModel.model.title": "Modell",
"settingModel.params.panel.advanced": "Erweiterte Einstellungen",
"settingModel.params.panel.agentTitle": "Erweiterte Agenteneinstellungen",
"settingModel.params.panel.contextCompression": "Kontext automatisch komprimieren",
"settingModel.params.panel.creativity": "Kreativität",
"settingModel.params.panel.historyLimit": "Nachrichtenverlauf begrenzen",
"settingModel.params.panel.openness": "Offenheit",
"settingModel.params.panel.responseLength": "Antwortlänge begrenzen",
"settingModel.params.panel.tab": "Parameter",
"settingModel.params.panel.title": "Chat-Parameter-Einstellungen",
"settingModel.params.panel.topicDivergence": "Themenabweichung",
"settingModel.params.panel.vocabularyRichness": "Wortschatzreichtum",
"settingModel.params.title": "Erweiterte Parameter",
"settingModel.presencePenalty.desc": "Je höher der Wert, desto mehr unterschiedliche Ausdrücke; je niedriger, desto mehr Wiederholungen.",
"settingModel.presencePenalty.title": "Ausdrucksvielfalt",
@@ -684,6 +693,10 @@
"settingOpening.openingQuestions.title": "Einstiegsfragen",
"settingOpening.title": "Begrüßungseinstellungen",
"settingPlugin.title": "Fähigkeitenliste",
"settingSelfIteration.enabled.desc": "Erlauben Sie diesem Assistenten, kürzlich empfangene Signale zu überprüfen und seine eigenen Fähigkeiten zu verbessern, wenn der Selbstiterations-Workflow ausgeführt wird.",
"settingSelfIteration.enabled.managedDesc": "Immer aktiviert für Lobe AI, solange Selbstiteration verfügbar ist.",
"settingSelfIteration.enabled.title": "Selbstiteration aktivieren",
"settingSelfIteration.title": "Selbstiteration",
"settingSystem.oauth.info.desc": "Angemeldet",
"settingSystem.oauth.info.title": "Kontoinformationen",
"settingSystem.oauth.signin.action": "Anmelden",
@@ -897,7 +910,12 @@
"tab.uploadZip": "Zip hochladen",
"tab.uploadZip.desc": "Laden Sie eine lokale .zip- oder .skill-Datei hoch",
"tab.usage": "Nutzungsstatistik",
"tools.activation.auto": "Automatisch",
"tools.activation.auto.desc": "Intelligent",
"tools.activation.pinned": "Angeheftet",
"tools.activation.pinned.desc": "Immer an",
"tools.add": "Fähigkeit hinzufügen",
"tools.builtins.configure": "Konfigurieren",
"tools.builtins.find-skills.description": "Hilft Nutzern, AgentenFähigkeiten zu entdecken und zu installieren, wenn sie fragen „Wie mache ich X?“, „Finde eine Fähigkeit für X“ oder Funktionen erweitern möchten",
"tools.builtins.find-skills.title": "Fähigkeiten finden",
"tools.builtins.groupName": "Integriert",
@@ -932,9 +950,6 @@
"tools.builtins.lobe-group-agent-builder.title": "GruppenAgentBuilder",
"tools.builtins.lobe-group-management.description": "Unterhaltungen von MultiAgentenGruppen orchestrieren und verwalten",
"tools.builtins.lobe-group-management.title": "Gruppenverwaltung",
"tools.builtins.lobe-gtd.description": "Ziele planen und Fortschritte mit der GTD-Methode verfolgen. Strategische Pläne erstellen, Aufgabenlisten mit Statusverfolgung verwalten und lang laufende asynchrone Aufgaben ausführen.",
"tools.builtins.lobe-gtd.readme": "Planen Sie Ziele und verfolgen Sie Fortschritte mit der GTD-Methodik. Erstellen Sie strategische Pläne, verwalten Sie Aufgabenlisten mit Statusverfolgung und führen Sie lang laufende asynchrone Aufgaben aus.",
"tools.builtins.lobe-gtd.title": "GTD-Werkzeuge",
"tools.builtins.lobe-knowledge-base.description": "Hochgeladene Dokumente und Domainwissen per semantischer Vektorsuche durchsuchen für persistente, wiederverwendbare Referenzen",
"tools.builtins.lobe-knowledge-base.title": "Wissensdatenbank",
"tools.builtins.lobe-local-system.description": "Zugriff auf Ihr lokales Dateisystem auf dem Desktop. Dateien lesen, schreiben, durchsuchen und organisieren. Shell-Befehle mit Unterstützung für Hintergrundaufgaben ausführen und Inhalte mit Regex-Mustern durchsuchen.",
+4
View File
@@ -16,11 +16,15 @@
"table.columns.trigger.enums.api": "API-Aufruf",
"table.columns.trigger.enums.bot": "Bot-Nachricht",
"table.columns.trigger.enums.chat": "Chat-Nachricht",
"table.columns.trigger.enums.cli": "CLI",
"table.columns.trigger.enums.cron": "Geplanter Task",
"table.columns.trigger.enums.eval": "Benchmark-Auswertung",
"table.columns.trigger.enums.file_embedding": "Datei-Einbettung",
"table.columns.trigger.enums.image": "Bildgenerierung",
"table.columns.trigger.enums.memory": "Speicherextraktion",
"table.columns.trigger.enums.notify": "Benachrichtigung",
"table.columns.trigger.enums.onboarding": "Einführung",
"table.columns.trigger.enums.openapi": "OpenAPI",
"table.columns.trigger.enums.semantic_search": "Wissenssuche",
"table.columns.trigger.enums.topic": "Themenzusammenfassung",
"table.columns.trigger.enums.video": "Videogenerierung",
+11
View File
@@ -21,6 +21,9 @@
"channel.botTokenPlaceholderNew": "Paste your bot token here",
"channel.charLimit": "Character Limit",
"channel.charLimitHint": "Maximum number of characters per message",
"channel.comingSoon": "Coming Soon",
"channel.comingSoonDesc": "We are working on bringing this integration to LobeHub. Stay tuned for updates.",
"channel.comingSoonTitle": "{{name}} integration is coming soon",
"channel.concurrency": "Concurrency Mode",
"channel.concurrencyDebounce": "Debounce",
"channel.concurrencyDebounceHint": "Only process the last message in a burst (earlier ones are dropped)",
@@ -183,6 +186,14 @@
"channel.verificationToken": "Verification Token",
"channel.verificationTokenHint": "Optional. Used to verify webhook event source.",
"channel.verificationTokenPlaceholder": "Paste your verification token here",
"channel.watchKeywordInstructionLabel": "Instruction",
"channel.watchKeywordInstructionPlaceholder": "e.g. Scan the recent thread and reply if there is an actionable bug report",
"channel.watchKeywordLabel": "Keyword",
"channel.watchKeywordPlaceholder": "e.g. bug",
"channel.watchKeywords": "Watch Keywords",
"channel.watchKeywordsAdd": "Add keyword",
"channel.watchKeywordsEmpty": "No keywords added yet — bot only wakes on @mention or DM in subscribed channels.",
"channel.watchKeywordsHint": "A keyword match wakes the bot without an @mention; its instruction is prepended to the user message. Whole-word, case-insensitive.",
"channel.wechat.description": "Connect this assistant to WeChat via iLink Bot for private and group chats.",
"channel.wechatBotId": "Bot ID",
"channel.wechatBotIdHint": "Bot identifier assigned after QR code authorization.",
+53 -1
View File
@@ -24,6 +24,7 @@
"agentProfile.knowledgeBases_other": "{{count}} knowledge bases",
"agentProfile.skills_one": "{{count}} skill",
"agentProfile.skills_other": "{{count}} skills",
"agentSignal.receipts.agentSignalLabel": "Agent Signal",
"agentSignal.receipts.memory.detail": "Saved this for future replies",
"agentSignal.receipts.memory.title": "Memory saved",
"agentSignal.receipts.recentActivity": "Recent activity",
@@ -41,6 +42,16 @@
"builtinCopilot": "Built-in Copilot",
"chatList.expandMessage": "Expand Message",
"chatList.longMessageDetail": "View Details",
"chatMode.agent": "Agent",
"chatMode.agentCap.env": "Runtime env",
"chatMode.agentCap.files": "File access",
"chatMode.agentCap.memory": "Memory",
"chatMode.agentCap.tools": "Tool calls",
"chatMode.agentCap.web": "Web search",
"chatMode.agentDesc": "Agent can use tools and environment to complete tasks automatically",
"chatMode.chat": "Chat",
"chatMode.chatDesc": "No runtime environment or autonomy; uses fewer tokens",
"chatMode.select": "Switch Mode",
"claudeCodeInstallGuide.actions.openDocs": "Open Install Guide",
"claudeCodeInstallGuide.actions.openSystemTools": "Open System Tools",
"claudeCodeInstallGuide.afterInstall": "After installing, run Claude Code once to sign in, then retry your message or click Re-detect in System Tools.",
@@ -59,6 +70,7 @@
"cliAuthGuide.runCommand": "Run this in Terminal",
"cliAuthGuide.title": "Sign in to {{name}}",
"cliRateLimitGuide.actions.openSystemTools": "Open System Tools",
"cliRateLimitGuide.actions.retry": "Retry",
"cliRateLimitGuide.afterReset": "Wait until the reset time, then retry your message. If you are using API authorization, you can also check your provider quota and billing status.",
"cliRateLimitGuide.desc": "{{name}} has reached its current usage limit and cannot continue this run right now.",
"cliRateLimitGuide.limitType": "Limit window",
@@ -141,7 +153,7 @@
"extendParams.title": "Model Extension Features",
"extendParams.urlContext.desc": "When enabled, web links will be automatically parsed to retrieve the actual webpage context content",
"extendParams.urlContext.title": "Extract Webpage Link Content",
"followUpPlaceholder": "Follow up. @ to assign tasks to other agents.",
"followUpPlaceholder": "Follow up.",
"followUpPlaceholderHeterogeneous": "Follow up.",
"group.desc": "Move a task forward with multiple Agents in one shared space.",
"group.memberTooltip": "There are {{count}} members in the group",
@@ -184,6 +196,9 @@
"groupWizard.searchTemplates": "Search templates...",
"groupWizard.title": "Create Group",
"groupWizard.useTemplate": "Use Template",
"heteroAgent.cloudNotConfigured.action": "Configure",
"heteroAgent.cloudNotConfigured.desc": "Configure your Claude Code token in agent profile to start sending messages.",
"heteroAgent.cloudNotConfigured.title": "Cloud credentials required",
"heteroAgent.cloudRepo.multiSelected": "{{count}} repos selected",
"heteroAgent.cloudRepo.noRepos": "No repositories configured. Add them in agent settings.",
"heteroAgent.cloudRepo.notSet": "No repo selected",
@@ -223,6 +238,8 @@
"knowledgeBase.allFiles": "All Files",
"knowledgeBase.allLibraries": "All Libraries",
"knowledgeBase.disabled": "Library chat isnt available in this deployment. Switch to a server-side database, or use {{cloud}}.",
"knowledgeBase.files": "Files",
"knowledgeBase.libraries": "Libraries",
"knowledgeBase.library.action.add": "Add",
"knowledgeBase.library.action.detail": "Details",
"knowledgeBase.library.action.remove": "Remove",
@@ -326,6 +343,15 @@
"pageSelection.reference": "Selected Text",
"pin": "Pin",
"pinOff": "Unpin",
"plus.addSkills": "Add Skills...",
"plus.search.appSearch": "Smart Search",
"plus.search.appSearchDesc": "LobeHub optimized search service, delivering best retrieval results.",
"plus.search.modelSearch": "Provider Search",
"plus.search.modelSearchDesc": "May cause unexpected behavior when enabled, not recommended.",
"plus.search.off": "Off",
"plus.search.offDesc": "",
"plus.title": "Add",
"plus.tooltip": "Add files, skills, and more context...",
"rag.referenceChunks": "Reference Source",
"rag.userQuery.actions.delete": "Delete Query Rewrite",
"rag.userQuery.actions.regenerate": "Regenerate Query",
@@ -357,6 +383,8 @@
"searchAgents": "Search agents...",
"selectedAgents": "Selected agents",
"sendPlaceholder": "Ask, create, or start a task, <hotkey><hotkey/>",
"sendPlaceholderChat": "Ask, search, or brainstorm, <hotkey><hotkey/>",
"sendPlaceholderChatWithAgentAssignment": "Ask, search, or brainstorm. @ to bring in other agents.",
"sendPlaceholderHeterogeneous": "Describe a task or ask a question to {{name}}",
"sendPlaceholderWithAgentAssignment": "Ask, create, or start a task. @ to assign tasks to other agents.",
"sessionGroup.config": "Category Management",
@@ -734,6 +762,7 @@
"untitledAgent": "Untitled Agent",
"untitledGroup": "Untitled Group",
"updateAgent": "Update Agent Information",
"upload.action.fileOrImageUpload": "Upload File or Image",
"upload.action.fileUpload": "Upload File",
"upload.action.folderUpload": "Upload Folder",
"upload.action.imageDisabled": "The current model does not support visual recognition. Please switch models to use this feature.",
@@ -846,6 +875,25 @@
"workingPanel.documents.saved": "All changes saved",
"workingPanel.documents.title": "Document",
"workingPanel.documents.unsaved": "Unsaved changes",
"workingPanel.files.copyAbsolutePath": "Copy Path",
"workingPanel.files.copyRelativePath": "Copy Relative Path",
"workingPanel.files.count_one": "{{count}} file",
"workingPanel.files.count_other": "{{count}} files",
"workingPanel.files.empty": "No files in this workspace",
"workingPanel.files.open": "Open File",
"workingPanel.files.refresh": "Refresh",
"workingPanel.files.showInReview": "Show in Review",
"workingPanel.files.showInSystem": "Reveal in Folder",
"workingPanel.files.title": "Files",
"workingPanel.localFile.binary": "Binary file — preview unavailable",
"workingPanel.localFile.close": "Close",
"workingPanel.localFile.closeLeft": "Close to the Left",
"workingPanel.localFile.closeOther": "Close Others",
"workingPanel.localFile.closeRight": "Close to the Right",
"workingPanel.localFile.error": "Couldn't load this file",
"workingPanel.localFile.preview.raw": "Raw",
"workingPanel.localFile.preview.render": "Preview",
"workingPanel.localFile.truncated": "File preview truncated to {{limit}} characters",
"workingPanel.progress": "Progress",
"workingPanel.progress.allCompleted": "All tasks completed",
"workingPanel.resources": "Resources",
@@ -892,6 +940,8 @@
"workingPanel.review.mode.unstaged": "Unstaged",
"workingPanel.review.more": "More options",
"workingPanel.review.refresh": "Refresh",
"workingPanel.review.revealInTree": "Reveal in tree",
"workingPanel.review.revealNotFound": "File not found in project index",
"workingPanel.review.revert": "Discard changes",
"workingPanel.review.revert.confirm.cancel": "Cancel",
"workingPanel.review.revert.confirm.description": "Working tree changes to {{filePath}} will be permanently discarded. Untracked files are deleted from disk.",
@@ -908,6 +958,8 @@
"workingPanel.review.viewMode.unified": "Switch to unified view",
"workingPanel.review.wordWrap.disable": "Disable word wrap",
"workingPanel.review.wordWrap.enable": "Enable word wrap",
"workingPanel.skills.empty": "No skills found in this project",
"workingPanel.skills.title": "Skills",
"workingPanel.space": "Space",
"workingPanel.title": "Working Panel",
"you": "You",
+2
View File
@@ -1,8 +1,10 @@
{
"actionTag.category.command": "Command",
"actionTag.category.projectSkill": "Project skill",
"actionTag.category.skill": "Skill",
"actionTag.category.tool": "Tool",
"actionTag.tooltip.command": "Runs a client-side slash command before sending.",
"actionTag.tooltip.projectSkill": "Sent as a slash invocation so the agent's CLI runs the matching project skill.",
"actionTag.tooltip.skill": "Loads a reusable skill package for this request.",
"actionTag.tooltip.tool": "Marks a tool the user explicitly selected for this request.",
"actions.expand.off": "Collapse",
+13
View File
@@ -11,6 +11,19 @@
"brief.action.ignore": "Ignore",
"brief.action.retry": "Retry",
"brief.addFeedback": "Share feedback",
"brief.agentSignal.selfReview.applied.heading": "Updated",
"brief.agentSignal.selfReview.applied.summary": "{{count}} dream update was applied.",
"brief.agentSignal.selfReview.applied.summary_plural": "{{count}} dream updates were applied.",
"brief.agentSignal.selfReview.applied.title": "Dream updated resources",
"brief.agentSignal.selfReview.error.heading": "Issue",
"brief.agentSignal.selfReview.error.summary": "Some work could not be completed during this dream.",
"brief.agentSignal.selfReview.error.title": "Dream ran into an issue",
"brief.agentSignal.selfReview.ideas.summary": "Saved dream notes for future review.",
"brief.agentSignal.selfReview.ideas.title": "Dream notes",
"brief.agentSignal.selfReview.proposal.heading": "Suggestion",
"brief.agentSignal.selfReview.proposal.summary": "{{count}} dream suggestion needs your review.",
"brief.agentSignal.selfReview.proposal.summary_plural": "{{count}} dream suggestions need your review.",
"brief.agentSignal.selfReview.proposal.title": "Dream suggestion needs review",
"brief.collapse": "Show less",
"brief.commentPlaceholder": "Share your feedback...",
"brief.commentSubmit": "Submit feedback",
+16
View File
@@ -20,6 +20,22 @@
"messenger.discord.connections.disconnectFailed": "Failed to remove server.",
"messenger.discord.connections.disconnectSuccess": "Server removed.",
"messenger.discord.connections.disconnectTitle": "Remove server",
"messenger.discord.installBlocked.dismiss": "Got it",
"messenger.discord.installBlocked.suggestion": "DM the LobeHub bot in Discord to link your personal account — you don't need to add the bot again. Or ask the original installer to remove this server in LobeHub Settings → Messenger before re-adding it.",
"messenger.discord.installBlocked.title": "Server already connected",
"messenger.discord.installBlocked.withName": "\"{{workspace}}\" is already connected to LobeHub by another user.",
"messenger.discord.installBlocked.withoutName": "This Discord server is already connected to LobeHub by another user.",
"messenger.discord.installResult.failed": "Discord install failed ({{reason}}). Please try again or contact support.",
"messenger.discord.installResult.reasons.accessDenied": "authorization was cancelled",
"messenger.discord.installResult.reasons.exchangeFailed": "Discord authorization failed",
"messenger.discord.installResult.reasons.generic": "an unknown error occurred",
"messenger.discord.installResult.reasons.invalidState": "the install session expired",
"messenger.discord.installResult.reasons.missingAppId": "Discord returned incomplete app information",
"messenger.discord.installResult.reasons.missingCodeOrState": "Discord returned incomplete install parameters",
"messenger.discord.installResult.reasons.missingTenant": "Discord did not return a server identifier",
"messenger.discord.installResult.reasons.missingToken": "Discord did not return an access token",
"messenger.discord.installResult.reasons.persistFailed": "the server connection could not be saved",
"messenger.discord.installResult.success": "Discord server connected.",
"messenger.discord.userPending.cta": "Open in Discord",
"messenger.discord.userPending.hint": "Open the bot in Discord and send any message to finish linking your account.",
"messenger.discord.userPending.name": "Not linked yet",
+8
View File
@@ -0,0 +1,8 @@
{
"dropdownLabel": "Open working directory in",
"errors.appNotInstalled": "{{appName}} is not installed",
"errors.launchFailed": "Failed to open in {{appName}}: {{error}}",
"errors.pathNotFound": "Path not found: {{path}}",
"errors.unknown": "unknown error",
"tooltip": "Open in {{appName}}"
}
+19
View File
@@ -69,6 +69,9 @@
"builtins.lobe-agent-management.render.installPlugin.plugin": "Plugin",
"builtins.lobe-agent-management.render.installPlugin.success": "Installed successfully",
"builtins.lobe-agent-management.title": "Agent Manager",
"builtins.lobe-agent.apiName.analyzeVisualMedia": "Analyze visual media",
"builtins.lobe-agent.apiName.analyzeVisualMedia.mediaCount": "{{count}} media",
"builtins.lobe-agent.apiName.analyzeVisualMedia.result": "Analyze visual media: <question>{{question}}</question>",
"builtins.lobe-agent.apiName.callSubAgent": "Call sub-agent",
"builtins.lobe-agent.apiName.callSubAgent.completed": "Sub-agent dispatched: ",
"builtins.lobe-agent.apiName.callSubAgent.loading": "Dispatching sub-agent: ",
@@ -87,6 +90,14 @@
"builtins.lobe-agent.title": "Lobe Agent",
"builtins.lobe-claude-code.agent.instruction": "Instruction",
"builtins.lobe-claude-code.agent.result": "Result",
"builtins.lobe-claude-code.task.createLabel": "Creating task: ",
"builtins.lobe-claude-code.task.getLabel": "Inspecting task #{{taskId}}",
"builtins.lobe-claude-code.task.listLabel": "Listing tasks",
"builtins.lobe-claude-code.task.updateCompleted": "Completed",
"builtins.lobe-claude-code.task.updateDeleted": "Deleted",
"builtins.lobe-claude-code.task.updateInProgress": "Started",
"builtins.lobe-claude-code.task.updateLabel": "Updating task #{{taskId}}",
"builtins.lobe-claude-code.task.updatePending": "Reset",
"builtins.lobe-claude-code.todoWrite.allDone": "All tasks completed",
"builtins.lobe-claude-code.todoWrite.currentStep": "Current step",
"builtins.lobe-claude-code.todoWrite.todos": "Todos",
@@ -234,6 +245,14 @@
"builtins.lobe-page-agent.apiName.updateNode": "Update node",
"builtins.lobe-page-agent.apiName.wrapNodes": "Wrap nodes",
"builtins.lobe-page-agent.title": "Page",
"builtins.lobe-self-feedback-intent.apiName.declareSelfFeedbackIntent": "Record improvement idea",
"builtins.lobe-self-feedback-intent.inspector.gap.proposal": "Suggest improvement",
"builtins.lobe-self-feedback-intent.inspector.memory.write": "Record preference",
"builtins.lobe-self-feedback-intent.inspector.rejected": "Not recorded",
"builtins.lobe-self-feedback-intent.inspector.skill.consolidate": "Organize methods",
"builtins.lobe-self-feedback-intent.inspector.skill.create": "New method found",
"builtins.lobe-self-feedback-intent.inspector.skill.refine": "Improve method",
"builtins.lobe-self-feedback-intent.title": "Improvement Ideas",
"builtins.lobe-skill-store.apiName.importFromMarket": "Import from Market",
"builtins.lobe-skill-store.apiName.importSkill": "Import Skill",
"builtins.lobe-skill-store.apiName.searchSkill": "Search Skills",
+21 -5
View File
@@ -181,12 +181,11 @@
"agentSkillModal.url.urlPlaceholder": "https://example.com/path/to/SKILL.md",
"agentSkillTag": "Agent Skill",
"agentTab.chat": "Chat Preferences",
"agentTab.documents": "Documents",
"agentTab.meta": "Agent info",
"agentTab.modal": "Model Settings",
"agentTab.opening": "Opening Settings",
"agentTab.plugin": "Skill Settings",
"agentTab.prompt": "Agent Profile",
"agentTab.selfIteration": "Self-Iteration",
"agentTab.tts": "Voice Service",
"analytics.telemetry.desc": "Help us improve {{appName}} with anonymous usage data",
"analytics.telemetry.title": "Send Anonymous Usage Data",
@@ -554,9 +553,6 @@
"settingChat.inputTemplate.desc": "The user's latest message will be filled into this template",
"settingChat.inputTemplate.placeholder": "Preprocessing template {{text}} will be replaced with real-time input information",
"settingChat.inputTemplate.title": "User Input Preprocessing",
"settingChat.selfIteration.enabled.desc": "Allow this assistant to review recent signals and improve its own skills when the lab workflow runs",
"settingChat.selfIteration.enabled.title": "Enable Self-Iteration",
"settingChat.selfIteration.title": "Advanced Labs",
"settingChat.submit": "Update Chat Preferences",
"settingChat.title": "Chat Settings",
"settingChatAppearance.autoScrollOnStreaming.desc": "Automatically scroll to bottom when AI is generating response",
@@ -659,6 +655,17 @@
"settingModel.maxTokens.title": "Max Tokens Limit",
"settingModel.model.desc": "{{provider}} model",
"settingModel.model.title": "Model",
"settingModel.params.panel.advanced": "Advanced Settings",
"settingModel.params.panel.agentTitle": "Agent Advanced Settings",
"settingModel.params.panel.contextCompression": "Auto-compress Context",
"settingModel.params.panel.creativity": "Creativity",
"settingModel.params.panel.historyLimit": "Limit History Messages",
"settingModel.params.panel.openness": "Openness",
"settingModel.params.panel.responseLength": "Limit Response Length",
"settingModel.params.panel.tab": "Params",
"settingModel.params.panel.title": "Chat Parameter Settings",
"settingModel.params.panel.topicDivergence": "Topic Divergence",
"settingModel.params.panel.vocabularyRichness": "Vocabulary Richness",
"settingModel.params.title": "Advanced Parameters",
"settingModel.presencePenalty.desc": "The higher the value, the more inclined to use different expressions and avoid concept repetition; the lower the value, the more inclined to use repeated concepts or narratives, resulting in more consistent expression.",
"settingModel.presencePenalty.title": "Expression Divergence",
@@ -684,6 +691,10 @@
"settingOpening.openingQuestions.title": "Opening Questions",
"settingOpening.title": "Opening Settings",
"settingPlugin.title": "Skill List",
"settingSelfIteration.enabled.desc": "Allow this assistant to review recent signals and improve its own skills when the self-iteration workflow runs.",
"settingSelfIteration.enabled.managedDesc": "Always on for Lobe AI while Self-Iteration is available.",
"settingSelfIteration.enabled.title": "Enable Self-Iteration",
"settingSelfIteration.title": "Self-Iteration",
"settingSystem.oauth.info.desc": "Logged in",
"settingSystem.oauth.info.title": "Account Information",
"settingSystem.oauth.signin.action": "Sign In",
@@ -897,7 +908,12 @@
"tab.uploadZip": "Upload Zip",
"tab.uploadZip.desc": "Upload a local .zip or .skill file",
"tab.usage": "Usage",
"tools.activation.auto": "Auto",
"tools.activation.auto.desc": "Smart",
"tools.activation.pinned": "Pinned",
"tools.activation.pinned.desc": "Always On",
"tools.add": "Add Skill",
"tools.builtins.configure": "Configure",
"tools.builtins.find-skills.description": "Helps users discover and install agent skills when they ask \"how do I do X\", \"find a skill for X\", or want to extend capabilities",
"tools.builtins.find-skills.title": "Find Skills",
"tools.builtins.groupName": "Built-ins",
+4
View File
@@ -16,11 +16,15 @@
"table.columns.trigger.enums.api": "API Call",
"table.columns.trigger.enums.bot": "Bot Message",
"table.columns.trigger.enums.chat": "Chat Message",
"table.columns.trigger.enums.cli": "CLI",
"table.columns.trigger.enums.cron": "Scheduled Task",
"table.columns.trigger.enums.eval": "Benchmark Eval",
"table.columns.trigger.enums.file_embedding": "File Embedding",
"table.columns.trigger.enums.image": "Image Generation",
"table.columns.trigger.enums.memory": "Memory Extraction",
"table.columns.trigger.enums.notify": "Notification",
"table.columns.trigger.enums.onboarding": "Onboarding",
"table.columns.trigger.enums.openapi": "OpenAPI",
"table.columns.trigger.enums.semantic_search": "Knowledge Search",
"table.columns.trigger.enums.topic": "Topic Summary",
"table.columns.trigger.enums.video": "Video Generation",
+3
View File
@@ -41,7 +41,9 @@
"credits.autoTopUp.monthlyLimitDesc": "Maximum amount that can be auto-charged per month. Leave empty for no limit",
"credits.autoTopUp.monthlyLimitPlaceholder": "No limit",
"credits.autoTopUp.monthlyTopUpAmount": "Monthly Top-Up Amount",
"credits.autoTopUp.noCustomerHint": "Purchase credits once to save a payment method before enabling auto top-up.",
"credits.autoTopUp.noPaymentMethodHint": "No payment method on file. Auto top-up needs a saved card to charge automatically.",
"credits.autoTopUp.purchaseCredits": "Purchase Credits",
"credits.autoTopUp.saveError": "Failed to save auto top-up settings",
"credits.autoTopUp.saveSuccess": "Auto top-up settings saved",
"credits.autoTopUp.setupPaymentMethod": "Add Payment Method",
@@ -83,6 +85,7 @@
"credits.packages.title": "My Credit Packages",
"credits.topUp.cancel": "Cancel",
"credits.topUp.custom": "Custom",
"credits.topUp.freeFeeHint": "Free plan top-ups include a {{fee}} service fee per 1M credits.",
"credits.topUp.maxAmountError": "Single purchase amount cannot exceed ${{max}}",
"credits.topUp.purchaseError": "Purchase failed, please try again later",
"credits.topUp.purchaseNow": "Purchase Now",
+8
View File
@@ -40,6 +40,14 @@
"agentMarketplace.render.alreadyInLibraryTag": "Already in library",
"agentMarketplace.render.alreadyInLibrary_one": "{{count}} already in library",
"agentMarketplace.render.alreadyInLibrary_other": "{{count}} already in library",
"claudeCode.askUserQuestion.escape.back": "Back to options",
"claudeCode.askUserQuestion.escape.enter": "Or type directly",
"claudeCode.askUserQuestion.escape.placeholder": "Type your answer here…",
"claudeCode.askUserQuestion.multiSelectTag": "(multi-select)",
"claudeCode.askUserQuestion.skip": "Skip",
"claudeCode.askUserQuestion.submit": "Submit",
"claudeCode.askUserQuestion.timeExpired": "Time expired — using option 1 of each question.",
"claudeCode.askUserQuestion.timeRemaining": "Time remaining: {{time}} · unanswered questions default to option 1 on timeout.",
"codeInterpreter-legacy.error": "Execution Error",
"codeInterpreter-legacy.executing": "Executing...",
"codeInterpreter-legacy.files": "Files:",
+11
View File
@@ -21,6 +21,9 @@
"channel.botTokenPlaceholderNew": "Pega tu token del bot aquí",
"channel.charLimit": "Límite de caracteres",
"channel.charLimitHint": "Número máximo de caracteres por mensaje",
"channel.comingSoon": "Próximamente",
"channel.comingSoonDesc": "Estamos trabajando para traer esta integración a LobeHub. Mantente atento para más actualizaciones.",
"channel.comingSoonTitle": "La integración de {{name}} estará disponible próximamente",
"channel.concurrency": "Modo de Concurrencia",
"channel.concurrencyDebounce": "Antirrebote",
"channel.concurrencyDebounceHint": "Procesar solo el último mensaje de una ráfaga (los anteriores se descartan)",
@@ -183,6 +186,14 @@
"channel.verificationToken": "Token de Verificación",
"channel.verificationTokenHint": "Opcional. Usado para verificar la fuente de eventos del webhook.",
"channel.verificationTokenPlaceholder": "Pega tu token de verificación aquí",
"channel.watchKeywordInstructionLabel": "Instrucción",
"channel.watchKeywordInstructionPlaceholder": "p. ej., Escanea el hilo reciente y responde si hay un informe de error accionable",
"channel.watchKeywordLabel": "Palabra clave",
"channel.watchKeywordPlaceholder": "p. ej., error",
"channel.watchKeywords": "Palabras clave de vigilancia",
"channel.watchKeywordsAdd": "Agregar palabra clave",
"channel.watchKeywordsEmpty": "No se han agregado palabras clave aún — el bot solo se activa con una @mención o un mensaje directo en los canales suscritos.",
"channel.watchKeywordsHint": "Cuando un mensaje en un canal suscrito coincide con una palabra clave, el bot se activa sin una @mención y la instrucción se antepone al mensaje del usuario antes de enviarlo a la IA. Coincidencia insensible a mayúsculas, de palabra completa.",
"channel.wechat.description": "Conecta este asistente a WeChat a través de iLink Bot para chats privados y grupales.",
"channel.wechatBotId": "ID del Bot",
"channel.wechatBotIdHint": "Identificador del bot asignado tras la autorización mediante código QR.",
+45
View File
@@ -24,6 +24,7 @@
"agentProfile.knowledgeBases_other": "{{count}} bases de conocimiento",
"agentProfile.skills_one": "{{count}} habilidad",
"agentProfile.skills_other": "{{count}} habilidades",
"agentSignal.receipts.agentSignalLabel": "Señal del Agente",
"agentSignal.receipts.memory.detail": "Guardado para respuestas futuras",
"agentSignal.receipts.memory.title": "Memoria guardada",
"agentSignal.receipts.recentActivity": "Actividad reciente",
@@ -41,6 +42,16 @@
"builtinCopilot": "Copiloto integrado",
"chatList.expandMessage": "Expandir mensaje",
"chatList.longMessageDetail": "Ver detalles",
"chatMode.agent": "Agente",
"chatMode.agentCap.env": "Entorno de ejecución",
"chatMode.agentCap.files": "Acceso a archivos",
"chatMode.agentCap.memory": "Memoria",
"chatMode.agentCap.tools": "Llamadas de herramientas",
"chatMode.agentCap.web": "Búsqueda web",
"chatMode.agentDesc": "El agente puede usar herramientas y el entorno para completar tareas automáticamente",
"chatMode.chat": "Chat",
"chatMode.chatDesc": "Sin entorno de ejecución ni autonomía; usa menos tokens",
"chatMode.select": "Cambiar modo",
"claudeCodeInstallGuide.actions.openDocs": "Abrir guía de instalación",
"claudeCodeInstallGuide.actions.openSystemTools": "Abrir herramientas del sistema",
"claudeCodeInstallGuide.afterInstall": "Después de instalar, ejecuta Claude Code una vez para iniciar sesión; luego vuelve a intentar tu mensaje o haz clic en Detectar de nuevo en Herramientas del sistema.",
@@ -59,6 +70,7 @@
"cliAuthGuide.runCommand": "Ejecuta esto en la Terminal",
"cliAuthGuide.title": "Iniciar sesión en {{name}}",
"cliRateLimitGuide.actions.openSystemTools": "Abrir herramientas del sistema",
"cliRateLimitGuide.actions.retry": "Reintentar",
"cliRateLimitGuide.afterReset": "Espera hasta la hora de restablecimiento y vuelve a intentar tu mensaje. Si usas autorización por API, también puedes revisar tu cuota y estado de facturación.",
"cliRateLimitGuide.desc": "{{name}} ha alcanzado su límite de uso actual y no puede continuar esta ejecución por ahora.",
"cliRateLimitGuide.limitType": "Ventana de límite",
@@ -223,6 +235,8 @@
"knowledgeBase.allFiles": "Todos los archivos",
"knowledgeBase.allLibraries": "Todas las bibliotecas",
"knowledgeBase.disabled": "El chat de biblioteca no está disponible en esta implementación. Cambia a una base de datos del lado del servidor o usa {{cloud}}.",
"knowledgeBase.files": "Archivos",
"knowledgeBase.libraries": "Bibliotecas",
"knowledgeBase.library.action.add": "Agregar",
"knowledgeBase.library.action.detail": "Detalles",
"knowledgeBase.library.action.remove": "Eliminar",
@@ -326,6 +340,15 @@
"pageSelection.reference": "Texto Seleccionado",
"pin": "Fijar",
"pinOff": "Desfijar",
"plus.addSkills": "Agregar habilidades...",
"plus.search.appSearch": "Búsqueda inteligente",
"plus.search.appSearchDesc": "Servicio de búsqueda optimizado de LobeHub, ofreciendo los mejores resultados de recuperación.",
"plus.search.modelSearch": "Búsqueda de proveedor",
"plus.search.modelSearchDesc": "Puede causar un comportamiento inesperado cuando está habilitado, no recomendado.",
"plus.search.off": "Apagado",
"plus.search.offDesc": "",
"plus.title": "Agregar",
"plus.tooltip": "Agregar archivos, habilidades y más contexto...",
"rag.referenceChunks": "Fuente de referencia",
"rag.userQuery.actions.delete": "Eliminar reescritura de consulta",
"rag.userQuery.actions.regenerate": "Regenerar consulta",
@@ -357,6 +380,8 @@
"searchAgents": "Buscar agentes...",
"selectedAgents": "Agentes seleccionados",
"sendPlaceholder": "Pregunta, crea o inicia una tarea, <hotkey><hotkey/>",
"sendPlaceholderChat": "Pregunta, busca o genera ideas, <hotkey><hotkey/>",
"sendPlaceholderChatWithAgentAssignment": "Pregunta, busca o genera ideas. @ para incluir a otros agentes.",
"sendPlaceholderHeterogeneous": "Pide a {{name}} que realice una tarea...",
"sendPlaceholderWithAgentAssignment": "Pregunta, crea o inicia una tarea. Usa @ para asignar tareas a otros agentes.",
"sessionGroup.config": "Gestión de grupos",
@@ -734,6 +759,7 @@
"untitledAgent": "Agente sin título",
"untitledGroup": "Grupo sin título",
"updateAgent": "Actualizar información del agente",
"upload.action.fileOrImageUpload": "Subir archivo o imagen",
"upload.action.fileUpload": "Subir archivo",
"upload.action.folderUpload": "Subir carpeta",
"upload.action.imageDisabled": "El modelo actual no admite reconocimiento visual. Cambia de modelo para usar esta función.",
@@ -846,6 +872,23 @@
"workingPanel.documents.saved": "All changes saved",
"workingPanel.documents.title": "Document",
"workingPanel.documents.unsaved": "Unsaved changes",
"workingPanel.files.copyAbsolutePath": "Copiar Ruta Absoluta",
"workingPanel.files.copyRelativePath": "Copiar Ruta Relativa",
"workingPanel.files.count_one": "{{count}} archivo",
"workingPanel.files.count_other": "{{count}} archivos",
"workingPanel.files.empty": "No hay archivos en este espacio de trabajo",
"workingPanel.files.open": "Abrir Archivo",
"workingPanel.files.refresh": "Actualizar",
"workingPanel.files.showInReview": "Mostrar en Revisión",
"workingPanel.files.showInSystem": "Revelar en Carpeta",
"workingPanel.files.title": "Archivos",
"workingPanel.localFile.binary": "Archivo binario — vista previa no disponible",
"workingPanel.localFile.close": "Cerrar",
"workingPanel.localFile.closeLeft": "Cerrar a la Izquierda",
"workingPanel.localFile.closeOther": "Cerrar Otros",
"workingPanel.localFile.closeRight": "Cerrar a la Derecha",
"workingPanel.localFile.error": "No se pudo cargar este archivo",
"workingPanel.localFile.truncated": "Vista previa del archivo truncada a {{limit}} caracteres",
"workingPanel.progress": "Progress",
"workingPanel.progress.allCompleted": "All tasks completed",
"workingPanel.resources": "Resources",
@@ -892,6 +935,8 @@
"workingPanel.review.mode.unstaged": "No preparado",
"workingPanel.review.more": "Más opciones",
"workingPanel.review.refresh": "Actualizar",
"workingPanel.review.revealInTree": "Revelar en el árbol",
"workingPanel.review.revealNotFound": "Archivo no encontrado en el índice del proyecto",
"workingPanel.review.revert": "Descartar cambios",
"workingPanel.review.revert.confirm.cancel": "Cancelar",
"workingPanel.review.revert.confirm.description": "Los cambios en el árbol de trabajo de {{filePath}} se descartarán permanentemente. Los archivos sin seguimiento se eliminarán del disco.",
+1
View File
@@ -8,6 +8,7 @@
"brief.action.confirm": "Confirmar",
"brief.action.confirmDone": "Confirmar",
"brief.action.feedback": "Comentarios",
"brief.action.ignore": "Ignorar",
"brief.action.retry": "Reintentar",
"brief.addFeedback": "Compartir comentarios",
"brief.collapse": "Mostrar menos",
+16 -3
View File
@@ -20,6 +20,22 @@
"messenger.discord.connections.disconnectFailed": "No se pudo eliminar el servidor.",
"messenger.discord.connections.disconnectSuccess": "Servidor eliminado.",
"messenger.discord.connections.disconnectTitle": "Eliminar servidor",
"messenger.discord.installBlocked.dismiss": "Entendido",
"messenger.discord.installBlocked.suggestion": "Envía un mensaje directo al bot de LobeHub en Discord para vincular tu cuenta personal; no necesitas agregar el bot nuevamente. O pide al instalador original que elimine este servidor en Configuración de LobeHub → Messenger antes de volver a agregarlo.",
"messenger.discord.installBlocked.title": "Servidor ya conectado",
"messenger.discord.installBlocked.withName": "\"{{workspace}}\" ya está conectado a LobeHub por otro usuario.",
"messenger.discord.installBlocked.withoutName": "Este servidor de Discord ya está conectado a LobeHub por otro usuario.",
"messenger.discord.installResult.failed": "La instalación de Discord falló ({{reason}}). Por favor, inténtalo de nuevo o contacta con soporte.",
"messenger.discord.installResult.reasons.accessDenied": "la autorización fue cancelada",
"messenger.discord.installResult.reasons.exchangeFailed": "la autorización de Discord falló",
"messenger.discord.installResult.reasons.generic": "ocurrió un error desconocido",
"messenger.discord.installResult.reasons.invalidState": "la sesión de instalación expiró",
"messenger.discord.installResult.reasons.missingAppId": "Discord devolvió información incompleta de la aplicación",
"messenger.discord.installResult.reasons.missingCodeOrState": "Discord devolvió parámetros de instalación incompletos",
"messenger.discord.installResult.reasons.missingTenant": "Discord no devolvió un identificador de servidor",
"messenger.discord.installResult.reasons.missingToken": "Discord no devolvió un token de acceso",
"messenger.discord.installResult.reasons.persistFailed": "no se pudo guardar la conexión del servidor",
"messenger.discord.installResult.success": "Servidor de Discord conectado.",
"messenger.discord.userPending.cta": "Abrir en Discord",
"messenger.discord.userPending.hint": "Abre el bot en Discord y envía cualquier mensaje para finalizar la vinculación de tu cuenta.",
"messenger.discord.userPending.name": "Aún no vinculado",
@@ -96,9 +112,6 @@
"verify.error.missingToken": "Enlace no válido. Abre esta página desde el bot.",
"verify.error.title": "No se pudo confirmar la vinculación",
"verify.error.unlinkBeforeRelink": "Esta cuenta de LobeHub ya está vinculada a otra cuenta de Telegram. Desconéctala en Configuración → Messenger antes de vincular una nueva.",
"verify.labRequired.description": "Messenger es actualmente una función de Labs. Actívala en Configuración → Avanzado → Labs y recarga esta página.",
"verify.labRequired.openSettings": "Abrir configuración de Labs",
"verify.labRequired.title": "Habilita Messenger para continuar",
"verify.signInCta": "Inicia sesión para continuar",
"verify.signInRequired": "Por favor, inicia sesión en LobeHub para confirmar la vinculación.",
"verify.success.description": "Tu cuenta ahora está conectada a {{platform}}. Abre {{platform}} y envía tu primer mensaje.",

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