Compare commits

...

55 Commits

Author SHA1 Message Date
Innei ee14d6c481 Merge branch 'main' into feat/onboarding-patch-preference
Resolve conflicts, take main's version for all files.
2026-04-21 15:16:53 +08:00
Innei 84a36c0cc3 Merge remote-tracking branch 'origin/canary' into feat/onboarding-patch-preference 2026-04-20 17:29:14 +08:00
Arvin Xu 77fd0f13f0 🐛 fix(hetero-agent): persist streamed text alongside tool writes; collapse workflow summary (#13968)
* 🐛 fix(hetero-agent): persist accumulated text alongside tools[] writes

Carry the latest streamed content/reasoning into the same UPDATE that
writes tools[], so the DB row stays in sync with the in-memory stream.
Without this, gateway `tool_end → fetchAndReplaceMessages` reads a
tools-only row and clobbers the UI's streamed text.

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

*  feat(workflow-summary): collapse summary when many tool kinds

When a turn calls >4 distinct tool kinds, list only the top 3 by count
and append "+N more · X calls total[ · Y failed]". Keeps the inline
summary scannable on long tool-heavy turns instead of running off the
line. Short turns keep the existing full list.

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

* 💄 style(claude-code): use chip style for Skill inspector name

Replace the colon+highlight text with a pill-shaped chip containing the
SkillsIcon and skill name. Gives the Skill activation readout visual
parity with other tool chips and prevents long skill names from
overflowing the inspector line.

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

*  test(agent-documents): assert on rendered title, not filename

#13940 changed DocumentItem to prefer document.title over filename, but
the sidebar test still expected 'brief.md' / 'example.com'. Align the
assertions with the current behavior so the suite is green on canary.

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

* 💄 style(tab-bar): show agent avatar on agent/topic tabs

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 17:13:46 +08:00
Arvin Xu ccbb75da06 ♻️ refactor(hetero-agent): persist per-step usage to each step assistant message (#13964)
* ♻️ refactor(hetero-agent): persist per-step usage to each step assistant message

Previously, usage tokens from a multi-step Claude Code run were accumulated
across all turns and written only to the final assistant message, leaving
intermediate step messages with no usage metadata.

Each Claude Code `turn_metadata` event carries per-turn token usage
(deduped by adapter per message.id), so write it straight through to the
current step's assistant message via persistQueue (runs after any in-flight
stream_start(newStep) that swaps currentAssistantMessageId). The `result_usage`
grand-total event is intentionally dropped — applying it would overwrite the
last step with the sum of all prior steps.

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

* ♻️ refactor(hetero-agent): normalize usage inside CC adapter (UsageData)

Follows the same principle as LOBE-7363: provider-native shape knowledge
stays in the adapter, executor only sees normalized events. The previous
commit left Anthropic-shape fields (input_tokens, cache_creation_input_tokens,
cache_read_input_tokens) leaking into the executor via `buildUsageMetadata`.

Introduce `UsageData` in `@lobechat/heterogeneous-agents` types with LobeHub's
MessageMetadata.usage field names. The Claude Code adapter now normalizes
Anthropic usage into `UsageData` before emitting step_complete, for both
turn_metadata (per-turn) and result_usage (grand total). Executor drops
`buildUsageMetadata` and writes `{ metadata: { usage: event.data.usage } }`
directly. Future adapters (Codex, Kimi-CLI) normalize their native usage into
the same shape; executor stays provider-agnostic.

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

* ♻️ refactor(hetero-agent): persist per-step provider alongside model

CC / hetero-agent assistant messages were writing `model` per step but
leaving `message.provider` NULL, so pricing/usage lookups could not key on
the adapter (e.g. `claude-code`, billed via CLI subscription rather than
raw Anthropic API rates).

CC adapter now emits `provider: 'claude-code'` on every turn_metadata event
(same collection point as model + normalized usage). Executor tracks
`lastProvider` alongside `lastModel` and writes it into:

- the step-boundary update for the previous step
- `createMessage` for each new step's assistant
- the onComplete write for the final step

Provider choice is the CLI flavor (what the adapter knows), not the wrapped
model's native vendor — CC runs under its own subscription billing, so
downstream pricing must treat `claude-code` as its own provider rather than
conflating with `anthropic`.

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

* 🐛 fix(hetero-agent): read authoritative usage from message_delta, not assistant

Under `--include-partial-messages` (enabled by the CC adapter preset), Claude
Code echoes a STALE usage snapshot from `message_start` on every content-block
`assistant` event — e.g. `output_tokens: 8` or `1` — and never updates that
snapshot as more output tokens are generated. The authoritative per-turn
total arrives on a separate `stream_event: message_delta` with the final
`input_tokens` + cache counts + cumulative `output_tokens` (e.g. 265).

The adapter previously grabbed usage from the first `assistant` event per
message.id and deduped, so DB rows ended up with `totalOutputTokens: 1` on
every CC turn.

Move turn_metadata emission from `handleAssistant` to a new `message_delta`
case in `handleStreamEvent`. `handleAssistant` still tracks the latest model
so turn_metadata (emitted later on message_delta) carries the correct model
even if `message_start` doesn't.

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

* 💄 style(extras-usage): fall back to metadata.usage when top-level is absent

The assistant Extras bar passes `message.usage` to the Usage component,
which conditionally renders a token-count badge on `!!usage.totalTokens`.
Nothing in the read path aggregates `message.metadata.usage` up to
`message.usage`, so the top-level field is always undefined for DB-read
messages — the badge never shows for CC/hetero turns (and in practice also
skips the gateway path where usage only lands in `metadata.usage`).

Prefer `usage` when the top-level field is populated, fall back to
`metadata.usage` otherwise. Both fields are the same `ModelUsage` shape, so
the Usage/TokenDetail components don't need any other change.

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

* ♻️ refactor(extras-usage): promote metadata.usage inside conversation-flow parse

The previous fix spread a `usage ?? metadata?.usage` fallback across each
renderer site that passed usage to the Extras bar. Consolidate: `parse`
(src/store → packages/conversation-flow) is the single renderer-side
transform every consumer flows through, so promote `metadata.usage` onto the
top-level `usage` field there and revert the per-site fallbacks.

UIChatMessage exposes a canonical `usage` field, but no server-side or
client-side transform populated it — executors write to `metadata.usage`
(canonical storage, JSONB-friendly). Doing the promotion in parse keeps the
rule in one place, close to where display shapes are built, and covers both
desktop (local PGlite) and web (remote Postgres) without a backend deploy.

Top-level `usage` is preserved when already present (e.g. group-level
aggregates) — `metadata.usage` is strictly a fallback.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:19:18 +08:00
Innei 2711aa9191 feat(desktop): add dedicated topic popup window with cross-window sync (#13957)
*  feat(desktop): add dedicated topic popup window with cross-window sync

Introduce a standalone Vite entry for the desktop "open topic in new window"
action. The popup is a lightweight SPA (no sidebar, no portal) hosting only
the Conversation, and stays in sync with the main window through a
BroadcastChannel bus.

- Add popup.html + entry.popup.tsx + popupRouter.config.tsx
- Add /popup/agent/:aid/:tid and /popup/group/:gid/:tid routes
- Reuse main Conversation/ChatInput; wrap in MarketAuth + Hotkeys providers
- Pin-on-top button in the popup titlebar (new windows IPC: set/isAlwaysOnTop)
- Group topic "open in new window" now uses groupId (previously misused agentId)
- Cross-window sync: refreshMessages/refreshTopic emit via BroadcastChannel;
  subscriber revalidates local SWR caches with echo-loop suppression
- Hide WorkingPanel toggle inside /popup (no WorkingSidebar present)
- RendererUrlManager dispatches /popup/* to popup.html in prod; dev middleware
  rewrites SPA deep links while skipping asset/module requests

* 💄 style(desktop): restore loading splash in popup window

* ♻️ refactor(desktop): replace cross-window sync with popup-ownership guard

The BroadcastChannel-based bidirectional sync between the main SPA and the
topic popup window had edge cases during streaming. Drop it in favour of a
simpler ownership model: when a topic is already open in a popup, the main
window shows a "focus popup" redirect instead of rendering a second
conversation.

- Remove src/libs/crossWindowBus.ts and src/features/CrossWindowSync
- Remove postMessagesMutation/postTopicsMutation calls from refresh actions
- Add windows.listTopicPopups + windows.focusTopicPopup IPC
- Main process broadcasts topicPopupsChanged on popup open/close; parses
  (scope, id, topicId) from the popup window's /popup/... path
- Renderer useTopicPopupsRegistry subscribes to broadcasts and fetches the
  initial snapshot; useTopicInPopup selects by scope
- New TopicInPopupGuard component with "Focus popup window" button
- Desktop-only index.desktop.tsx variants for (main)/agent and (main)/group
  render the guard when the current topic is owned by a popup
- i18n: topic.inPopup.title / description / focus in default + en/zh

* 🐛 fix(desktop): re-evaluate popup guard when topic changes

Subscribe to the popups array and derive findPopup via useMemo so scope changes (e.g. switching topic in the sidebar while a popup is open) correctly re-compute the guard and let the main window render the newly active topic.

* 🐛 fix(desktop): focus detached topic popup from main window

*  feat(desktop): add open in popup window action to menu for active topic

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

* 🎨 style: sort imports to satisfy simple-import-sort rule

*  feat(error): add resetPath prop to ErrorCapture and ErrorBoundary for customizable navigation

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

* ♻️ refactor: restore ChatHydration in ConversationArea for web/mobile routes

Reintroduce ChatHydration component to agent and group ConversationArea
so that URL query sync (topic/thread) works on web and mobile routes,
not only on desktop entry files.

*  feat(electron): enforce absolute base URL in renderer config to fix asset resolution in popup windows

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

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-04-19 02:15:29 +08:00
Innei c213483a7a feat(workflow): tri-state completion status icon for WorkflowCollapse (#13952)
*  feat: add full-expand toggle to WorkflowCollapse with three-level expansion

- Replace boolean expanded with expandLevel: 'collapsed' | 'semi' | 'full'
- Add cyclic toggle button in header (ChevronDown / Maximize2 / Minimize2)
- Keep max-height scroll constraint in semi mode, remove it in full mode
- Update tests for three-level states and toggle behavior

*  feat: enhance WorkflowCollapse with animated expand toggle and refined icon behavior

- Introduced animated transitions for the expand toggle button using `motion` from `framer-motion`.
- Updated expand toggle logic to improve user experience with clearer icon states.
- Removed unused `ChevronDown` icon and adjusted expand toggle label conditions.
- Added constants for toggle icon size and transition settings for better maintainability.

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

* test: fix WorkflowCollapse tests for animated toggle behavior

* feat(workflow): tri-state completion status icon for WorkflowCollapse

Replace binary errorPresent with getWorkflowCompletionStatus:
- success → green Check
- partial failure → yellow AlertTriangle
- all failed → red X

Adds unit tests for all three states.

* fix(workflow): address Codex review feedback

- Add workflow.collapse / workflow.expandFull locale keys
- Make expand toggle keyboard-accessible (tabIndex + Enter/Space)

* refactor(workflow): replace nested ternary with switch for statusIcon

* 🌐 fix(workflow): remove hardcoded defaultValue from i18n keys

Addresses Codex review: per AGENTS.md i18n rule, user-facing strings
should live in locale files, not as defaultValue fallbacks.

- Remove defaultValue from t('workflow.expandFull') and t('workflow.collapse')
- Update test mock to include the new keys so tests remain green

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-04-19 01:23:51 +08:00
Arvin Xu 4e5db98ffc ♻️ refactor(agent-documents): fix title/documentId flow + split Inspector per action (#13940)
- extract H1 from markdown content as document title (stripped from content)
- use title verbatim as filename (no extension); simplify dedup to `-2`, `-3`
- AgentDocumentModel.create accepts optional title; falls back to filename
- ExecutionRuntime createDocument returns documents.id (not agentDocuments.id)
  as state.documentId so the portal can resolve the row for openDocument
- sidebar DocumentItem prefers title over filename
- split AgentDocumentsInspector into 11 per-apiName components (Notebook pattern)
- tests: filename util (13), ExecutionRuntime wiring (5), updated model + service

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 01:06:06 +08:00
Arvin Xu b909e4ae20 💄 style(hetero-agent): add hetero-mode actions bar (#13963)
*  feat(hetero-agent): add hetero-mode actions bar with copy/delete only

Hide edit, regenerate, branching, translate, tts, share and delAndRegenerate
for heterogeneous-agent sessions where these actions don't apply. Introduce
`mode: 'hetero'` on MessageActionsConfig and dispatch to dedicated Hetero
action bars for user, assistant, and assistant-group messages.

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

* ♻️ refactor(conversation): replace per-role action hooks with declarative action registry

Replace the 4 duplicate per-role action hooks (useUserActions / useAssistantActions
/ useGroupActions / Task.useAssistantActions) and the 4 copies of
stripHandleClick / buildActionsMap / dispatch logic with a single registry +
universal MessageActionBar renderer.

Each action (copy / del / edit / regenerate / delAndRegenerate /
continueGeneration / translate / tts / share / collapse / branching) is now a
standalone module under components/MessageActionBar/actions/. Config is
declarative — string slot keys (e.g. ['copy', 'divider', 'del']) resolved
against the registry at render time.

Hetero-agent sessions drop the special mode flag; they just declare copy-only
slot lists via config. Dev-mode branching becomes a registry key instead of a
factory.

Deletes ErrorActionsBar (handled in-place via slot lists), the dead
Supervisor/Actions folder, and the HeteroActionsBar scaffold introduced in
the previous commit.

Net: -1900 lines, one place to add a new action.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 00:16:48 +08:00
Rdmclin2 7fe751eaec feat: billboard in sidebar (#13962)
* feat: support billboard

* feat: support BillBoard display

* fix: carousel dot style

* chore: adjust Anouncements copy

* feat: add annoucements animations

* feat: support  i18n and show less and more

* fix: notification copy

* chore: remove show less and show more

* feat:support Billboard title i18n

* fix: show billboard in time window

* feat: add  schema validation

* Potential fix for pull request finding 'Unused variable, import, function or class'

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

* Potential fix for pull request finding 'Unused variable, import, function or class'

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

* fix: test case

---------

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-19 00:00:34 +08:00
Arvin Xu f38dcc4cfc 🐛 fix(cc): persist workingDirectory when CC topic is created (#13956)
Hetero-agent topic creation went through `aiChat.sendMessageInServer`'s
`newTopic` payload, which had no metadata field, so the topic row was
inserted with `metadata.workingDirectory = NULL`. Today the only writer
is the post-execution `updateTopicMetadata` in `heterogeneousAgentExecutor`
— that never lands when CC is cancelled or errors before completion, and
in the meantime the topic is missed by By-Project grouping and `--resume`
cwd verification has nothing to compare against.

Source the cwd at the start of the hetero branch and thread it through
`newTopic.metadata`, so the binding is set at insert time. The post-exec
update still runs to record `ccSessionId` (and is now a no-op for cwd).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:08:15 +08:00
Arvin Xu 30e93ada67 ♻️ refactor(hetero-agent): rename ccSessionId to heteroSessionId (#13961)
CC-specific naming leaked into a field/module that's meant to be shared
across heterogeneous agent adapters. Rename to a provider-neutral id so
new adapters can reuse the topic-level session binding without inheriting
CC terminology.

- ChatTopicMetadata.ccSessionId -> heteroSessionId
- resolveCcResume / CcResumeDecision -> resolveHeteroResume / HeteroResumeDecision
- ccResume.{ts,test.ts} -> heteroResume.{ts,test.ts}
- updateTopicMetadata zod schema + executor + conversationLifecycle callsites

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:52:08 +08:00
Arvin Xu bc9164ae4a 🐛 fix(cmdk): scope topic/message search to current agent (#13960)
Previously `agentId` was only used to boost relevance in SearchRepo,
so results from other agents still leaked into CMD+K when scoped to
an agent. Strictly filter topics/messages by `agentId` when provided,
and surface the active agent (avatar + title) as the scope chip so
users can see what the search is limited to.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:41:32 +08:00
Innei e990b08cc6 ♻️ refactor(types): break circular dep between types and const packages (#13948)
* ♻️ refactor(types): break circular dep between types and const packages

Types package should only carry types, not values. Moved hotkey type
definitions to be owned by @lobechat/types and removed the @lobechat/const
runtime dependency from @lobechat/types. @lobechat/const now imports its
hotkey types from @lobechat/types via import type and uses satisfies to
keep enum values aligned.

*  feat(types): add desktop hotkey types and configuration

Introduced new types for desktop hotkeys, including `DesktopHotkeyId`, `DesktopHotkeyItem`, and `DesktopHotkeyConfig`. These types facilitate the management of hotkeys in the desktop application, ensuring better type safety and clarity in the codebase. Updated documentation to reflect the relationship with `@lobechat/const` entrypoints.

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

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-04-18 22:36:13 +08:00
Innei 5c82da7515 feat(onboarding): persist topic onboarding analytics snapshot (#13930)
*  feat(onboarding): persist topic onboarding analytics snapshot

* fix(onboarding): allow null in syncTopicOnboardingSession metadata option

Resolves TS2322 where topic?.metadata (ChatTopicMetadata | null | undefined)
was not assignable to metadata?: ChatTopicMetadata (undefined only).
The function already safely handles null via the ?? fallback, so widening
the parameter type is the minimal correct fix.

* fix(test): add ShikiLobeTheme to @lobehub/ui mock in WorkflowCollapse test

Resolves vitest error where @lobehub/editor tries to load
ShikiLobeTheme from the mocked module.
2026-04-18 22:08:56 +08:00
Arvin Xu 9218fbfcf3 💄 style(shared-tool-ui): wrap Bash inspector in a rounded chip (#13959)
💄 style(shared-tool-ui): wrap RunCommand inspector in a rounded chip

Put the terminal-prompt icon and the mono command text inside a single
pill-shaped chip (colorFillTertiary background) so the command reads as
one unit instead of two loose elements next to the "Bash:" label. Row
goes back to center-aligned since the chip has its own vertical padding.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:01:23 +08:00
Arvin Xu d581937196 feat(cc): account card, topic filter, and CC integration polish (#13955)
* 💄 style(error): refine error page layout and stack panel

Replace Collapse with Accordion for a clickable full-row header, move
stack below action buttons as a secondary branch, and wrap in a Block
that softens to filled when collapsed and outlined when expanded.

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

* 💄 style(cc): boost topic loading ring contrast in light mode

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

* 💄 style(error): reload page on retry instead of no-op navigate

The retry button called navigate(resetPath) which often landed on the
same path and re-triggered the same error, feeling broken. Switch to
window.location.reload() so the error page actually recovers, and drop
the now-unused resetPath prop across route configs.

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

* 🐛 fix(cc-agent): send prompt via stdin stream-json to avoid CLI arg parsing

Previously the Claude Code prompt was appended as a positional CLI arg,
so any prompt starting with `-` / `--` (dashes, 破折号) got
misinterpreted as a flag by the CC CLI's argparser.

Switch the claude-code preset to `--input-format stream-json` and write
the prompt as a newline-delimited JSON user message on stdin for all
messages (not just image-attached ones). Unifies the image and text
paths and paves the way for LOBE-7346 Phase 2 (persistent process +
native queue/interrupt).

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

* ♻️ refactor(cc): extract per-tool inspectors into Inspector/ folder

Mirrors the Inspector/<Tool>/index.tsx convention used by builtin-tool-skills,
builtin-tool-skill-store, and builtin-tool-activator.

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

* ♻️ refactor(cc): flatten Inspector/ to per-tool tsx files

Drop the per-tool subfolder wrapper (Inspector/Edit/index.tsx → Inspector/Edit.tsx)
since each tool is a single file — no co-located assets to justify the folder.

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

*  feat(topic): add filter with By project grouping and sort-by option

Split the legacy topicDisplayMode enum into independent topicGroupMode
(byTime / byProject / flat) and topicSortBy (createdAt / updatedAt), and
surface them from a new sidebar Filter dropdown. Adds groupTopicsByProject
so topics can be grouped by their workingDirectory, with favorites pinned
and the "no project" bucket placed last.

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

*  feat(cc): show Claude Code account and subscription on profile

Add a getClaudeAuthStatus IPC that shells out to claude auth status --json,
and render the returned email + subscription tag on the CC Status Card.
The auth fetch runs independently of tool detection so a failure can't
flip the CLI card to unavailable.

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

* 💄 style(home): show running spinner badge on agent/inbox avatars

Replace NavItem's generic loading state with a bottom-right spinner badge
on the avatar, so a running agent stays clearly labelled without hiding
the avatar. Inbox entries switch to per-agent isAgentRunning so only the
actively running inbox shows the badge.

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

* 💄 style(cc): default-expand Edit and Write tool renderers

Add ClaudeCodeApiName.Edit and Write to ClaudeCodeRenderDisplayControls
so their inspectors render expanded by default, matching TodoWrite.

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

* 🔧 chore(cc): drop default system prompt when creating Claude Code agent

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

* Update avatar URL for Claude Code

*  test(workflow-collapse): stub ShikiLobeTheme on @lobehub/ui mock

@lobehub/editor's init code reads ShikiLobeTheme from @lobehub/ui, which
some transitive import pulls in during the test. Add the stub to match
the pattern used in WorkingSidebar/index.test.tsx.

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

* 🐛 fix(cc): fall back to Desktop path instead of `/` when no cwd is set

- Selector prefers desktopPath over homePath before it resolves nothing,
  so the renderer always forwards a sensible cwd.
- Main-process spawn mirrors the same fallback with app.getPath('desktop'),
  covering cases where Electron is launched from Finder (parent cwd is `/`).

Fixes LOBE-7354

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

* 🐛 fix(topic): use remote app origin for topic copy link

Desktop 下 window.location.origin 是 app://renderer,复制出来的链接无法分享。
改用 useAppOrigin(),与分享链接保持一致(web 用 window.location.origin,
desktop 用 electron store 的 remoteServerUrl)。

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:58:50 +08:00
Innei 568389d43f ♻️ refactor(web-onboarding): rename doc tools and drive incremental persona writes (#13933)
* ♻️ refactor(web-onboarding): rename doc tools and drive incremental persona writes

- Rename writeDocument (full rewrite) and updateDocument (SEARCH/REPLACE patch) so tool
  names match model intuition; the old updateDocument (full) is now writeDocument and the
  old patchDocument (patch) is now updateDocument.
- Rework systemRole, toolSystemRole, and OnboardingActionHintInjector to require per-turn
  persistence: seed persona on user_identity, patch on every discovery turn where a new
  fact is learned, and stop the one-shot full-write pattern.
- Add a Pre-Finish Checklist so agents verify soul/persona reflect the session before
  calling finishOnboarding.

Eval (deepseek-chat, web-onboarding-v3):
- fe-intj-crud-v1: write=2, updateDocument=6/6 success
- extreme-minimal-response-v1: write=2, updateDocument=4/4 success
- Previously 0 patch usage; now patch dominates incremental edits.

* 🐛 fix(web-onboarding): decouple fullName persistence from role discovery

Persona seeding and saveUserQuestion(fullName) were gated on learning both
name AND role in the same turn, which regressed the prior behavior of saving
the name the moment it was provided. If the user shared only a name (or left
early before role was clarified), the agent could skip the save and end
onboarding with missing identity data.

Split the hint:
1. saveUserQuestion(fullName) fires as soon as the name is known, regardless
   of role.
2. Persona seeding fires on ANY useful fact (name alone, role alone, or both).

Thanks to codex review for catching this.
2026-04-18 20:02:39 +08:00
Arvin Xu 7d5889a7ed feat(heterogeneous-agent): git-aware runtime config + topic rename modal + inspectors (#13951)
*  feat(cc-desktop): git-aware runtime config + topic rename modal + inspectors

Cluster of desktop UX improvements around the Claude Code integration:

- CC chat input runtime bar: branch switcher, git status, and a richer
  working-directory bar powered by a new SystemCtr git API
  (branch list / current status) and `useGitInfo` hook.
- Topic rename: switch to a dedicated RenameModal component; add an
  auto-rename action in the conversation header menu.
- ToolSearch inspector for the CC tool client.
- Shared DotsLoading indicator.
- Operation slice tidy-ups for CC flows.

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

* ♻️ refactor(types): rename heterogeneous provider type `claudecode` → `claude-code`

Align the type literal with the npm/CLI naming convention used elsewhere
(@lobechat/builtin-tool-claude-code, claude-code provider id) so the union
matches the rest of the codebase.

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

* 💄 style(cc-desktop): polish TodoWrite labels, branch switcher refresh, and chat input affordances

- TodoWrite render + inspector: i18n the header label (Todos / Current step
  / All tasks completed), surface the active step inline as highlighted text,
  and switch the in-progress accent from primary to info for better contrast.
- BranchSwitcher: move the refresh button into the dropdown's section header,
  switch the search and create-branch inputs to the filled variant, and
  reuse DropdownMenuItem for the create-branch entry instead of a custom
  footer chip.
- GitStatus: drop the inline refresh affordance (now lives in the switcher),
  collapse trigger styles, and split the PR badge with its own separator.
- WorkingDirectory / WorkingDirectoryBar: tighten paddings and gaps so the
  runtime config row reads at a consistent height.
- InputEditor: skip inline placeholder completion when the cursor is not at
  end of paragraph — inserting a placeholder mid-text triggered nested
  editor updates that froze the input.

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

* 🐛 fix(cc-desktop): probe repoType for working dirs not cached in recents

GitStatus was gated on the `repoType` stored in `recentDirs`, but legacy
string entries and agent-config-driven paths that never went through the
folder picker have no cached `repoType`. As a result, branch / PR status
silently disappeared for valid git repos until users re-selected the
folder.

Promote `detectRepoType` to a public IPC method and add a `useRepoType`
hook that uses the cached value as a fast path, otherwise probes the
filesystem via SWR and backfills the recents entry so subsequent reads
hit cache. Both runtime config bars (CC mode + heterogeneous chat input)
now resolve `repoType` through the hook.

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

* 💄 style(shared-tool-ui): rework Bash/Grep/Glob inspector rows

- RunCommand: terminal-prompt icon + mono command text instead of underline highlight
- Grep: split pattern by `|` into mono tag chips
- Glob: single mono tag chip matching Grep
- Switch rows to baseline alignment so the smaller mono text lines up with the label

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

* 🐛 fix(DotsLoading): allow optional color in styles params

The Required<StyleArgs> generic forced color to string, but it's only
defaulted at the CSS level via fallback to token.colorTextSecondary.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:40:39 +08:00
Arvin Xu 5dc94cbc45 feat(cc-agent): improve for CC integration mode (#13950)
*  feat(cc-agent-profile): swap model/skills pickers for CC CLI status in CC mode

When an agent runs under the Claude Code heterogeneous runtime, its model and tools are
owned by the external CLI, so the profile page's model selector and integration-skills
block are misleading. Replace them with a card that re-detects `claude --version` on
mount and shows the resolved binary path — useful when CLAUDE_CODE_BIN or similar
points at a non-default CLI.

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

* 💄 style(cc-agent-profile): hide cron for CC agent and polish render previews

- Hide cron sidebar entry when current agent is heterogeneous (CC)
- Allow model avatar in agent header emoji picker
- Add padding to Glob/Grep/Read/Write preview boxes for consistent spacing
- Simplify NavPanelDraggable by removing slide animation layer

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

* ♻️ refactor(shared-tool-ui): extract ToolResultCard for Read/Write/Glob/Grep renders

Hoist the shared card shell (icon + header + preview box) into
@lobechat/shared-tool-ui/components so the four Claude Code Render
files no longer duplicate container/header/previewBox styles.

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

* 💄 style(agent-header): restyle title and expand actions menu

Bold the topic title, render the working directory as plain text (no chip/icon), move the "..." menu to the left, and expand it with pin/rename/copy working directory/copy session ID/delete. Fall back to "New Topic" when no topic is active.

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

* 💄 style(topic-list): replace spinning loader with ring-and-arc loading icon

Adds a reusable RingLoadingIcon (static track + rotating arc, mirroring the send-button style) and swaps the topic-item loader over to it so the loading state reads as a polished ring rather than a thin spinning dash.

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

* 💄 style(topic-list): switch unread indicator to a radar ping effect

Replaces the glowing neon-dot pulse with a smaller 6px core dot plus a CSS-keyframe ripple ring that scales out and fades, giving the unread marker a subtler, more refined cadence.

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

* 💄 style(cc-chat-input): drop file upload in CC mode, surface typo toggle

Claude Code brings its own file handling and knowledge context, so the
paperclip dropdown only showed "Upload Image" + a useless "View More"
link — confusing and not clean. Replace fileUpload with typo in the
heterogeneous chat input, and fold ServerMode back into a single
Upload/index.tsx now that the ClientMode/ServerMode split is 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-04-18 16:53:58 +08:00
Arvin Xu 13fe968480 feat: claude code intergration polish (#13942)
* 🐛 fix(cc-resume): guard resume against cwd mismatch (LOBE-7336)

Claude Code CLI stores sessions per-cwd under `~/.claude/projects/<encoded-cwd>/`,
so resuming a session from a different working directory fails with
"No conversation found with session ID". Persist the cwd alongside the session
id on each turn and skip `--resume` when the current cwd can't be verified
against the stored one, falling back to a fresh session plus a toast explaining
the reset.

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

*  feat(cc-desktop): Claude Code desktop polish + completion notifications

Bundles the follow-on UX improvements for Claude Code on desktop:

- Completion notifications: CC / Codex / ACP runs now fire a desktop
  notification (when the window is hidden) plus dock badge when the turn
  finishes, matching the Gateway client-mode behavior.
- Inspector + renders: add Skill and TodoWrite inspectors, wire them
  through Render/index + renders registry, expose shared displayControls.
- Adapter: extend claude-code adapter with additional event coverage and
  regression tests.
- Sidebar / home menu: clean up Topic list item and dropdown menu, rename
  "Claude Code Agent" entry point to "Add Claude Code" across EN/ZH.
- Assorted: NotificationCtr, Browser, WorkflowCollapse, ServerMode upload,
  agent/tool selectors — small follow-ups surfaced while building the
  above.

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

*  test(browser): mock electron.app for badge-clear on focus

Browser.focus handler now calls app.setBadgeCount / app.dock.setBadge to
clear the completion badge when the user returns. Tests imported the
Browser module without exposing app on the electron mock, causing a
module-load failure.

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

*  feat(cc-topic): folder chip + unify cwd into workingDirectory (#13949)

 feat(cc-topic): show bound folder chip and unify cwd into workingDirectory

Replace the separate `ccSessionCwd` metadata field with the existing
`workingDirectory` so a CC topic's bound cwd has one source of truth:
persisted on first CC execution, read back by resume validation, and
surfaced in a clickable folder chip next to the topic title on desktop.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 13:42:00 +08:00
Innei a98d113a80 feat: add full-expand toggle to WorkflowCollapse with three-level expansion (#13906)
*  feat: add full-expand toggle to WorkflowCollapse with three-level expansion

- Replace boolean expanded with expandLevel: 'collapsed' | 'semi' | 'full'
- Add cyclic toggle button in header (ChevronDown / Maximize2 / Minimize2)
- Keep max-height scroll constraint in semi mode, remove it in full mode
- Update tests for three-level states and toggle behavior

*  feat: enhance WorkflowCollapse with animated expand toggle and refined icon behavior

- Introduced animated transitions for the expand toggle button using `motion` from `framer-motion`.
- Updated expand toggle logic to improve user experience with clearer icon states.
- Removed unused `ChevronDown` icon and adjusted expand toggle label conditions.
- Added constants for toggle icon size and transition settings for better maintainability.

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

* test: fix WorkflowCollapse tests for animated toggle behavior

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-04-18 12:25:00 +08:00
Innei 55eb1509d2 Merge branch 'canary' into feat/onboarding-patch-preference 2026-04-18 12:03:50 +08:00
Innei 9a2ee8a58f feat(onboarding): add wrap-up button for agent onboarding (#13934)
Let users finish agent onboarding explicitly once they've engaged
enough, instead of waiting for the agent to trigger finishOnboarding.

- New WrapUpHint component above ChatInput; shows in summary phase or
  discovery phase after ≥3 user messages
- Confirm modal before finish; reuses existing finishOnboarding service
- Tightened Phase 2 (user_identity) system prompt: MUST save fullName
  before leaving phase, handle ambiguous name responses explicitly
2026-04-18 11:58:49 +08:00
LobeHub Bot 326ca352b1 🌐 chore: translate non-English comments to English in oidc-provider (#13945)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 11:41:26 +08:00
Junghwan 2c43f409d9 🐛 fix(desktop): sanitize heterogeneous-agent attachment cache filenames (#13937)
* Keep heterogeneous-agent attachment cache writes inside the cache root

The desktop heterogeneous-agent controller used raw image ids as path
segments for cache payload and metadata files. Path-like ids could
escape the intended cache directory, and pre-seeded traversal targets
could be treated as cache hits. Hashing the cache key removes any path
semantics from user-controlled ids while preserving stable cache reuse.
A regression test covers both out-of-root write prevention and ignoring
pre-seeded traversal cache files.

Constraint: The fix must preserve deterministic cache hits without trusting user-controlled path segments
Rejected: path.basename(image.id) | collapses distinct ids onto the same filename and leaves edge-case normalization concerns
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Any future cache layout change must keep user-controlled identifiers out of direct filesystem path composition
Tested: Custom local reproduction against current controller source; custom local validation against patched source; regression test added for desktop controller path handling
Not-tested: Upstream vitest/CI run in this workspace (desktop dependencies unavailable locally)

* Keep heterogeneous-agent cache regression aligned with runtime MIME behavior

The traversal regression test uses a data:text/plain URL under the desktop
node test environment, so the controller returns text/plain from the fetch
response headers. The expectation now matches the actual runtime behavior
instead of assuming the image/png fallback path.

Constraint: The regression should validate cache isolation rather than rely on an incorrect MIME fallback assumption
Rejected: Mock fetch in the regression test | adds extra indirection without improving the path traversal coverage
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep this test focused on path safety and cache-hit behavior; avoid coupling it to unrelated transport mocks unless the controller logic changes
Tested: Local patched-controller validation harness; static review against desktop vitest node environment behavior
Not-tested: Upstream vitest/CI run in this workspace (desktop dependencies unavailable locally)

* Keep heterogeneous-agent cache regression isolated to the temp test namespace

The first regression test used a fixed traversal target name under the shared
system temp directory. Switching that escape target to a unique name derived
from the test's temporary appStoragePath preserves the same out-of-root check
while avoiding accidental interaction with unrelated files under /tmp.

Constraint: The regression must still verify escape prevention beyond appStoragePath without touching shared fixed temp paths
Rejected: Remove the out-of-root assertion entirely | weakens coverage for the exact traversal behavior this PR is meant to guard
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep filesystem regressions hermetic; if a test needs to reason about escaped paths, derive them from per-test temp namespaces whenever possible
Tested: Static review of resolved path behavior before/after the change
Not-tested: Upstream vitest/CI run in this workspace (desktop dependencies unavailable locally)

---------

Co-authored-by: OpenAI Codex <codex@example.com>
2026-04-18 00:54:32 +08:00
YuTengjing 4d7ca56c21 🔨 chore: split test-app shards and deprecate isOnboarded (#13938) 2026-04-18 00:23:01 +08:00
Innei 4c5dbbece7 🐛 fix(web-onboarding): decouple fullName persistence from role discovery
Persona seeding and saveUserQuestion(fullName) were gated on learning both
name AND role in the same turn, which regressed the prior behavior of saving
the name the moment it was provided. If the user shared only a name (or left
early before role was clarified), the agent could skip the save and end
onboarding with missing identity data.

Split the hint:
1. saveUserQuestion(fullName) fires as soon as the name is known, regardless
   of role.
2. Persona seeding fires on ANY useful fact (name alone, role alone, or both).

Thanks to codex review for catching this.
2026-04-17 23:21:48 +08:00
Arvin Xu 80ae553f0f 🔨 chore: stream token-level deltas via --include-partial-messages (#13929)
 feat(cc-partial-messages): stream token-level deltas via --include-partial-messages

Enables Claude Code's --include-partial-messages flag so the CLI emits
token-level deltas wrapped in stream_event events. The adapter surfaces
these deltas as incremental stream_chunk events and suppresses the
trailing full-block emission from handleAssistant for any message.id
whose text/thinking has already been streamed.

Message-boundary handling is refactored into an idempotent
openMainMessage() helper so stepIndex advances on the first signal of a
new turn (delta or assistant), keeping deltas attached to the correct
step.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 23:12:47 +08:00
Arvin Xu 75b55edca1 feat: promote agent documents as primary workspace panel (#13924)
* ♻️ refactor: adopt Notebook list + EditorCanvas for agent documents

The agent working sidebar previously used a FileTree directory view and
a hand-rolled Markdown+TextArea editor with manual save. Agent documents
already back onto the canonical `documents` table via an FK, so they can
reuse the exact same rendering surface as Notebook.

- AgentDocumentsGroup: replace FileTree with a flat card list styled
  after Portal/Notebook/DocumentItem (icon + title + description + delete).
- AgentDocumentEditorPanel: drop the bespoke draft/save/segmented view
  logic; mount the shared <EditorCanvas documentId={doc.documentId}
  sourceType="notebook" /> inside an EditorProvider so auto-save and
  rich editing are handled by useDocumentStore.

*  feat: promote agent documents as the primary workspace panel

- Replace the agent-document sidebar with a Notebook-style list: pill
  filter (All/Docs/Web), per-item createdAt, globe icon for sourceType=web.
- Add a stable panel header "Resources" with a close button (small size,
  consistent with other chat header actions); no border divider.
- Wire clicks to the shared Portal Document view via openDocument(),
  retiring the inline AgentDocumentEditorPanel.
- Portal/Document/Header now resolves title directly from documentId
  via documentService.getDocumentById + a skeleton loading state.
- Portal top-right close icon switched to `X`.
- Layout: move AgentWorkingSidebar to the rightmost position; auto-collapse
  the left navigation sidebar while Portal is open (PortalAutoCollapse).
- Header: remove dead NotebookButton, drop the Notebook menu item; add a
  WorkingPanelToggle visible only when the working panel is collapsed.
- ProgressSection hides itself when the topic has no GTD todos.
- Builtin tool list removes Notebook; migrate CreateDocument Render and
  Streaming renderers to builtin-tool-agent-documents (notebook package
  kept for legacy rendering of historical tool calls).
- agent_documents list UI now reads from a separate SWR key
  (documentsList) so the agent-store context mapping doesn't strip
  documentId/sourceType/createdAt from the UI payload.
- i18n: add workingPanel.resources.filter.{all,documents,web},
  viewMode.{list,tree}, and the expanded empty-state copy; zh-CN
  translations seeded for preview.
- New local-testing reference: agent-browser-login (inject better-auth
  cookie for authenticated agent-browser sessions).

* update

* 🐛 fix: satisfy tsc strict i18next keys, remove duplicate getDocumentById, coerce showLeftPanel

* ♻️ refactor: graduate agent working panel out of labs
2026-04-17 23:04:59 +08:00
Innei 5a08e92603 ♻️ refactor(web-onboarding): rename doc tools and drive incremental persona writes
- Rename writeDocument (full rewrite) and updateDocument (SEARCH/REPLACE patch) so tool
  names match model intuition; the old updateDocument (full) is now writeDocument and the
  old patchDocument (patch) is now updateDocument.
- Rework systemRole, toolSystemRole, and OnboardingActionHintInjector to require per-turn
  persistence: seed persona on user_identity, patch on every discovery turn where a new
  fact is learned, and stop the one-shot full-write pattern.
- Add a Pre-Finish Checklist so agents verify soul/persona reflect the session before
  calling finishOnboarding.

Eval (deepseek-chat, web-onboarding-v3):
- fe-intj-crud-v1: write=2, updateDocument=6/6 success
- extreme-minimal-response-v1: write=2, updateDocument=4/4 success
- Previously 0 patch usage; now patch dominates incremental edits.
2026-04-17 22:34:04 +08:00
Arvin Xu 7981bab5bd 🐛 fix(auth): clear OIDC sessions when user signs out via better-auth (#13916)
🐛 fix(auth): clear current-browser OIDC session on sign-out

When a user signs out and signs back in as a different account,
the oidc-provider session cookie (_session) still references the
old accountId. The next /authorize silently reuses it, issuing
tokens for the wrong user.

Fix: add a POST /oidc/clear-session endpoint that:
1. Reads the _session cookie from the current request
2. Deletes the matching row in oidc_sessions (by primary key)
3. Expires the _session cookies in the response

The frontend logout action calls this endpoint *before* signOut()
while the better-auth session is still valid.

Only the current browser's OIDC session is affected — other
devices (desktop, CLI, mobile) keep their sessions intact.
2026-04-17 22:32:29 +08:00
Innei 03d2068a5d feat(onboarding): add feature flags and footer promotion pipeline (#13853)
*  feat(onboarding): enhance agent onboarding experience and add feature flags

- Added new promotional messages for agent onboarding in both Chinese and default locales.
- Updated HighlightNotification component to support action handling and target attributes.
- Introduced feature flags for agent onboarding in the configuration schema and tests.
- Implemented logic to conditionally display onboarding options based on feature flags and user state.
- Added tests for the onboarding flow and promotional notifications in the footer.

This update aims to improve the user experience during the onboarding process and ensure proper feature management through flags.

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

*  feat(home): add footer promotion pipeline with feature-flag gating

Extract resolveFooterPromotionState for agent onboarding vs Product Hunt promos.
Normalize isMobile boolean, refine HighlightNotification CTA layout, extend tests.

Made-with: Cursor

*  feat(locales): add agent onboarding promotional messages in multiple languages

Added new promotional messages for agent onboarding across various locales, enhancing the user experience with localized action labels, descriptions, and titles. This update supports a more engaging onboarding process for users globally.

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

* 💄 chore: refresh quick wizard onboarding promo

* 🐛 fix(chat): keep long mixed assistant content outside workflow fold

*  feat(onboarding): add agent onboarding feedback panel and service

LOBE-7210

Made-with: Cursor

*  feat(markdown-patch): add shared markdown patch tool with SEARCH/REPLACE hunks

Introduce @lobechat/markdown-patch util and expose patchDocument API on the
web-onboarding and agent-documents builtin tools so agents can apply
byte-exact SEARCH/REPLACE hunks instead of resending full document content.

*  feat(onboarding): prefer patchDocument for non-empty documents

Teach the onboarding agent (systemRole) and context engine
(OnboardingActionHintInjector) to prefer patchDocument over updateDocument
when SOUL.md or User Persona already has content, keeping updateDocument
reserved for the initial seed write or full rewrites.

* 🐛 fix(conversation): add rightActions to ChatInput component

Updated the AgentOnboardingConversation component to include rightActions in the ChatInput, enhancing the functionality of the onboarding conversation interface.

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

* Add specialized onboarding approval UI

* 🐛 fix(serverConfig): handle fetch errors in server config actions

Updated the server configuration action to include error handling for fetch failures, ensuring that the server config is marked as initialized when an error occurs. Additionally, modified the SWR mock to simulate error scenarios in tests.

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

* 🐛 fix(tests): update Group component tests with new data-testid attributes

Added data-testid attributes for workflow and answer segments in the Group component tests to improve test targeting. Adjusted the isFirstBlock property for consistency and ensured the component renders correctly with the provided props.

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

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-04-17 21:14:27 +08:00
Zhijie He d6a47531c6 💄 style: add qwen3.6-flash/plus & pixverse-c1 support (#13923)
style: add qwen3.6-flash/plus & pixverse-c1 support
2026-04-17 19:46:49 +08:00
Arvin Xu 2298ad8ce1 chore(heterogeneous-agent): integrate heterogeneous agents with claude code (#13754)
* ♻️ refactor(acp): move agent provider to agencyConfig + restore creation entry

- Move AgentProviderConfig from chatConfig to agencyConfig.heterogeneousProvider
- Rename type from 'acp' to 'claudecode' for clarity
- Restore Claude Code agent creation entry in sidebar + menu
- Prioritize heterogeneousProvider check over gateway mode in execution flow
- Remove ACP settings from AgentChat form (provider is set at creation time)
- Add getAgencyConfigById selector for cleaner access
- Use existing agent workingDirectory instead of duplicating in provider config

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

 feat(acp): defer terminal events + extract model/usage per turn

Three improvements to ACP stream handling:

1. Defer agent_runtime_end/error: Previously the adapter emitted terminal
   events from result.type directly into the Gateway handler. The handler
   immediately fires fetchAndReplaceMessages which reads stale DB state
   (before we persist final content/tools). Fix: intercept terminal events
   in the executor's event loop and forward them only AFTER content +
   metadata has been written to DB.

2. Extract model/usage per assistant event: Claude Code sets model name
   and token usage on every assistant event. Adapter now emits a
   'step_complete' event with phase='turn_metadata' carrying these.
   Executor accumulates input/output/cache tokens across turns and
   persists them onto the assistant message (model + metadata.totalTokens).

3. Missing final text fix: The accumulated assistant text was being
   written AFTER agent_runtime_end triggered fetchAndReplaceMessages,
   so the UI rendered stale (empty) content. Deferred terminals solve this.

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

🐛 fix(acp): eliminate orphan-tool warning flicker during streaming

Root cause:
LobeHub's conversation-flow parser (collectToolMessages) filters tool
messages by matching `tool_call_id` against `assistant.tools[].id`. The
previous flow created tool messages FIRST, then updated assistant.tools[],
which opened a brief window where the UI saw tool messages that had no
matching entry in the parent's tools array — rendering them as "orphan"
with a scary "请删除" warning to the user.

Fix:
Reorder persistNewToolCalls into three phases:
  1. Pre-register tool entries in assistant.tools[] (id only, no result_msg_id)
  2. Create the tool messages in DB (tool_call_id matches pre-registered ids)
  3. Back-fill result_msg_id and re-write assistant.tools[]

Between phase 1 and phase 3 the UI always sees consistent state: every
tool message in DB has a matching entry in the parent's tools array.

Verified: orphan count stays at 0 across all sampled timepoints during
streaming (vs 1+ before fix).

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

🐛 fix(acp): dedupe tool_use + capture tool_result + persist result_msg_id

Three critical fixes to ACP tool-call handling, discovered via live testing:

1. **tool_use dedupe** — Claude Code stream-json previously produced 15+
   duplicate tool messages per tool_call_id. The adapter now tracks emitted
   ids so each tool_use → exactly one tool message.

2. **tool_result content capture** — tool_result blocks live in
   `type: 'user'` events in Claude Code's stream-json, not in assistant
   events. The adapter now handles the 'user' event type and emits a new
   `tool_result` HeterogeneousAgentEvent which the executor consumes to
   call messageService.updateToolMessage() with the actual result content.
   Previously all tool messages had empty content.

3. **result_msg_id on assistant.tools[]** — LobeHub's parse() step links
   tool messages to their parent assistant turn via tools[].result_msg_id.
   Without it, the UI renders orphan-message warnings. The executor now
   captures the tool message id returned by messageService.createMessage
   and writes it back into the assistant.tools[] JSONB.

Also adds vitest config + 9 unit tests for the adapter covering lifecycle,
content mapping, and tool_result handling.

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

 feat(acp): integrate external AI agents via ACP protocol

Adds support for connecting external AI agents (Claude Code and future
agents like Codex, Kimi CLI) into LobeHub Desktop via a new heterogeneous
agent layer that adapts agent-specific protocols to the unified Gateway
event stream.

Architecture:
- New @lobechat/heterogeneous-agents package: pluggable adapters that
  convert agent-specific outputs to AgentStreamEvent
- AcpCtr (Electron main): agent-agnostic process manager with CLI
  presets registry, broadcasts raw stdout lines to renderer
- acpExecutor (renderer): subscribes to broadcasts, runs events through
  adapter, feeds into existing createGatewayEventHandler
- Tool call persistence: creates role='tool' messages via messageService
  before emitting tool_start/tool_end to the handler

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

* ♻️ refactor: rename acpExecutor to heterogeneousAgentExecutor

- Rename file acpExecutor.ts → heterogeneousAgentExecutor.ts
- Rename ACPExecutorParams → HeterogeneousAgentExecutorParams
- Rename executeACPAgent → executeHeterogeneousAgent
- Change operation type from execAgentRuntime to execHeterogeneousAgent
- Change operation label to "Heterogeneous Agent Execution"
- Change error type from ACPError to HeterogeneousAgentError
- Rename acpData/acpContext variables to heteroData/heteroContext

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

* ♻️ refactor: rename AcpCtr and acp service to heterogeneousAgent

Desktop side:
- AcpCtr.ts → HeterogeneousAgentCtr.ts
- groupName 'acp' → 'heterogeneousAgent'
- IPC channels: acpRawLine → heteroAgentRawLine, etc.

Renderer side:
- services/electron/acp.ts → heterogeneousAgent.ts
- ACPService → HeterogeneousAgentService
- acpService → heterogeneousAgentService
- Update all IPC channel references in executor

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

* 🔧 chore: switch CC permission mode to bypassPermissions

Use bypassPermissions to allow Bash and other tool execution.
Previously acceptEdits only allowed file edits, causing Bash tool
calls to fail during CC execution.

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

* 🐛 fix: don't fallback activeAgentId to empty string in AgentIdSync

Empty string '' causes chat store to have a truthy but invalid
activeAgentId, breaking message routing. Pass undefined instead.

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

* 🐛 fix: use AI_RUNTIME_OPERATION_TYPES for loading and cancel states

stopGenerateMessage and cancelOperation were hardcoding
['execAgentRuntime', 'execServerAgentRuntime'], missing
execHeterogeneousAgent. This caused:
- CC execution couldn't be cancelled via stop button
- isAborting flag wasn't set for heterogeneous agent operations

Now uses AI_RUNTIME_OPERATION_TYPES constant everywhere to ensure
all AI runtime operation types are handled consistently.

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

*  feat: split multi-step CC execution into separate assistant messages

Claude Code's multi-turn execution (thinking → tool → final text) was
accumulating everything onto a single assistant message, causing the
final text response to appear inside the tool call message.

Changes:
- ClaudeCodeAdapter: detect message.id changes and emit stream_end +
  stream_start with newStep flag at step boundaries
- heterogeneousAgentExecutor: on newStep stream_start, persist previous
  step's content, create a new assistant message, reset accumulators,
  and forward the new message ID to the gateway handler

This ensures each LLM turn gets its own assistant message, matching
how Gateway mode handles multi-step agent execution.

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

* 🐛 fix: fix multi-step CC execution and add DB persistence tests

Adapter fixes:
- Fix false step boundary on first assistant after init (ghost empty message)

Executor fixes:
- Fix parentId chain: new-step assistant points to last tool message
- Fix content contamination: sync snapshot of content accumulators on step boundary
- Fix type errors (import path, ChatToolPayload casts, sessionId guard)

Tests:
- Add ClaudeCodeAdapter unit tests (multi-step, usage, flush, edge cases)
- Add ClaudeCodeAdapter E2E test (full multi-step session simulation)
- Add registry tests
- Add executor DB persistence tests covering:
  - Tool 3-phase write (pre-register → create → backfill)
  - Tool result content + error persistence
  - Multi-step parentId chain (assistant → tool → assistant)
  - Final content/reasoning/model/usage writes
  - Sync snapshot preventing cross-step contamination
  - Error handling with partial content persistence
  - Full multi-step E2E (Read → Write → text)

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

* 🔧 chore: add orphan tool regression tests and debug trace

- Add orphan tool regression tests for multi-turn tool execution
- Add __HETERO_AGENT_TRACE debug instrumentation for event flow capture

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

*  feat: support image attachments in CC via stream-json stdin

- Main process downloads files by ID from cloud (GET {domain}/f/{fileId})
- Local disk cache at lobehub-storage/heteroAgent/files/ (by fileId)
- When fileIds present, switches to --input-format stream-json + stdin pipe
- Constructs user message with text + image content blocks (base64)
- Pass fileIds through executor → service → IPC → controller

Closes LOBE-7254

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

* ♻️ refactor: pass imageList instead of fileIds for CC vision support

- Use imageList (with url) instead of fileIds — Main downloads from URL directly
- Cache by image id at lobehub-storage/heteroAgent/files/
- Only images (not arbitrary files) are sent to CC via stream-json stdin

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

* 🐛 fix: read imageList from persisted DB message instead of chatUploadFileList

chatUploadFileList is cleared after sendMessageInServer, so tempImages
was empty by the time the executor ran. Now reads imageList from the
persisted user message in heteroData.messages instead.

Also removes debug console.log/console.error statements.

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

* update i18n

* 🐛 fix: prevent orphan tool UI by deferring handler events during step transition

Root cause: when a CC step boundary occurs, the adapter produces
[stream_end, stream_start(newStep), stream_chunk(tools_calling)] in one batch.
The executor deferred stream_start via persistQueue but forwarded stream_chunk
synchronously — handler received tools_calling BEFORE stream_start, dispatching
tools to the OLD assistant message → UI showed orphan tool warning.

Fix: add pendingStepTransition flag that defers ALL handler-bound events through
persistQueue until stream_start is forwarded, guaranteeing correct event ordering.

Also adds:
- Minimal regression test in gatewayEventHandler confirming correct ordering
- Multi-tool per turn regression test from real LOBE-7240 trace
- Data-driven regression replaying 133 real CC events from regression.json

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

*  feat: add lab toggle for heterogeneous agent (Claude Code)

- Add enableHeterogeneousAgent to UserLabSchema + defaults (off by default)
- Add selector + settings UI toggle (desktop only)
- Gate "Claude Code Agent" sidebar menu item behind the lab setting
- Remove regression.json (no longer needed)
- Add i18n keys for the lab feature

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

* 🐛 fix: gate heterogeneous agent execution behind isDesktop check

Without this, web users with an agent that has heterogeneousProvider
config would hit the CC execution path and fail (no Electron IPC).

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

* ♻️ refactor: rename tool identifier from acp-agent to claude-code

Also update operation label to "External agent running".

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

*  feat: add CLI agent detectors for system tools settings

Detect agentic coding CLIs installed on the system:
- Claude Code, Codex, Gemini CLI, Qwen Code, Kimi CLI, Aider
- Uses validated detection (which + --version keyword matching)
- New "CLI Agents" category in System Tools settings
- i18n for en-US and zh-CN

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

* 🐛 fix: fix token usage over-counting in CC execution

Two bugs fixed:

1. Adapter: same message.id emitted duplicate step_complete(turn_metadata)
   for each content block (thinking/text/tool_use) — all carry identical
   usage. Now deduped by message.id, only emits once per turn.

2. Executor: CC result event contains authoritative session-wide usage
   totals but was ignored. Now adapter emits step_complete(result_usage)
   from the result event, executor uses it to override accumulated values.

Fixes LOBE-7261

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

* 🔧 chore: gitignore cc-stream.json and .heterogeneous-tracing/

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

* 🔧 chore: untrack .heerogeneous-tracing/

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

*  feat: wire CC session resume for multi-turn conversations

Reads `ccSessionId` from topic metadata and passes it as `resumeSessionId`
into the heterogeneous-agent executor, which forwards it into the Electron
main-process controller. `sendPrompt` then appends `--resume <id>` so the
next turn continues the same Claude Code session instead of starting fresh.
After each run, the CC init-event session_id (captured by the adapter) is
persisted back onto the topic so the chain survives page reloads.

Also stops killing the session in `finally` — it needs to stay alive for
subsequent turns; cleanup happens on topic deletion or app quit.

* 🐛 fix: record cache token breakdown in CC execution metadata

The prior token-usage fix only wrote totals — `inputCachedTokens`,
`inputWriteCacheTokens` and `inputCacheMissTokens` were dropped, so the
pricing card rendered zero cached/write-cache tokens even though CC had
reported them. Map the accumulated Anthropic-shape usage to the same
breakdown the anthropic usage converter emits, so CC turns display
consistently with Gateway turns.

Refs LOBE-7261

* ♻️ refactor: write CC usage under metadata.usage instead of flat fields

Flat `inputCachedTokens / totalInputTokens / ...` on `MessageMetadata` are
the legacy shape; new code should put usage under `metadata.usage`. Move
the CC executor to the nested shape so it matches the convention the rest
of the runtime is migrating to.

Refs LOBE-7261

* ♻️ refactor(types): mark flat usage fields on MessageMetadata as deprecated

Stop extending `ModelUsage` and redeclare each token field inline with a
`@deprecated` JSDoc pointing to `metadata.usage` (nested). Existing readers
still type-check, but IDEs now surface the deprecation so writers migrate
to the nested shape.

* ♻️ refactor(types): mark flat performance fields on MessageMetadata as deprecated

Stop extending `ModelPerformance` and redeclare `duration` / `latency` /
`tps` / `ttft` inline with `@deprecated`, pointing at `metadata.performance`.
Mirrors the same treatment just done for the token usage fields.

*  feat: CC agent gets claude avatar + lands on chat page directly

Skip the shared createAgent hook's /profile redirect for the Claude Code
variant — its config is fixed so the profile editor would be noise — and
preseed the Claude avatar from @lobehub/icons-static-avatar so new CC
agents aren't blank.

* 🐛 fix(conversation-flow): read usage/performance from nested metadata

`splitMetadata` only scraped the legacy flat token/perf fields, so messages
written under the new canonical shape (`metadata.usage`, `metadata.performance`)
never populated `UIChatMessage.usage` and the Extras panel rendered blank.

- Prefer nested `metadata.usage` / `metadata.performance` when present; keep
  flat scraping as fallback for pre-migration rows.
- Add `usage` / `performance` to FlatListBuilder's filter sets so the nested
  blobs don't leak into `otherMetadata`.
- Drop the stale `usage! || metadata` fallback in the Assistant / CouncilMember
  Extra renders — with splitMetadata fixed, `item.usage` is always populated
  when usage data exists, and passing raw metadata as ModelUsage is wrong now
  that the flat fields are gone.

* 🐛 fix: skip stores.reset on initial dataSyncConfig hydration

`useDataSyncConfig`'s SWR onSuccess called `refreshUserData` (which runs
`stores.reset()`) whenever the freshly-fetched config didn't deep-equal the
hard-coded initial `{ storageMode: 'cloud' }` — which happens on every
first load. The reset would wipe `chat.activeAgentId` just after
`AgentIdSync` set it from the URL, and because `AgentIdSync`'s sync
effects are keyed on `params.aid` (which hasn't changed), they never re-fire
to restore it. Result: topic SWR saw `activeAgentId === ''`, treated the
container as invalid, and left the sidebar stuck on the loading skeleton.

Gate the reset on `isInitRemoteServerConfig` so it only runs when the user
actually switches sync modes, not on the first hydration.

*  feat(claude-code): wire Inspector layer for CC tool calls

Mirrors local-system: each CC tool now has an inspector rendered above the
tool-call output instead of an opaque default row.

- `Inspector.tsx` — registry that passes the CC tool name itself as the
  shared factories' `translationKey`. react-i18next's missing-key fallback
  surfaces the literal name (Bash / Edit / Glob / Grep / Read / Write), so
  we don't add CC-specific entries to the plugin locale.
- `ReadInspector.tsx` / `WriteInspector.tsx` — thin adapters that map
  Anthropic-native args (`file_path` / `offset` / `limit`) onto the shared
  inspectors' shape (`path` / `startLine` / `endLine`), so shared stays
  pure. Bash / Edit / Glob / Grep reuse shared factories directly.
- Register `ClaudeCodeInspectors` under `claude-code` in the builtin-tools
  inspector dispatch.

Also drops the redundant `Render/Bash/index.tsx` wrapper and pipes the
shared `RunCommandRender` straight into the registry.

* ♻️ refactor: use agentSelectors.isCurrentAgentHeterogeneous

Two callsites (ConversationArea / useActionsBarConfig) were reaching into
`currentAgentConfig(...)?.agencyConfig?.heterogeneousProvider` inline.
Switch them to the existing `isCurrentAgentHeterogeneous` selector so the
predicate lives in one place.

* update

* ♻️ refactor: drop no-op useCallback wrapper in AgentChat form

`handleFinish` just called `updateConfig(values)` with no extra logic; the
zustand action is already a stable reference so the wrapper added no
memoization value. Leftover from the ACP refactor (930ba41fe3) where the
handler once did more work — hand the action straight to `onFinish`.

* update

*  revert: roll back conversation-flow nested-shape reads

Unwind the `splitMetadata` nested-preference + `FlatListBuilder` filter
additions from 306fd6561f. The nested `metadata.usage` / `metadata.performance`
promotion now happens in `parse.ts` (and a `?? metadata?.usage` fallback at
the UI callsites), so conversation-flow's transformer layer goes back to
its original flat-field-only behavior.

* update

* 🐛 fix(cc): wire Stop to cancel the external Claude Code process

Previously hitting Stop only flipped the `execHeterogeneousAgent` operation
to `cancelled` in the store — the spawned `claude -p` process kept
running and kept streaming/persisting output for the user. The op's abort
signal had no listeners and no `onCancelHandler` was registered.

- On session start, register an `onCancelHandler` that calls
  `heterogeneousAgentService.cancelSession(sessionId)` (SIGINT to the CLI).
- Read the op's `abortController.signal` and short-circuit `onRawLine` so
  late events the CLI emits between SIGINT and exit don't leak into DB
  writes.
- Skip the error-event forward in `onError` / the outer catch when the
  abort came from the user, so the UI doesn't surface a misleading error
  toast on top of the already-cancelled operation.

Verified end-to-end: prompt that runs a long sequence of Reads → click
Stop → `claude -p` process is gone within 2s, op status = cancelled, no
error message written to the conversation.

*  feat(sidebar): mark heterogeneous agents with an "External" tag

Pipes the agent's `agencyConfig.heterogeneousProvider.type` through the
sidebar data flow and renders a `<Tag>` next to the title for any agent
driven by an external CLI runtime (Claude Code today, more later). Mirrors
the group-member External pattern so future provider types just need a
label swap — the field is a string, not a boolean.

- `SidebarAgentItem.heterogeneousType?: string | null` on the shared type
- `HomeRepository.getSidebarAgentList` selects `agents.agencyConfig` and
  derives the field via `cleanObject`
- `AgentItem` shows `<Tag>{t('group.profile.external')}</Tag>` when the
  field is present

Verified client-side by injecting `heterogeneousType: 'claudecode'` into
a sidebar item at runtime — the "外部" tag renders next to the title in
the zh-CN locale.

* ♻️ refactor(i18n): dedicated key for the sidebar external-agent tag

Instead of reusing `group.profile.external` (which is about group members
that are user-linked rather than virtual), add `agentSidebar.externalTag`
specifically for the heterogeneous-runtime tag. Keeps the two concepts
separate so we can swap this one to "Claude Code" / provider-specific
labels later without touching the group UI copy.

Remember to run `pnpm i18n` before the PR so the remaining locales pick
up the new key.

* 🐛 fix: clear remaining CI type errors

Three small fixes so `tsgo --noEmit` exits clean:

- `AgentIdSync`: `useChatStoreUpdater` is typed off the chat-store key, whose
  `activeAgentId` is `string` (initial ''). Coerce the optional URL param to
  `''` so the store key type matches; `createStoreUpdater` still skips the
  setState when the value is undefined-ish.
- `heterogeneousAgentExecutor.test.ts`: `scope: 'session'` isn't a valid
  `MessageMapScope` (the union dropped that variant); switch the fixture to
  `'main'`, which is the correct scope for agent main conversations.
- Same test file: `Array.at(-1)` is `T | undefined`; non-null assert since
  the preceding calls guarantee the slot is populated.

* 🐛 fix: loosen createStoreUpdater signature to accept nullable values

Upstream `createStoreUpdater` types `value` as exactly `T[Key]`, so any
call site feeding an optional source (URL param, selector that may return
undefined) fails type-check — even though the runtime already guards
`typeof value !== 'undefined'` and no-ops in that case.

Wrap it once in `store/utils/createStoreUpdater.ts` with a `T[Key] | null
| undefined` value type so callers can pass `params.aid` directly, instead
of the lossy `?? ''` fallback the previous commit used (which would have
written an empty-string sentinel into the chat store).

Swap the import in `AgentIdSync.tsx`.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 19:33:39 +08:00
Neko 3fb6b0d8e1 🐛 fix(app): right panel should use stableLayout, bump @lobehub/ui to 5.9.0 (#13920)
🐛 fix(app): right panel should use stableLayout, bump @lobehub/ui to 5.9.0
2026-04-17 19:11:45 +08:00
Arvin Xu 34b60e1842 🔨 chore: return full brief data in task activities (#13914)
*  feat: return full brief data in task activities (LOBE-7266)

The activity feed for tasks previously emitted a stripped `brief` row that
concatenated `resolvedAction` and `resolvedComment` and omitted everything
BriefCard needs (taskId, topicId, agentId, cronJobId, agents, actions,
artifacts, readAt, resolvedAt, etc.). Map the full `BriefItem` into each
activity row and reuse `BriefService.enrichBriefsWithAgents` to populate
the participant avatars. The CLI and prompt formatter now compose the
action + comment display string themselves.

* 🐛 fix: degrade gracefully when brief agent enrichment fails

getTaskDetail was calling BriefService.enrichBriefsWithAgents inside
Promise.all without a fallback, so a failure in the agent-tree lookup
would reject the whole request — a regression vs. the existing
.catch(() => []) pattern used by other activity reads in this method.
Fall back to agentless briefs on error so the task detail keeps
rendering.
2026-04-17 19:10:48 +08:00
LiJian 828175f8f0 🐛 fix: add the lost tools into manual agent runtime mode (#13918)
* fix: slove the manual mode cant use some builtin tools

* refactor: change the active skill tools from lobe-activtor to  lobe-skill tools

* fix: only inject the avaiable skill when use the auto mode

* fix: update the desktop tools skill

* fix: add the some test to ensure the builin tools will use in manual mode
2026-04-17 17:02:53 +08:00
Arvin Xu 316349ea06 💄 style: remove 'Management' from API Key tab title (#13919)
fix: remove 'Management' from API Key tab title
2026-04-17 16:30:35 +08:00
Innei 2f4fbd35d4 🐛 fix: show success status for tool calls with no return value (#13905)
* 🐛 fix: show success status for tool calls with no return value

When a tool call completes without returning content, the status indicator
was incorrectly showing a loading spinner instead of a success checkmark.
This fix passes the isToolCalling operation state to StatusIndicator to
correctly determine when a tool has finished executing.

https://claude.ai/code/session_01EBaKqzVTeEmrUXgFdNk7WH

* 🐛 fix(conversation): improve tool execution status handling

Updated the logic for determining tool execution states in both the Tool and Inspector components. The changes ensure that the status indicator accurately reflects when a tool is actively processing, even if no result is returned. This prevents misleading loading indicators and enhances user experience during tool interactions.

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

* 🐛 fix(DocumentHistoryDiff): correct JSX syntax for CircleLoading component

Removed unnecessary semicolon from CircleLoading component in DocumentHistoryDiff to ensure proper rendering. This minor fix enhances code clarity and maintains JSX standards.

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

* 🐛 fix(ModeSwitch.test): refactor tests to improve readability and performance

Updated the ModeSwitch test suite by removing unnecessary async/await patterns, simplifying the mock configuration, and ensuring consistent cleanup after each test. These changes enhance the clarity and efficiency of the test cases for the onboarding mode switch functionality.

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

---------

Signed-off-by: Innei <tukon479@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-17 16:12:35 +08:00
Innei 669cb98c3d 🐛 fix(conversation): restore markdown animation for first assistant group block (#13904)
Made-with: Cursor
2026-04-17 14:46:58 +08:00
LiJian 2824c826bd 🐛 fix: should inject the user Locals Language into systemRole (#13911)
* fix: should inject the user Locals Language into systemRole

* fix: slove the ts

* fix: update the snapshot test

* fix: update the test.ts

* fix: test fixed
2026-04-17 14:12:37 +08:00
YuTengjing d658daa95d 🐛 fix: strip temperature/top_p for Claude Opus 4.7 (#13909) 2026-04-17 11:47:22 +08:00
YuTengjing d707f60365 feat: add Claude Opus 4.7 with xhigh effort tier (#13903) 2026-04-17 02:55:02 +08:00
Arvin Xu 91428ea0d2 🔨 chore: persist ccSessionId in topic metadata for CC multi-turn resume (#13902)
🐛 fix: persist ccSessionId in topic metadata for CC multi-turn resume

The renderer writes `ccSessionId` to topic metadata after each Claude Code
execution so the next turn can spawn `claude --resume <id>`, but the server
zod schema on `updateTopicMetadata` didn't list `ccSessionId`, so zod silently
stripped it — every turn started a fresh CC session and lost prior context.
2026-04-17 01:50:23 +08:00
LobeHub Bot 3471d2bf74 🚀 release: sync main branch to canary (#13900)
* 🔖 chore(release): release version v2.1.50 [skip ci]

* 📝 docs: Update changelog docs and release skills (#13897) 

* 🔨 chore: update .vscode/settings.json (#13894)

* 🐛 fix(builtin-tool-local-system): honor glob scope in local system tool (#13875)

Made-with: Cursor

* 📝 docs: Update changelog docs and release skills (#13897)

- Update changelog documentation format across all historical changelog files
- Merge release-changelog-style skill into version-release skill
- Update changelog examples with improved formatting and structure

Made-with: Cursor

---------

Co-authored-by: YuTengjing <ytj2713151713@gmail.com>
Co-authored-by: Innei <i@innei.in>

* 🐛 fix: resolve merge conflicts in sync main to canary

Restore canary versions of skill docs that were overwritten during
main-to-canary sync, keeping #13899 improvements intact.

---------

Co-authored-by: CanisMinor <i@canisminor.cc>
Co-authored-by: YuTengjing <ytj2713151713@gmail.com>
Co-authored-by: Innei <i@innei.in>
Co-authored-by: Innei <tukon479@gmail.com>
2026-04-17 00:35:29 +08:00
Innei d2197f4c30 ♻️ refactor(desktop): consolidate global shortcuts (LOBE-7181) (#13880)
* ♻️ refactor(desktop): consolidate global shortcuts and remove default showApp hotkey

- Add desktopGlobalShortcuts.ts as single source for Electron + renderer defaults
- Wire ShortcutManager and store to DEFAULT_ELECTRON_DESKTOP_SHORTCUTS
- Use DesktopHotkeyId for @shortcut; drop local shortcuts barrel
- Stop re-exporting DESKTOP_HOTKEYS_REGISTRATION from hotkeys

Fixes LOBE-7181

Made-with: Cursor

*  feat(desktop): introduce new stubs for business constants and types

- Added `@lobechat/business-const` and `@lobechat/types` packages to support workspace dependency resolution.
- Updated `package.json` and `pnpm-workspace.yaml` to include new stubs.
- Refactored imports in `index.ts` to utilize the new constants structure.
- Enhanced `desktopGlobalShortcuts.ts` with improved type definitions for hotkeys.

This change streamlines the management of constants and types across the desktop application.

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

* ♻️ refactor(hotkeys): consolidate desktop global shortcut definitions (LOBE-7181)

Made-with: Cursor

*  feat(session, user): replace direct type imports with constants

- Updated session.ts to use constants for session types instead of direct imports from @lobechat/types.
- Updated user.ts to use a constant for the default topic display mode, enhancing consistency and maintainability.

This change improves code clarity and reduces dependencies on external type definitions.

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

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-04-17 00:32:05 +08:00
Innei 35558cbea1 🐛 fix(desktop): prevent invalid proxy toggle saves (#13850)
* 🐛 fix(desktop): prevent invalid proxy toggle saves

* 🩹 fix: close proxy form ci gaps

*  style: enhance SaveBar component with updated styles and improved color variables

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

* 🩹 fix(test): increase ProxyForm test timeout and add explicit delay: null

CI runs with coverage instrumentation cause these form-interaction
tests to take ~4–6s each, exceeding the default 5000ms timeout.
Increase describe timeout to 10000ms and add { delay: null } to
all user.type() calls to keep them stable under coverage.

* 🩹 fix(test): resolve ProxyForm test type errors with user-event v14

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-04-17 00:30:50 +08:00
Neko fef6ed122a 🐛 fix(app): collapse button of agent working panel should be clickable (#13884) 2026-04-17 00:29:22 +08:00
Innei f1d615fa9f feat(document): add history management and compare workflow (#13725)
* Add document history versioning and TRPC APIs

* 🩹 Improve document history patching for rekeyed editor nodes

* Refine PageEditor history timeline UI

* Enhance modal API documentation and update modal implementation guidelines. Introduce new modal components and migration notes for transitioning from legacy `@lobehub/ui` to `@lobehub/ui/base-ui`. Update version history localization for improved clarity in UI. Add new CompareModal components for document history comparison.

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

* 🔥 chore(docs): remove document history tech spec

Made-with: Cursor

* Enhance document history management by introducing a 30-day limit for history queries and updating related APIs. Refactor history service methods to support new options for filtering history based on the saved date. Improve UI elements in the PageEditor history timeline for better user experience.

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

* Add document history management features and improve API integration

- Introduced constants for document history retention and limits.
- Updated document history service to compact history based on new retention limits.
- Refactored PageEditor to utilize constants for document history limits.
- Added new TRPC router for document history management.
- Enhanced JSON diffing capabilities for better patching of document history.

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

* ♻️ refactor: sync document history schema and simplify history service

- Sync simplified document_history table from feat/document-history-db

- Remove version/storage_kind/payload/base_version, use editor_data + saved_at

- Rewrite pagination with composite (savedAt, id) cursor

- Update TRPC APIs from version-based to historyId-based

- Replace DocumentVersionControl with AutoSaveHint

- Add integration tests for history service

*  feat: add per-source document history retention limits

- autosave / manual: retain 20 entries each

- restore / system: retain 5 entries each

- trimHistoryBySource now deletes in batches of 100 to avoid unbounded overflow

- removed obsolete constants: PATCH_THRESHOLD, RETENTION_LIMIT, SNAPSHOT_INTERVAL

- added integration tests for large overflow trimming

*  add llm_call history source and queue-based snapshot for page agent

* 💄 restyle document history list to Notion timeline

* 💄 fix history timeline alignment, unify fonts and highlight current

*  feat(PageEditor): refine document history compare UI and date formatting

Made-with: Cursor

*  feat(editor): add validation for editor data and update related interfaces

- Introduced `isValidEditorData` function to validate editor data structure.
- Updated `GetHistoryItemOutput` and `DocumentHistoryItemResult` interfaces to allow `editorData` to be `null`.
- Modified `getDocumentEditorData` to return `null` for invalid editor data.
- Added integration tests to ensure proper handling of invalid editor data in document history service.
- Enhanced editor actions to prevent saving of invalid editor data.

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

* 💾 chore(database): split document history indexes

* Fix manual saves and optimize history item rendering

* 🌐 locale: add missing llm_call translation key in en-US file.json

Add pageEditor.history.saveSource.llm_call = \"AI Edit\" to match
the default locale and prevent raw i18n key from showing in the
history panel.

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-04-16 23:24:28 +08:00
CanisMinor 29734eec23 📝 docs: update release changelog skills (#13899)
docs: add release changelog skills
2026-04-16 23:14:00 +08:00
Arvin Xu c046d042f5 feat: associate web crawl documents with agent documents (#13893)
*  feat: associate web crawl documents with agent documents

- Add `associate` method to AgentDocumentModel for linking existing documents
- Add `associateDocument` to AgentDocumentsService, TRPC router, and client service
- Update web browsing executor to associate crawled pages with agent after notebook save
- Add server-side crawl-to-agent-document persistence in webBrowsing runtime
- Add `findOrCreateFolder` to DocumentModel for folder hierarchy support
- Extract `DOCUMENT_FOLDER_TYPE` constant from hardcoded 'custom/folder' strings
- Add tests for associate, findOrCreateFolder, and service layer

Fixes LOBE-7242

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

* 🐛 fix: log errors in web crawl agent document association

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

* ♻️ refactor: add onCrawlComplete callback to WebBrowsingExecutionRuntime

Replace monkey-patching of crawlMultiPages with a proper onCrawlComplete
callback in the runtime constructor options.

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

* ♻️ refactor: move document save logic into WebBrowsingExecutionRuntime

Replace onCrawlComplete callback with documentService dependency injection.
The runtime now directly handles createDocument + associateDocument internally.

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

* ♻️ refactor: pass per-call context to documentService via crawlMultiPages

Add WebBrowsingDocumentContext (topicId, agentId) as a parameter to
crawlMultiPages, which flows through to documentService methods. This
allows a singleton runtime with per-call context on the client side.

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

* 🐛 fix: enforce document ownership in associate and match root folders by null parentId

- associate: verify documentId belongs to current user before creating link
- findOrCreateFolder: add parentId IS NULL condition for root-level lookup

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 23:11:21 +08:00
Neko 13d1b011b7 🐛 fix(app): include working panel into Lab feature, minor fixes (#13889)
* 🐛 fix(app): include working panel into Lab feature, minor fixes

* 🐛 fix(app): conditional disabled.
2026-04-16 23:05:33 +08:00
CanisMinor df524103e4 📝 docs: Update changelog docs and release skills (#13897)
- Update changelog documentation format across all historical changelog files
- Merge release-changelog-style skill into version-release skill
- Update changelog examples with improved formatting and structure

Made-with: Cursor
2026-04-16 22:22:35 +08:00
Innei e487bcd8a1 🐛 fix(builtin-tool-local-system): honor glob scope in local system tool (#13875)
Made-with: Cursor
2026-04-16 22:09:38 +08:00
YuTengjing dfc6000ecd 🔨 chore: update .vscode/settings.json (#13894) 2026-04-16 21:07:05 +08:00
555 changed files with 25623 additions and 4919 deletions
+155
View File
@@ -0,0 +1,155 @@
---
name: docs-changelog
description: 'Writing guide for website changelog pages under docs/changelog/*.mdx. Use when creating or editing product update posts in EN/ZH. Not for GitHub Release notes.'
---
# Docs Changelog Writing Guide
## Scope Boundary (Important)
This skill is only for changelog pages in:
- `docs/changelog/*.mdx`
This skill is **not** for GitHub Releases.\
If the user asks for release PR body / GitHub Release notes, load `../version-release/SKILL.md`.
## Mandatory Companion Skills
For every docs changelog task, you MUST load:
- `../microcopy/SKILL.md`
- `../i18n/SKILL.md` (when EN/ZH pair is involved)
## File and Naming Convention
Use date-based file names:
- English: `docs/changelog/YYYY-MM-DD-topic.mdx`
- Chinese: `docs/changelog/YYYY-MM-DD-topic.zh-CN.mdx`
EN and ZH files must exist as a pair and describe the same release facts.
## Frontmatter Requirements
Each file should include:
```md
---
title: <Title>
description: <1 sentence summary>
tags:
- <Tag 1>
- <Tag 2>
---
```
Rules:
1. `title` should match the H1 title in meaning.
2. `description` should be concise and user-facing.
3. `tags` should be feature-oriented, not internal-team labels.
## Content Structure (Recommended)
Use this shape unless the user requests otherwise:
1. `# <Title>`
2. Opening paragraph (2-4 sentences): user-visible impact
3. 1-3 capability sections (optional `##` headings)
4. `## Improvements and fixes` / `## 体验优化与修复` with concise bullets
Keep heading count low and avoid heading-per-bullet structure.
## Writing Rules
1. Keep all claims factual and tied to actual shipped changes.
2. Explain user value first, implementation second.
3. Prefer natural narrative paragraphs over pure bullet dumps.
4. Avoid marketing exaggeration and vague adjectives.
5. Keep internal terms consistent across EN/ZH files.
6. Keep EN/ZH section order aligned and scope-aligned.
## EN/ZH Synchronization Rules
When generating bilingual changelogs:
1. Keep the same key facts in the same order.
2. Localize naturally; do not do literal sentence-by-sentence translation.
3. If one version has an `Improvements and fixes` bullet list, the other should have equivalent list intent.
4. Do not introduce capabilities in only one language unless explicitly requested.
## Length Guidance
- Small update: 3-5 short paragraphs total
- Medium update: 4-7 short paragraphs + concise fix bullets
- Large update: 6-10 short paragraphs split into 2-4 sections
Do not pad content when changes are limited.
## Authoring Workflow
1. Collect source facts from PRs/commits/issues.
2. Group changes by user workflow (not by internal module path).
3. Draft EN and ZH versions with aligned structure.
4. Verify terminology using `microcopy`/`i18n` guidance.
5. Final pass: remove AI-like filler and tighten sentences.
## Docs Changelog Template (English)
```md
---
title: <Feature title>
description: <One-sentence summary for users>
tags:
- <Tag A>
- <Tag B>
---
# <Feature title>
<Opening paragraph: what changed for users and why it matters.>
<Optional section paragraph for key capability 1.>
<Optional section paragraph for key capability 2.>
## Improvements and fixes
- <Fix or optimization 1>
- <Fix or optimization 2>
```
## Docs Changelog Template (Chinese)
```md
---
title: <功能标题>
description: <一句话说明>
tags:
- <标签 A>
- <标签 B>
---
# <功能标题>
<开场段:这次更新给用户带来的直接变化。>
<可选能力段 1。>
<可选能力段 2。>
## 体验优化与修复
- <优化或修复 1>
- <优化或修复 2>
```
## Quick Checklist
- [ ] File path matches `docs/changelog` naming convention
- [ ] EN and ZH versions both exist and match in facts
- [ ] Opening paragraph explains user-facing outcome
- [ ] Main body is narrative-first, not bullet-only
- [ ] `Improvements and fixes` section is concise and concrete
- [ ] No fabricated claims or unsupported scope
+4
View File
@@ -173,6 +173,10 @@ agent-browser state save auth.json
agent-browser state load auth.json
```
### LobeHub dev server — inject better-auth cookie
`agent-browser --headed` on macOS can create an off-screen Chromium window, blocking manual login. For a local LobeHub dev server (e.g. `localhost:3011`), copy the `better-auth.session_token` cookie out of a **Network request** in the user's own Chrome DevTools and load it via `state load`. See [references/agent-browser-login.md](./references/agent-browser-login.md) for the full recipe.
## Semantic Locators (Alternative to Refs)
```bash
@@ -0,0 +1,110 @@
# Log `agent-browser` into a local LobeHub dev server
`agent-browser --headed` on macOS often creates the Chromium window off-screen — the user can't see or interact with it, so manual login inside the agent-browser session fails. Instead of sharing the user's real Chrome profile, copy the **better-auth session cookie** out of a request in DevTools and inject it into the agent-browser session as a Playwright-style state file.
## When to use
- You need `agent-browser` to reach an authenticated page on `http://localhost:<port>` (e.g. `localhost:3011`).
- The user already has a logged-in tab of the same dev server in their own Chrome.
- Spawning a headed Chromium to let the user log in manually is unreliable (window off-screen, no interaction).
Do **not** use this on production URLs — only local dev. Treat the cookie as a secret: don't paste it into shared logs, PRs, or commit it anywhere.
## Step 1 — Ask the user to copy the cookie from a Network request, NOT `document.cookie`
`document.cookie` will not return HttpOnly cookies, which is exactly where better-auth puts its session. Instruct the user:
1. Open the logged-in tab (`http://localhost:<port>/…`) in their own Chrome.
2. `Cmd+Option+I`**Network** tab.
3. Refresh, click any same-origin request (e.g. the top-level document request).
4. In the right pane under **Request Headers**, right-click the `Cookie:` line → **Copy value** (or copy the entire header).
5. Paste the string into chat.
You only need the better-auth pieces. Everything else (Clerk, `LOBE_LOCALE`, HMR hash, theme vars) is noise and can stay. The minimum viable set is:
```
better-auth.session_token=<value>; better-auth.state=<value>
```
## Step 2 — Build a Playwright-style state file
`agent-browser state load` expects Playwright's `storageState` format: a JSON with a `cookies` array and an `origins` array.
```bash
cat > /tmp/mkstate.py << 'PY'
import json, sys, time
# Read the Cookie header from stdin (allows optional "Cookie: " prefix).
raw = sys.stdin.read().strip()
if raw.lower().startswith("cookie:"):
raw = raw.split(":", 1)[1].strip()
# Keep only better-auth cookies. Extend this set if the app genuinely needs more.
WANTED = {"better-auth.session_token", "better-auth.state"}
cookies = []
exp = int(time.time()) + 30 * 24 * 3600 # 30 days
for pair in raw.split("; "):
if "=" not in pair:
continue
name, _, value = pair.partition("=")
if name not in WANTED:
continue
cookies.append({
"name": name,
"value": value,
"domain": "localhost",
"path": "/",
"expires": exp,
"httpOnly": False,
"secure": False,
"sameSite": "Lax",
})
if not cookies:
sys.stderr.write("no better-auth cookies found in input\n")
sys.exit(1)
print(json.dumps({"cookies": cookies, "origins": []}, indent=2))
PY
# Feed the copied Cookie header in via env var or heredoc.
printf '%s' "$COOKIE_HEADER" | python3 /tmp/mkstate.py > /tmp/state.json
```
**Note on `httpOnly`**: the real cookie in the user's browser is HttpOnly, but `storageState` doesn't enforce the flag on load — it just attaches the value. Storing with `httpOnly: false` is fine for local dev and sidesteps a CDP-context quirk where HttpOnly cookies sometimes fail to attach.
## Step 3 — Load state and navigate
```bash
SESSION="my-test" # any stable session name
agent-browser --session "$SESSION" state load /tmp/state.json
agent-browser --session "$SESSION" open "http://localhost:3011/"
agent-browser --session "$SESSION" get url
# Expect NOT /signin?callbackUrl=… — if you still see signin, cookie didn't apply.
```
## Step 4 — Verify
```bash
agent-browser --session "$SESSION" snapshot -i | head -20
# Look for the user's avatar/name in the sidebar, or absence of the signin form.
```
## Common failure modes
| Symptom | Cause | Fix |
| ----------------------------------------------- | ----------------------------------------------------------------------- | ---------------------------------------------------- |
| Still redirects to `/signin` after `state load` | User pasted from `document.cookie` → missed HttpOnly session | Re-pull from Network request Headers, not console |
| `state load` reports 0 cookies | Separator wrong, or user pasted URL-decoded value | Keep the raw `Cookie:` header as-is; split on `"; "` |
| Login works briefly then expires | `better-auth.session_token` rotated (user logged out / signed in again) | Re-copy and re-load |
| Domain mismatch | Use `domain: "localhost"` literally, no leading dot for local dev | — |
## Scope
Only covers authenticating an **agent-browser** session into a **local** LobeHub dev server. It does not:
- Work for production — production cookies are `Secure; HttpOnly; Domain=.lobehub.com` and must be delivered over HTTPS.
- Replace real OAuth flows — tests that must exercise the login UI need a real Chromium with `--remote-debugging-port` or a bot account.
- Flow cookies back to the user's Chrome — injection is one-way (into agent-browser only).
+73 -36
View File
@@ -1,64 +1,76 @@
---
name: modal
description: Modal imperative API guide. Use when creating modal dialogs using createModal from @lobehub/ui. Triggers on modal component implementation or dialog creation tasks.
description: MUST use when creating, editing, or writing modal dialogs or imperative modals. Prefer createModal / useModalContext / confirmModal from @lobehub/ui/base-ui; root @lobehub/ui is legacy (antd Modal). Covers patterns, ModalHost, and migration notes.
user-invocable: false
---
# Modal Imperative API Guide
Use `createModal` from `@lobehub/ui` for imperative modal dialogs.
## Recommended: `@lobehub/ui/base-ui`
## Why Imperative?
New code should use the **base-ui** modal stack (headless primitives, not antd `Modal`):
| Mode | Characteristics | Recommended |
| ----------- | ------------------------------------- | ----------- |
| Declarative | Need `open` state, render `<Modal />` | ❌ |
| Imperative | Call function directly, no state | ✅ |
- `createModal`, `confirmModal`, `ModalHost` from `@lobehub/ui/base-ui`
- `useModalContext` from `@lobehub/ui/base-ui` inside modal **content**
## File Structure
Body slot: pass **`content`** (or `children`; runtime uses `content ?? children`).
### Global `ModalHost` (required)
Base-ui `createModal` renders through a **separate** host from the root package. The app must mount **`ModalHost`** from `@lobehub/ui/base-ui` once near the root (e.g. next to other global hosts). Without it, `createModal` calls will not appear.
If the project only mounts `ModalHost` from `@lobehub/ui`, add a second lazy `ModalHost` from `@lobehub/ui/base-ui` until all imperative modals are migrated.
### Why imperative?
| Mode | Characteristics | Recommended |
| ----------- | ------------------------------------ | ----------- |
| Declarative | `open` state + `<Modal />` | ❌ |
| Imperative | Call `createModal()`, no local state | ✅ |
### File structure
```
features/
└── MyFeatureModal/
├── index.tsx # Export createXxxModal
└── MyFeatureContent.tsx # Modal content
├── index.tsx # export createXxxModal
└── MyFeatureContent.tsx # modal body
```
## Implementation
### 1. Content Component (`MyFeatureContent.tsx`)
### 1. Content (`MyFeatureContent.tsx`)
```tsx
'use client';
import { useModalContext } from '@lobehub/ui';
import { useModalContext } from '@lobehub/ui/base-ui';
import { useTranslation } from 'react-i18next';
export const MyFeatureContent = () => {
const { t } = useTranslation('namespace');
const { close } = useModalContext(); // Optional: get close method
const { close } = useModalContext();
return <div>{/* Modal content */}</div>;
return <div>{/* ... */}</div>;
};
```
### 2. Export createModal (`index.tsx`)
### 2. `createModal` (`index.tsx`)
```tsx
'use client';
import { createModal } from '@lobehub/ui';
import { t } from 'i18next'; // Note: use i18next, not react-i18next
import { createModal } from '@lobehub/ui/base-ui';
import { t } from 'i18next';
import { MyFeatureContent } from './MyFeatureContent';
export const createMyFeatureModal = () =>
createModal({
allowFullscreen: true,
children: <MyFeatureContent />,
destroyOnHidden: false,
content: <MyFeatureContent />,
footer: null,
styles: { body: { overflow: 'hidden', padding: 0 } },
maskClosable: true,
styles: {
content: { overflow: 'hidden', padding: 0 },
},
title: t('myFeature.title', { ns: 'setting' }),
width: 'min(80%, 800px)',
});
@@ -76,27 +88,52 @@ const handleOpen = useCallback(() => {
return <Button onClick={handleOpen}>Open</Button>;
```
## i18n Handling
### i18n
- **Content component**: `useTranslation` hook (React context)
- **createModal params**: `import { t } from 'i18next'` (non-hook, imperative)
- **Content**: `useTranslation` in components.
- **`createModal` options**: `import { t } from 'i18next'` where hooks are unavailable.
## useModalContext Hook
### `useModalContext`
```tsx
const { close, setCanDismissByClickOutside } = useModalContext();
```
## Common Config
### Common options (base-ui)
| Property | Type | Description |
| ----------------- | ------------------- | ------------------------ |
| `allowFullscreen` | `boolean` | Allow fullscreen mode |
| `destroyOnHidden` | `boolean` | Destroy content on close |
| `footer` | `ReactNode \| null` | Footer content |
| `width` | `string \| number` | Modal width |
`ImperativeModalProps` builds on `BaseModalProps`: `title`, `width`, `maskClosable`, `open`, `onOpenChange`, `footer`, `styles` / `classNames` (keys: `backdrop`, `popup`, `header`, `title`, `close`, `content`, …).
| Property | Notes |
| -------------- | ---------------------------------------- |
| `content` | Main body (preferred name vs `children`) |
| `maskClosable` | Click outside to dismiss |
| `styles.*` | Semantic regions, not antd `styles.body` |
### Confirm
```tsx
import { confirmModal } from '@lobehub/ui/base-ui';
confirmModal({
title: '…',
content: '…',
okText: '…',
cancelText: '…',
onOk: async () => {},
});
```
---
## Legacy: `@lobehub/ui` (root)
Older call sites use **`createModal` from `@lobehub/ui`**, which is typed as **antd `Modal` props** (`children`, `allowFullscreen`, `getContainer`, `destroyOnHidden`, `styles.body`, etc.). Prefer migrating new work to **`@lobehub/ui/base-ui`**.
Examples (legacy): `src/features/SkillStore/index.tsx`, `src/features/LibraryModal/CreateNew/index.tsx`.
---
## Examples
- `src/features/SkillStore/index.tsx`
- `src/features/LibraryModal/CreateNew/index.tsx`
- Base-ui (preferred): follow sections above; ensure **base-ui `ModalHost`** is mounted.
- Legacy: `src/features/SkillStore/index.tsx`, `src/features/LibraryModal/CreateNew/index.tsx`
+1 -1
View File
@@ -67,7 +67,7 @@ import { dynamicElement, redirectElement, ErrorBoundary } from '@/utils/router';
element: dynamicElement(() => import('./chat'), 'Desktop > Chat');
element: redirectElement('/settings/profile');
errorElement: <ErrorBoundary resetPath="/chat" />;
errorElement: <ErrorBoundary />;
```
### Navigation
+3 -3
View File
@@ -97,8 +97,8 @@ jobs:
if: needs.check-duplicate-run.outputs.should_skip != 'true'
strategy:
matrix:
shard: [1, 2]
name: Test App (shard ${{ matrix.shard }}/2)
shard: [1, 2, 3]
name: Test App (shard ${{ matrix.shard }}/3)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
@@ -110,7 +110,7 @@ jobs:
run: pnpm install
- name: Run tests
run: bunx vitest --coverage --silent='passed-only' --reporter=default --reporter=blob --shard=${{ matrix.shard }}/2
run: bunx vitest --coverage --silent='passed-only' --reporter=default --reporter=blob --shard=${{ matrix.shard }}/3
- name: Upload blob report
if: ${{ !cancelled() }}
+1 -1
View File
@@ -146,4 +146,4 @@ apps/desktop/resources/cli-package.json
# Superpowers plugin brainstorm/spec outputs (local only; do not commit)
.superpowers/
docs/superpowers/
.heerogeneous-tracing
+6 -1
View File
@@ -466,7 +466,12 @@ export function registerTaskCommand(program: Command) {
: act.priority === 'normal'
? pc.yellow(' [normal]')
: '';
const resolved = act.resolvedAction ? pc.green(` ✏️ ${act.resolvedAction}`) : '';
const resolvedLabel = act.resolvedAction
? act.resolvedComment
? `${act.resolvedAction}: ${act.resolvedComment}`
: act.resolvedAction
: '';
const resolved = resolvedLabel ? pc.green(` ✏️ ${resolvedLabel}`) : '';
const typeLabel = pc.dim(`[${act.briefType}]`);
console.log(
` ${icon} ${pc.dim(ago.padStart(7))} Brief ${typeLabel} ${act.title}${pri}${resolved}${idSuffix}`,
+57 -4
View File
@@ -15,15 +15,64 @@ import {
import { getExternalDependencies } from './native-deps.config.mjs';
/**
* Rewrite `/` to `/apps/desktop/index.html` so the electron-vite dev server
* serves the desktop HTML entry when root is the monorepo root.
* Force `base: '/'` in renderer config. The `electron-vite` preset
* unconditionally rewrites base to `'./'` in production (with `enforce: 'pre'`),
* which produces relative asset URLs like `../../assets/...`. Those break in
* the popup window because its SPA URL (`/popup/agent/:aid/:tid`) is deep
* enough that relative resolution lands at `/popup/assets/...` instead of the
* actual `/assets/...`. Our `app://` protocol handler resolves absolute
* `/assets/...` correctly regardless of URL depth.
*/
function forceAbsoluteBasePlugin(): PluginOption {
return {
name: 'electron-desktop-force-base',
config(config) {
config.base = '/';
},
};
}
/**
* Rewrite SPA routes to their corresponding HTML entry so the electron-vite
* dev server serves the right HTML when root is the monorepo root.
*
* - `/popup/*` → `/apps/desktop/popup.html` (topic popup SPA)
* - `/`, `/index.html`, and everything else → `/apps/desktop/index.html`
*/
function electronDesktopHtmlPlugin(): PluginOption {
return {
configureServer(server: ViteDevServer) {
server.middlewares.use((req, _res, next) => {
if (req.url === '/' || req.url === '/index.html') {
const rawUrl = req.url ?? '';
const pathname = rawUrl.split('?')[0];
// Explicit document-entry requests — always rewrite.
if (pathname === '/' || pathname === '/index.html') {
req.url = '/apps/desktop/index.html';
next();
return;
}
if (pathname === '/popup.html') {
req.url = '/apps/desktop/popup.html';
next();
return;
}
// For SPA deep links (e.g. `/popup/agent/A/T`) rewrite to the popup
// HTML — but skip asset / module requests that happen to share the
// prefix (e.g. `/popup/@vite/client` would have been generated by a
// mis-resolved relative import).
const lastSegment = pathname.split('/').pop() ?? '';
const looksLikeAsset =
lastSegment.includes('.') ||
pathname.startsWith('/@') ||
pathname.startsWith('/src/') ||
pathname.startsWith('/node_modules/') ||
pathname.startsWith('/apps/') ||
pathname.startsWith('/packages/');
if (!looksLikeAsset && (pathname === '/popup' || pathname.startsWith('/popup/'))) {
req.url = '/apps/desktop/popup.html';
}
next();
});
@@ -102,7 +151,10 @@ export default defineConfig({
build: {
outDir: path.resolve(__dirname, 'dist/renderer'),
rollupOptions: {
input: path.resolve(__dirname, 'index.html'),
input: {
main: path.resolve(__dirname, 'index.html'),
popup: path.resolve(__dirname, 'popup.html'),
},
output: sharedRollupOutput,
},
},
@@ -112,6 +164,7 @@ export default defineConfig({
},
optimizeDeps: sharedOptimizeDeps,
plugins: [
forceAbsoluteBasePlugin(),
electronDesktopHtmlPlugin(),
...(sharedRendererPlugins({ platform: 'desktop' }) as PluginOption[]),
],
+3
View File
@@ -1,8 +1,11 @@
packages:
- '../../packages/const'
- '../../packages/electron-server-ipc'
- '../../packages/electron-client-ipc'
- '../../packages/file-loaders'
- '../../packages/desktop-bridge'
- '../../packages/device-gateway-client'
- '../../packages/local-file-shell'
- './stubs/business-const'
- './stubs/types'
- '.'
+114
View File
@@ -0,0 +1,114 @@
<!doctype html>
<html class="desktop">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
html,
body {
margin: 0;
height: 100%;
background: transparent;
}
html[data-theme='dark'] {
background: #141414;
}
html[data-theme='light'] {
background: #fafafa;
}
#loading-screen {
position: fixed;
inset: 0;
z-index: 99999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: inherit;
gap: 12px;
}
@keyframes loading-draw {
0% {
stroke-dashoffset: 1000;
}
100% {
stroke-dashoffset: 0;
}
}
@keyframes loading-fill {
30% {
fill-opacity: 0.05;
}
100% {
fill-opacity: 1;
}
}
#loading-brand {
display: flex;
align-items: center;
gap: 12px;
color: #1f1f1f;
}
#loading-brand svg path {
fill: currentcolor;
fill-opacity: 0;
stroke: currentcolor;
stroke-dasharray: 1000;
stroke-dashoffset: 1000;
stroke-width: 0.25em;
animation:
loading-draw 2s cubic-bezier(0.4, 0, 0.2, 1) infinite,
loading-fill 2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
html[data-theme='dark'] #loading-brand {
color: #f0f0f0;
}
</style>
</head>
<body>
<script>
(function () {
var theme = 'system';
try {
theme = localStorage.getItem('theme') || 'system';
} catch (_) {}
var systemTheme =
window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
var resolvedTheme = theme === 'system' ? systemTheme : theme;
if (resolvedTheme === 'dark' || resolvedTheme === 'light') {
document.documentElement.setAttribute('data-theme', resolvedTheme);
}
var urlParams = new URLSearchParams(window.location.search);
var locale = urlParams.get('lng') || navigator.language || 'en-US';
document.documentElement.lang = locale;
var rtl = ['ar', 'arc', 'dv', 'fa', 'ha', 'he', 'khw', 'ks', 'ku', 'ps', 'ur', 'yi'];
document.documentElement.dir =
rtl.indexOf(locale.split('-')[0].toLowerCase()) >= 0 ? 'rtl' : 'ltr';
})();
</script>
<div id="loading-screen">
<div id="loading-brand" aria-label="Loading" role="status">
<svg
fill="currentColor"
fill-rule="evenodd"
height="40"
style="flex: none; line-height: 1"
viewBox="0 0 940 320"
xmlns="http://www.w3.org/2000/svg"
>
<title>LobeHub</title>
<path
d="M15 240.035V87.172h39.24V205.75h66.192v34.285H15zM183.731 242c-11.759 0-22.196-2.621-31.313-7.862-9.116-5.241-16.317-12.447-21.601-21.619-5.153-9.317-7.729-19.945-7.729-31.883 0-11.937 2.576-22.492 7.729-31.664 5.164-8.963 12.159-15.98 20.982-21.05l.619-.351c9.117-5.241 19.554-7.861 31.313-7.861s22.196 2.62 31.313 7.861c9.248 5.096 16.449 12.229 21.601 21.401 5.153 9.172 7.729 19.727 7.729 31.664 0 11.938-2.576 22.566-7.729 31.883-5.152 9.172-12.353 16.378-21.601 21.619-9.117 5.241-19.554 7.862-31.313 7.862zm0-32.975c4.36 0 8.191-1.092 11.494-3.275 3.436-2.184 6.144-5.387 8.126-9.609 1.982-4.367 2.973-9.536 2.973-15.505 0-5.968-.991-10.991-2.973-15.067-1.906-4.06-4.483-7.177-7.733-9.352l-.393-.257c-3.303-2.184-7.134-3.276-11.494-3.276-4.228 0-8.059 1.092-11.495 3.276-3.303 2.184-6.011 5.387-8.125 9.609-1.982 4.076-2.973 9.099-2.973 15.067 0 5.969.991 11.138 2.973 15.505 2.114 4.222 4.822 7.425 8.125 9.609 3.436 2.183 7.267 3.275 11.495 3.275zM295.508 78l-.001 54.042a34.071 34.071 0 016.541-5.781c6.474-4.367 14.269-6.551 23.385-6.551 9.777 0 18.629 2.475 26.557 7.424 7.872 4.835 14.105 11.684 18.7 20.546l.325.637c4.756 9.026 7.135 19.799 7.135 32.319 0 12.666-2.379 23.585-7.135 32.757-4.624 9.026-10.966 16.087-19.025 21.182-7.928 4.95-16.78 7.425-26.557 7.425-9.644 0-17.704-2.184-24.178-6.551-2.825-1.946-5.336-4.355-7.532-7.226l.001 11.812h-35.87V78h37.654zm21.998 74.684c-4.228 0-8.059 1.092-11.494 3.276-3.303 2.184-6.012 5.387-8.126 9.609-1.982 4.076-2.972 9.099-2.972 15.067 0 5.969.99 11.138 2.972 15.505 2.114 4.222 4.823 7.425 8.126 9.609 3.435 2.183 7.266 3.275 11.494 3.275s7.994-1.092 11.297-3.275c3.435-2.184 6.143-5.387 8.125-9.609 2.114-4.367 3.171-9.536 3.171-15.505 0-5.968-1.057-10.991-3.171-15.067-1.906-4.06-4.483-7.177-7.732-9.352l-.393-.257c-3.303-2.184-7.069-3.276-11.297-3.276zm105.335 38.653l.084.337a27.857 27.857 0 002.057 5.559c2.246 4.222 5.417 7.498 9.513 9.827 4.096 2.184 8.984 3.276 14.665 3.276 5.285 0 9.777-.801 13.477-2.403 3.579-1.632 7.1-4.025 10.564-7.182l.732-.679 19.818 22.711c-5.153 6.26-11.494 11.064-19.025 14.413-7.531 3.203-16.449 4.804-26.755 4.804-12.683 0-23.782-2.621-33.294-7.862-9.381-5.386-16.713-12.665-21.998-21.837-5.153-9.317-7.729-19.872-7.729-31.665 0-11.792 2.51-22.274 7.53-31.446 5.036-9.105 11.902-16.195 20.596-21.268l.61-.351c8.984-5.241 19.091-7.861 30.322-7.861 10.311 0 19.743 2.286 28.294 6.859l.64.347c8.72 4.659 15.656 11.574 20.809 20.746 5.153 9.172 7.729 20.309 7.729 33.411 0 1.294-.052 2.761-.156 4.4l-.042.623-.17 2.353c-.075 1.01-.151 1.973-.227 2.888h-78.044zm21.365-42.147c-4.492 0-8.456 1.092-11.891 3.276-3.303 2.184-5.879 5.314-7.729 9.39a26.04 26.04 0 00-1.117 2.79 30.164 30.164 0 00-1.121 4.499l-.058.354h43.96l-.015-.106c-.401-2.638-1.122-5.055-2.163-7.252l-.246-.503c-1.776-3.774-4.282-6.742-7.519-8.906l-.409-.266c-3.303-2.184-7.2-3.276-11.692-3.276zm111.695-62.018l-.001 57.432h53.51V87.172h39.24v152.863h-39.24v-59.617H555.9l.001 59.617h-39.24V87.172h39.24zM715.766 242c-8.72 0-16.581-1.893-23.583-5.678-6.87-3.785-12.287-9.681-16.251-17.688-3.832-8.153-5.747-18.417-5.747-30.791v-66.168h37.654v59.398c0 9.172 1.519 15.723 4.558 19.654 3.171 3.931 7.597 5.896 13.278 5.896 3.7 0 7.069-.946 10.108-2.839 3.038-1.892 5.483-4.877 7.332-8.953 1.85-4.222 2.775-9.609 2.775-16.16v-56.996h37.654v118.36h-35.871l.004-12.38c-2.642 3.197-5.682 5.868-9.12 8.012-7.002 4.222-14.599 6.333-22.791 6.333zM841.489 78l-.001 54.041a34.1 34.1 0 016.541-5.78c6.474-4.367 14.269-6.551 23.385-6.551 9.777 0 18.629 2.475 26.556 7.424 7.873 4.835 14.106 11.684 18.701 20.546l.325.637c4.756 9.026 7.134 19.799 7.134 32.319 0 12.666-2.378 23.585-7.134 32.757-4.624 9.026-10.966 16.087-19.026 21.182-7.927 4.95-16.779 7.425-26.556 7.425-9.645 0-17.704-2.184-24.178-6.551-2.825-1.946-5.336-4.354-7.531-7.224v11.81h-35.87V78h37.654zm21.998 74.684c-4.228 0-8.059 1.092-11.495 3.276-3.303 2.184-6.011 5.387-8.125 9.609-1.982 4.076-2.973 9.099-2.973 15.067 0 5.969.991 11.138 2.973 15.505 2.114 4.222 4.822 7.425 8.125 9.609 3.436 2.183 7.267 3.275 11.495 3.275 4.228 0 7.993-1.092 11.296-3.275 3.435-2.184 6.144-5.387 8.126-9.609 2.114-4.367 3.171-9.536 3.171-15.505 0-5.968-1.057-10.991-3.171-15.067-1.906-4.06-4.484-7.177-7.733-9.352l-.393-.257c-3.303-2.184-7.068-3.276-11.296-3.276z"
/>
</svg>
</div>
</div>
<div id="root" style="height: 100%"></div>
<script>
window.__SERVER_CONFIG__ = undefined;
</script>
<script type="module" src="/src/spa/entry.popup.tsx"></script>
</body>
</html>
+14
View File
@@ -66,6 +66,20 @@ export const windowTemplates = {
titleBarStyle: 'hidden',
width: 900,
},
// Dedicated single-topic popup window. Loads the popup.html SPA entry
// (no sidebar / portal), one window per (scope, id) pair.
topicPopup: {
allowMultipleInstances: true,
autoHideMenuBar: true,
baseIdentifier: 'topicPopup',
basePath: '/popup',
height: 720,
keepAlive: false,
minWidth: 480,
parentIdentifier: 'app',
titleBarStyle: 'hidden',
width: 900,
},
} satisfies Record<string, WindowTemplate>;
export type AppBrowsersIdentifiers = keyof typeof appBrowsers;
+2 -2
View File
@@ -1,11 +1,11 @@
/**
* Application settings storage related constants
*/
import { DEFAULT_ELECTRON_DESKTOP_SHORTCUTS } from '@lobechat/const/desktopGlobalShortcuts';
import type { NetworkProxySettings } from '@lobechat/electron-client-ipc';
import { appStorageDir } from '@/const/dir';
import { UPDATE_CHANNEL } from '@/modules/updater/configs';
import { DEFAULT_SHORTCUTS_CONFIG } from '@/shortcuts';
import type { ElectronMainStore } from '@/types/store';
/**
@@ -35,7 +35,7 @@ export const STORE_DEFAULTS: ElectronMainStore = {
gatewayUrl: 'https://device-gateway.lobehub.com',
locale: 'auto',
networkProxy: defaultProxySettings,
shortcuts: DEFAULT_SHORTCUTS_CONFIG,
shortcuts: DEFAULT_ELECTRON_DESKTOP_SHORTCUTS,
storagePath: appStorageDir,
themeMode: 'system',
updateChannel: UPDATE_CHANNEL,
@@ -1,4 +1,5 @@
import type {
FocusTopicPopupParams,
InterceptRouteParams,
OpenSettingsWindowOptions,
WindowMinimumSizeParams,
@@ -80,6 +81,30 @@ export default class BrowserWindowsCtr extends ControllerModule {
});
}
@IpcMethod()
setWindowAlwaysOnTop(flag: boolean) {
this.withSenderIdentifier((identifier) => {
this.app.browserManager.setWindowAlwaysOnTop(identifier, flag);
});
}
@IpcMethod()
isWindowAlwaysOnTop() {
return this.withSenderIdentifier((identifier) => {
return this.app.browserManager.isWindowAlwaysOnTop(identifier);
});
}
@IpcMethod()
listTopicPopups() {
return this.app.browserManager.listTopicPopups();
}
@IpcMethod()
focusTopicPopup(params: FocusTopicPopupParams) {
return this.app.browserManager.focusTopicPopup(params.identifier);
}
@IpcMethod()
setWindowSize(params: WindowSizeParams) {
this.withSenderIdentifier((identifier) => {
@@ -0,0 +1,436 @@
import type { ChildProcess } from 'node:child_process';
import { spawn } from 'node:child_process';
import { createHash, randomUUID } from 'node:crypto';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import type { Readable, Writable } from 'node:stream';
import { app as electronApp, BrowserWindow } from 'electron';
import { createLogger } from '@/utils/logger';
import { ControllerModule, IpcMethod } from './index';
const logger = createLogger('controllers:HeterogeneousAgentCtr');
/** Directory under appStoragePath for caching downloaded files */
const FILE_CACHE_DIR = 'heteroAgent/files';
// ─── CLI presets per agent type ───
// Mirrors @lobechat/heterogeneous-agents/registry but runs in main process
// (can't import from the workspace package in Electron main directly)
interface CLIPreset {
baseArgs: string[];
promptMode: 'positional' | 'stdin';
resumeArgs?: (sessionId: string) => string[];
}
const CLI_PRESETS: Record<string, CLIPreset> = {
'claude-code': {
baseArgs: [
'-p',
'--input-format',
'stream-json',
'--output-format',
'stream-json',
'--verbose',
'--include-partial-messages',
'--permission-mode',
'bypassPermissions',
],
promptMode: 'stdin',
resumeArgs: (sid) => ['--resume', sid],
},
// Future presets:
// 'codex': { baseArgs: [...], promptMode: 'positional' },
// 'kimi-cli': { baseArgs: [...], promptMode: 'positional' },
};
// ─── IPC types ───
interface StartSessionParams {
/** Agent type key (e.g., 'claude-code'). Defaults to 'claude-code'. */
agentType?: string;
/** Additional CLI arguments */
args?: string[];
/** Command to execute */
command: string;
/** Working directory */
cwd?: string;
/** Environment variables */
env?: Record<string, string>;
/** Session ID to resume (for multi-turn) */
resumeSessionId?: string;
}
interface StartSessionResult {
sessionId: string;
}
interface ImageAttachment {
id: string;
url: string;
}
interface SendPromptParams {
/** Image attachments to include in the prompt (downloaded from url, cached by id) */
imageList?: ImageAttachment[];
prompt: string;
sessionId: string;
}
interface CancelSessionParams {
sessionId: string;
}
interface StopSessionParams {
sessionId: string;
}
interface GetSessionInfoParams {
sessionId: string;
}
interface SessionInfo {
agentSessionId?: string;
}
// ─── Internal session tracking ───
interface AgentSession {
agentSessionId?: string;
agentType: string;
args: string[];
command: string;
cwd?: string;
env?: Record<string, string>;
process?: ChildProcess;
sessionId: string;
}
/**
* External Agent Controller — manages external agent CLI processes via Electron IPC.
*
* Agent-agnostic: uses CLI presets from a registry to support Claude Code,
* Codex, Kimi CLI, etc. Only handles process lifecycle and raw stdout line
* broadcasting. All event parsing and DB persistence happens on the Renderer side.
*
* Lifecycle: startSession → sendPrompt → (heteroAgentRawLine broadcasts) → stopSession
*/
export default class HeterogeneousAgentCtr extends ControllerModule {
static override readonly groupName = 'heterogeneousAgent';
private sessions = new Map<string, AgentSession>();
// ─── Broadcast ───
private broadcast<T>(channel: string, data: T) {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) {
win.webContents.send(channel, data);
}
}
}
// ─── File cache ───
private get fileCacheDir(): string {
return path.join(this.app.appStoragePath, FILE_CACHE_DIR);
}
/**
* Derive a filesystem-safe cache key for attachments.
*
* Never use the raw image id as a path segment — upstream callers can persist
* arbitrary ids and path.join would treat traversal sequences as real
* directories. A stable hash preserves cache hits without trusting the id as a
* filename.
*/
private getImageCacheKey(imageId: string): string {
return createHash('sha256').update(imageId).digest('hex');
}
/**
* Download an image by URL, with local disk cache keyed by id.
*/
private async resolveImage(
image: ImageAttachment,
): Promise<{ buffer: Buffer; mimeType: string }> {
const cacheDir = this.fileCacheDir;
const cacheKey = this.getImageCacheKey(image.id);
const metaPath = path.join(cacheDir, `${cacheKey}.meta`);
const dataPath = path.join(cacheDir, cacheKey);
// Check cache first
try {
const metaRaw = await readFile(metaPath, 'utf8');
const meta = JSON.parse(metaRaw);
const buffer = await readFile(dataPath);
logger.debug('Image cache hit:', image.id);
return { buffer, mimeType: meta.mimeType || 'image/png' };
} catch {
// Cache miss — download
}
logger.info('Downloading image:', image.id);
const res = await fetch(image.url);
if (!res.ok)
throw new Error(`Failed to download image ${image.id}: ${res.status} ${res.statusText}`);
const arrayBuffer = await res.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const mimeType = res.headers.get('content-type') || 'image/png';
// Write to cache
await mkdir(cacheDir, { recursive: true });
await writeFile(dataPath, buffer);
await writeFile(metaPath, JSON.stringify({ id: image.id, mimeType }));
logger.debug('Image cached:', image.id, `${buffer.length} bytes`);
return { buffer, mimeType };
}
/**
* Build a stream-json user message with text + optional image content blocks.
*/
private async buildStreamJsonInput(
prompt: string,
imageList: ImageAttachment[] = [],
): Promise<string> {
const content: any[] = [{ text: prompt, type: 'text' }];
for (const image of imageList) {
try {
const { buffer, mimeType } = await this.resolveImage(image);
content.push({
source: {
data: buffer.toString('base64'),
media_type: mimeType,
type: 'base64',
},
type: 'image',
});
} catch (err) {
logger.error(`Failed to resolve image ${image.id}:`, err);
}
}
return JSON.stringify({
message: { content, role: 'user' },
type: 'user',
});
}
// ─── IPC methods ───
/**
* Create a session (stores config, process spawned on sendPrompt).
*/
@IpcMethod()
async startSession(params: StartSessionParams): Promise<StartSessionResult> {
const sessionId = randomUUID();
const agentType = params.agentType || 'claude-code';
this.sessions.set(sessionId, {
// If resuming, pre-set the agent session ID so sendPrompt adds --resume
agentSessionId: params.resumeSessionId,
agentType,
args: params.args || [],
command: params.command,
cwd: params.cwd,
env: params.env,
sessionId,
});
logger.info('Session created:', { agentType, sessionId });
return { sessionId };
}
/**
* Send a prompt to an agent session.
*
* Spawns the CLI process with preset flags. Broadcasts each stdout line
* as an `heteroAgentRawLine` event — Renderer side parses and adapts.
*/
@IpcMethod()
async sendPrompt(params: SendPromptParams): Promise<void> {
const session = this.sessions.get(params.sessionId);
if (!session) throw new Error(`Session not found: ${params.sessionId}`);
const preset = CLI_PRESETS[session.agentType];
if (!preset) throw new Error(`Unknown agent type: ${session.agentType}`);
const useStdin = preset.promptMode === 'stdin';
// Build stream-json payload up-front so any image download errors
// surface before the process is spawned.
let stdinPayload: string | undefined;
if (useStdin) {
stdinPayload = await this.buildStreamJsonInput(params.prompt, params.imageList ?? []);
}
return new Promise<void>((resolve, reject) => {
// Build CLI args: base preset + resume + user args
const cliArgs = [
...preset.baseArgs,
...(session.agentSessionId && preset.resumeArgs
? preset.resumeArgs(session.agentSessionId)
: []),
...session.args,
];
if (!useStdin && preset.promptMode === 'positional') {
// Positional mode: append prompt as a CLI arg (legacy / non-CC presets).
cliArgs.push(params.prompt);
}
// Fall back to the user's Desktop so the process never inherits
// the Electron parent's cwd (which is `/` when launched from Finder).
const cwd = session.cwd || electronApp.getPath('desktop');
logger.info('Spawning agent:', session.command, cliArgs.join(' '), `(cwd: ${cwd})`);
const proc = spawn(session.command, cliArgs, {
cwd,
env: { ...process.env, ...session.env },
stdio: [useStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
});
// In stdin mode, write the stream-json message and close stdin.
if (useStdin && stdinPayload && proc.stdin) {
const stdin = proc.stdin as Writable;
stdin.write(stdinPayload + '\n', () => {
stdin.end();
});
}
session.process = proc;
let buffer = '';
// Stream stdout lines as raw events to Renderer
const stdout = proc.stdout as Readable;
stdout.on('data', (chunk: Buffer) => {
buffer += chunk.toString('utf8');
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const parsed = JSON.parse(trimmed);
// Extract agent session ID from init event (for multi-turn)
if (parsed.type === 'system' && parsed.subtype === 'init' && parsed.session_id) {
session.agentSessionId = parsed.session_id;
}
// Broadcast raw parsed JSON — Renderer handles all adaptation
this.broadcast('heteroAgentRawLine', {
line: parsed,
sessionId: session.sessionId,
});
} catch {
// Not valid JSON, skip
}
}
});
// Capture stderr
const stderrChunks: string[] = [];
const stderr = proc.stderr as Readable;
stderr.on('data', (chunk: Buffer) => {
stderrChunks.push(chunk.toString('utf8'));
});
proc.on('error', (err) => {
logger.error('Agent process error:', err);
this.broadcast('heteroAgentSessionError', {
error: err.message,
sessionId: session.sessionId,
});
reject(err);
});
proc.on('exit', (code) => {
logger.info('Agent process exited:', { code, sessionId: session.sessionId });
session.process = undefined;
if (code === 0) {
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
resolve();
} else {
const stderrOutput = stderrChunks.join('').trim();
const errorMsg = stderrOutput || `Agent exited with code ${code}`;
this.broadcast('heteroAgentSessionError', {
error: errorMsg,
sessionId: session.sessionId,
});
reject(new Error(errorMsg));
}
});
});
}
/**
* Get session info (agent's internal session ID for multi-turn resume).
*/
@IpcMethod()
async getSessionInfo(params: GetSessionInfoParams): Promise<SessionInfo> {
const session = this.sessions.get(params.sessionId);
return { agentSessionId: session?.agentSessionId };
}
/**
* Cancel an ongoing session.
*/
@IpcMethod()
async cancelSession(params: CancelSessionParams): Promise<void> {
const session = this.sessions.get(params.sessionId);
if (session?.process) {
session.process.kill('SIGINT');
}
}
/**
* Stop and clean up a session.
*/
@IpcMethod()
async stopSession(params: StopSessionParams): Promise<void> {
const session = this.sessions.get(params.sessionId);
if (!session) return;
if (session.process && !session.process.killed) {
session.process.kill('SIGTERM');
setTimeout(() => {
if (session.process && !session.process.killed) {
session.process.kill('SIGKILL');
}
}, 3000);
}
this.sessions.delete(params.sessionId);
}
@IpcMethod()
async respondPermission(): Promise<void> {
// No-op for CLI mode (permissions handled by --permission-mode flag)
}
/**
* Cleanup on app quit.
*/
afterAppReady() {
electronApp.on('before-quit', () => {
for (const [, session] of this.sessions) {
if (session.process && !session.process.killed) {
session.process.kill('SIGTERM');
}
}
this.sessions.clear();
});
}
}
@@ -178,6 +178,28 @@ export default class NotificationCtr extends ControllerModule {
}
}
/**
* Set the app-level badge count (dock red dot on macOS, Unity counter on Linux,
* overlay icon on Windows). Pass 0 to clear.
*
* On macOS we pair `app.setBadgeCount` with `app.dock.setBadge` — the former
* keeps Electron's internal count (cross-platform), the latter is the
* reliable Dock repaint trigger. Note: macOS Focus Mode / DND suppresses the
* badge visually until the user exits Focus.
*/
@IpcMethod()
setBadgeCount(count: number): void {
try {
const next = Math.max(0, Math.floor(count));
app.setBadgeCount(next);
if (macOS() && app.dock) {
app.dock.setBadge(next > 0 ? String(next) : '');
}
} catch (error) {
logger.error('Failed to set badge count:', error);
}
}
/**
* Check if the main window is hidden
*/
+220 -2
View File
@@ -1,8 +1,18 @@
import { execFile } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import { promisify } from 'node:util';
import type { ElectronAppState, ThemeMode } from '@lobechat/electron-client-ipc';
import type {
ElectronAppState,
GitBranchInfo,
GitBranchListItem,
GitCheckoutResult,
GitLinkedPullRequestResult,
GitWorkingTreeStatus,
ThemeMode,
} from '@lobechat/electron-client-ipc';
import { app, dialog, nativeTheme, shell } from 'electron';
import { macOS } from 'electron-is';
import { pathExists, readdir } from 'fs-extra';
@@ -235,7 +245,8 @@ export default class SystemController extends ControllerModule {
}
}
private async detectRepoType(dirPath: string): Promise<'git' | 'github' | undefined> {
@IpcMethod()
async detectRepoType(dirPath: string): Promise<'git' | 'github' | undefined> {
const gitConfigPath = path.join(dirPath, '.git', 'config');
try {
const config = await readFile(gitConfigPath, 'utf8');
@@ -246,6 +257,213 @@ export default class SystemController extends ControllerModule {
}
}
/**
* Read current git branch from `.git/HEAD`. Returns short sha on detached HEAD.
* Handles both standard `.git` directories and `.git` worktree pointer files.
*/
@IpcMethod()
async getGitBranch(dirPath: string): Promise<GitBranchInfo> {
try {
const gitDir = await this.resolveGitDir(dirPath);
if (!gitDir) return {};
const head = (await readFile(path.join(gitDir, 'HEAD'), 'utf8')).trim();
const refMatch = /^ref:\s*refs\/heads\/(.+)$/.exec(head);
if (refMatch) {
return { branch: refMatch[1] };
}
// Detached HEAD — HEAD file contains the full sha
if (/^[\da-f]{40}$/i.test(head)) {
return { branch: head.slice(0, 7), detached: true };
}
return {};
} catch {
return {};
}
}
/**
* Query `gh` CLI for an open pull request whose head branch matches `branch`.
* Returns status = 'gh-missing' when `gh` is not installed / not authenticated,
* so the UI can render a helpful tooltip instead of an error.
*/
@IpcMethod()
async getLinkedPullRequest(payload: {
branch: string;
path: string;
}): Promise<GitLinkedPullRequestResult> {
const { path: dirPath, branch } = payload;
if (!branch) {
return { pullRequest: null, status: 'ok' };
}
const execFileAsync = promisify(execFile);
try {
const { stdout } = await execFileAsync(
'gh',
[
'pr',
'list',
'--head',
branch,
'--state',
'open',
'--limit',
'5',
'--json',
'number,url,title,state',
],
{ cwd: dirPath, timeout: 8000 },
);
const parsed = JSON.parse(stdout.trim() || '[]') as Array<{
number: number;
state: string;
title: string;
url: string;
}>;
if (parsed.length === 0) {
return { pullRequest: null, status: 'ok' };
}
const [primary, ...rest] = parsed;
return {
extraCount: rest.length,
pullRequest: primary,
status: 'ok',
};
} catch (error: any) {
const code = error?.code;
const stderr: string = error?.stderr ?? '';
// `gh` binary not on PATH
if (code === 'ENOENT') {
return { pullRequest: null, status: 'gh-missing' };
}
// gh reports auth issues via stderr; treat as a soft-fail
if (/auth\s+login|not\s+logged\s+in|authentication/i.test(stderr)) {
return { pullRequest: null, status: 'gh-missing' };
}
logger.debug('[getLinkedPullRequest] failed', { branch, code, stderr });
return { pullRequest: null, status: 'error' };
}
}
/**
* List local git branches ordered by most recent commit.
* `current` is true for the checked-out branch.
*/
@IpcMethod()
async listGitBranches(dirPath: string): Promise<GitBranchListItem[]> {
const execFileAsync = promisify(execFile);
try {
const { stdout } = await execFileAsync(
'git',
[
'for-each-ref',
'--sort=-committerdate',
'--format=%(HEAD)%09%(refname:short)%09%(upstream:short)',
'refs/heads',
],
{ cwd: dirPath, timeout: 5000 },
);
return stdout
.replaceAll('\r', '')
.split('\n')
.filter((line) => line.length > 0)
.map((line) => {
// Line format: "<HEAD-marker>\t<branch>\t<upstream>" where HEAD-marker is '*' or ' '
const [head, name, upstream] = line.split('\t');
return {
current: head === '*',
name: name ?? '',
upstream: upstream || undefined,
};
})
.filter((b) => b.name);
} catch (error: any) {
logger.warn('[listGitBranches] git command failed', {
code: error?.code,
cwd: dirPath,
message: error?.message,
stderr: error?.stderr?.toString?.() ?? error?.stderr,
});
return [];
}
}
/**
* Count unstaged / staged / untracked files via `git status --porcelain`.
*/
@IpcMethod()
async getGitWorkingTreeStatus(dirPath: string): Promise<GitWorkingTreeStatus> {
const execFileAsync = promisify(execFile);
try {
const { stdout } = await execFileAsync('git', ['status', '--porcelain'], {
cwd: dirPath,
timeout: 5000,
});
const lines = stdout.split('\n').filter((line) => line.trim().length > 0);
return { clean: lines.length === 0, modified: lines.length };
} catch {
return { clean: true, modified: 0 };
}
}
/**
* Check out (or create + check out) a branch.
* Relies on git itself to reject unsafe checkouts (dirty tree, non-fast-forward, etc.)
* and surfaces git's stderr so the UI can display a meaningful error.
*/
@IpcMethod()
async checkoutGitBranch(payload: {
branch: string;
create?: boolean;
path: string;
}): Promise<GitCheckoutResult> {
const { path: dirPath, branch, create } = payload;
if (!branch?.trim()) {
return { error: 'Branch name is required', success: false };
}
// Reject obviously invalid refs early to avoid a confusing git error
if (/[\s~^:?*[\\]/.test(branch) || branch.startsWith('-') || branch.includes('..')) {
return { error: `Invalid branch name: ${branch}`, success: false };
}
const execFileAsync = promisify(execFile);
const args = create ? ['checkout', '-b', branch] : ['checkout', branch];
try {
await execFileAsync('git', args, { cwd: dirPath, timeout: 10_000 });
return { success: true };
} catch (error: any) {
const stderr: string = (error?.stderr ?? error?.message ?? '').toString().trim();
logger.debug('[checkoutGitBranch] failed', { args, stderr });
return { error: stderr || 'git checkout failed', success: false };
}
}
/**
* Resolve the actual `.git` directory for a working tree.
* Supports both standard layouts and worktree pointer files (`.git` as a regular file).
*/
private async resolveGitDir(dirPath: string): Promise<string | undefined> {
const gitPath = path.join(dirPath, '.git');
try {
const content = await readFile(gitPath, 'utf8');
const worktreeMatch = /^gitdir:\s*(\S.*)$/m.exec(content.trim());
if (worktreeMatch) {
const resolved = worktreeMatch[1].trim();
return path.isAbsolute(resolved) ? resolved : path.resolve(dirPath, resolved);
}
} catch {
// `.git` is a directory (EISDIR) or missing — fall through
}
try {
const stat = await readdir(gitPath);
if (stat.length > 0) return gitPath;
} catch {
return undefined;
}
return undefined;
}
private async setSystemThemeMode(themeMode: ThemeMode) {
nativeTheme.themeSource = themeMode;
}
@@ -1,8 +1,15 @@
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import type { ClaudeAuthStatus } from '@lobechat/electron-client-ipc';
import type { ToolCategory, ToolStatus } from '@/core/infrastructure/ToolDetectorManager';
import { createLogger } from '@/utils/logger';
import { ControllerModule, IpcMethod } from './index';
const execPromise = promisify(exec);
const logger = createLogger('controllers:ToolDetectorCtr');
/**
@@ -112,4 +119,19 @@ export default class ToolDetectorCtr extends ControllerModule {
priority: detector.priority,
}));
}
/**
* Get Claude Code CLI auth/account status by running `claude auth status --json`.
* Returns null if the CLI is unavailable or the command fails.
*/
@IpcMethod()
async getClaudeAuthStatus(): Promise<ClaudeAuthStatus | null> {
try {
const { stdout } = await execPromise('claude auth status --json', { timeout: 5000 });
return JSON.parse(stdout.trim()) as ClaudeAuthStatus;
} catch (error) {
logger.debug('Failed to get claude auth status:', error);
return null;
}
}
}
@@ -0,0 +1,216 @@
import { EventEmitter } from 'node:events';
import { access, mkdtemp, readdir, readFile, rm, unlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { PassThrough } from 'node:stream';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import HeterogeneousAgentCtr from '../HeterogeneousAgentCtr';
const FAKE_DESKTOP_PATH = '/Users/fake/Desktop';
vi.mock('electron', () => ({
BrowserWindow: { getAllWindows: () => [] },
app: {
getPath: vi.fn((name: string) => (name === 'desktop' ? FAKE_DESKTOP_PATH : `/fake/${name}`)),
on: vi.fn(),
},
ipcMain: { handle: vi.fn() },
}));
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
verbose: vi.fn(),
warn: vi.fn(),
}),
}));
// Captures the most recent spawn() call so sendPrompt tests can assert on argv.
const spawnCalls: Array<{ args: string[]; command: string; options: any }> = [];
let nextFakeProc: any = null;
vi.mock('node:child_process', () => ({
spawn: (command: string, args: string[], options: any) => {
spawnCalls.push({ args, command, options });
return nextFakeProc;
},
}));
/**
* Build a fake ChildProcess that immediately exits cleanly. Records every
* stdin write on the returned `writes` array so tests can inspect the payload.
*/
const createFakeProc = () => {
const proc = new EventEmitter() as any;
const stdout = new PassThrough();
const stderr = new PassThrough();
const writes: string[] = [];
proc.stdout = stdout;
proc.stderr = stderr;
proc.stdin = {
end: vi.fn(),
write: vi.fn((chunk: string, cb?: () => void) => {
writes.push(chunk);
cb?.();
return true;
}),
};
proc.kill = vi.fn();
proc.killed = false;
// Exit asynchronously so the Promise returned by sendPrompt resolves cleanly.
setImmediate(() => {
stdout.end();
stderr.end();
proc.emit('exit', 0);
});
return { proc, writes };
};
describe('HeterogeneousAgentCtr', () => {
let appStoragePath: string;
beforeEach(async () => {
appStoragePath = await mkdtemp(path.join(tmpdir(), 'lobehub-hetero-'));
});
afterEach(async () => {
await rm(appStoragePath, { force: true, recursive: true });
});
describe('resolveImage', () => {
it('stores traversal-looking ids inside the cache root via a stable hash key', async () => {
const ctr = new HeterogeneousAgentCtr({ appStoragePath } as any);
const cacheDir = path.join(appStoragePath, 'heteroAgent/files');
const escapedTargetName = `${path.basename(appStoragePath)}-outside-storage`;
const escapePath = path.join(cacheDir, `../../../${escapedTargetName}`);
try {
await unlink(escapePath);
} catch {
// best-effort cleanup
}
await (ctr as any).resolveImage({
id: `../../../${escapedTargetName}`,
url: 'data:text/plain;base64,T1VUU0lERQ==',
});
const cacheEntries = await readdir(cacheDir);
expect(cacheEntries).toHaveLength(2);
expect(cacheEntries.every((entry) => /^[a-f0-9]{64}(?:\.meta)?$/.test(entry))).toBe(true);
await expect(access(escapePath)).rejects.toThrow();
try {
await unlink(escapePath);
} catch {
// best-effort cleanup
}
});
it('does not trust pre-seeded out-of-root traversal cache files as cache hits', async () => {
const ctr = new HeterogeneousAgentCtr({ appStoragePath } as any);
const cacheDir = path.join(appStoragePath, 'heteroAgent/files');
const traversalId = '../../preexisting-secret';
const outOfRootDataPath = path.join(cacheDir, traversalId);
const outOfRootMetaPath = path.join(cacheDir, `${traversalId}.meta`);
await writeFile(outOfRootDataPath, 'SECRET');
await writeFile(
outOfRootMetaPath,
JSON.stringify({ id: traversalId, mimeType: 'text/plain' }),
);
const result = await (ctr as any).resolveImage({
id: traversalId,
url: 'data:text/plain;base64,SUdOT1JFRA==',
});
expect(Buffer.from(result.buffer).toString('utf8')).toBe('IGNORED');
expect(result.mimeType).toBe('text/plain');
await expect(readFile(outOfRootDataPath, 'utf8')).resolves.toBe('SECRET');
});
});
describe('sendPrompt (claude-code)', () => {
beforeEach(() => {
spawnCalls.length = 0;
});
const runSendPrompt = async (prompt: string, sessionOverrides: Record<string, any> = {}) => {
const { proc, writes } = createFakeProc();
nextFakeProc = proc;
const ctr = new HeterogeneousAgentCtr({ appStoragePath } as any);
const { sessionId } = await ctr.startSession({
agentType: 'claude-code',
command: 'claude',
...sessionOverrides,
});
await ctr.sendPrompt({ prompt, sessionId });
const { args: cliArgs, command, options } = spawnCalls[0];
return { cliArgs, command, options, writes };
};
it('passes prompt via stdin stream-json — never as a positional arg', async () => {
const prompt = '-- 这是破折号测试 --help';
const { cliArgs, writes } = await runSendPrompt(prompt);
// Prompt must never appear in argv (that is what previously broke CC's arg parser).
expect(cliArgs).not.toContain(prompt);
// Stream-json input must be wired up.
expect(cliArgs).toContain('--input-format');
expect(cliArgs).toContain('--output-format');
expect(cliArgs.filter((a) => a === 'stream-json')).toHaveLength(2);
// Exactly one stdin write, carrying the prompt as a user message JSON line.
expect(writes).toHaveLength(1);
const line = writes[0].trimEnd();
expect(line.endsWith('\n') || writes[0].endsWith('\n')).toBe(true);
const msg = JSON.parse(line);
expect(msg).toMatchObject({
message: {
content: [{ text: prompt, type: 'text' }],
role: 'user',
},
type: 'user',
});
});
it.each([
'-flag-looking-prompt',
'--help please',
'- dash at start',
'-p -- mixed',
'normal prompt with -dash- inside',
])('accepts dash-containing prompt without leaking to argv: %s', async (prompt) => {
const { cliArgs, writes } = await runSendPrompt(prompt);
expect(cliArgs).not.toContain(prompt);
expect(writes).toHaveLength(1);
const msg = JSON.parse(writes[0].trimEnd());
expect(msg.message.content[0].text).toBe(prompt);
});
it('falls back to the user Desktop when no cwd is supplied', async () => {
const { options } = await runSendPrompt('hello');
// When launched from Finder the Electron parent cwd is `/` — the
// controller must override that with the user's Desktop so CC writes
// land somewhere sensible.
expect(options.cwd).toBe(FAKE_DESKTOP_PATH);
});
it('respects an explicit cwd passed to startSession', async () => {
const explicitCwd = '/Users/fake/projects/my-repo';
const { options } = await runSendPrompt('hello', { cwd: explicitCwd });
expect(options.cwd).toBe(explicitCwd);
});
});
});
+3 -2
View File
@@ -1,6 +1,7 @@
import type { DesktopHotkeyId } from '@lobechat/types';
import type { App } from '@/core/App';
import { IoCContainer } from '@/core/infrastructure/IoCContainer';
import type { ShortcutActionType } from '@/shortcuts';
import { IpcService } from '@/utils/ipc';
const shortcutDecorator = (name: string) => (target: any, methodName: string, descriptor?: any) => {
@@ -15,7 +16,7 @@ const shortcutDecorator = (name: string) => (target: any, methodName: string, de
/**
* shortcut inject decorator
*/
export const shortcut = (method: ShortcutActionType) => shortcutDecorator(method);
export const shortcut = (method: DesktopHotkeyId) => shortcutDecorator(method);
const protocolDecorator =
(urlType: string, action: string) => (target: any, methodName: string, descriptor?: any) => {
@@ -5,6 +5,7 @@ import BrowserWindowsCtr from './BrowserWindowsCtr';
import CliCtr from './CliCtr';
import DevtoolsCtr from './DevtoolsCtr';
import GatewayConnectionCtr from './GatewayConnectionCtr';
import HeterogeneousAgentCtr from './HeterogeneousAgentCtr';
import LocalFileCtr from './LocalFileCtr';
import McpCtr from './McpCtr';
import McpInstallCtr from './McpInstallCtr';
@@ -22,6 +23,7 @@ import UpdaterCtr from './UpdaterCtr';
import UploadFileCtr from './UploadFileCtr';
export const controllerIpcConstructors = [
HeterogeneousAgentCtr,
AuthCtr,
BrowserWindowsCtr,
CliCtr,
+2
View File
@@ -17,6 +17,7 @@ import { generateCliWrapper, getCliWrapperDir } from '@/modules/cliEmbedding';
import {
astSearchDetectors,
browserAutomationDetectors,
cliAgentDetectors,
contentSearchDetectors,
fileSearchDetectors,
type IToolDetector,
@@ -190,6 +191,7 @@ export class App {
const detectorCategories: Partial<Record<ToolCategory, IToolDetector[]>> = {
'runtime-environment': runtimeEnvironmentDetectors,
'cli-agents': cliAgentDetectors,
'ast-search': astSearchDetectors,
'browser-automation': browserAutomationDetectors,
'content-search': contentSearchDetectors,
@@ -4,7 +4,7 @@ import { join } from 'node:path';
import { APP_WINDOW_MIN_SIZE } from '@lobechat/desktop-bridge';
import type { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-client-ipc';
import type { BrowserWindowConstructorOptions } from 'electron';
import { BrowserWindow, ipcMain, screen, session as electronSession, shell } from 'electron';
import { app, BrowserWindow, ipcMain, screen, session as electronSession, shell } from 'electron';
import { preloadDir, resourcesDir } from '@/const/dir';
import { isMac } from '@/const/env';
@@ -259,6 +259,13 @@ export default class Browser {
browserWindow.on('focus', () => {
logger.debug(`[${this.identifier}] Window 'focus' event fired.`);
this.broadcast('windowFocused');
// Clear any completion badge once the user returns to the app.
try {
app.setBadgeCount(0);
if (process.platform === 'darwin' && app.dock) app.dock.setBadge('');
} catch {
/* noop — some platforms may not support badge counts */
}
});
}
@@ -1,4 +1,8 @@
import type { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-client-ipc';
import type {
MainBroadcastEventKey,
MainBroadcastParams,
TopicPopupInfo,
} from '@lobechat/electron-client-ipc';
import type { WebContents } from 'electron';
import { isLinux } from '@/const/env';
@@ -11,6 +15,9 @@ import type { App } from '../App';
import type { BrowserWindowOpts } from './Browser';
import Browser from './Browser';
const TOPIC_POPUP_TEMPLATE_ID: WindowTemplateIdentifiers = 'topicPopup';
const TOPIC_POPUP_PATH_RE = /^\/popup\/(agent|group)\/([^/?#]+)\/([^/?#]+)/;
// Create logger
const logger = createLogger('core:BrowserManager');
@@ -145,12 +152,62 @@ export class BrowserManager {
const browser = this.retrieveOrInitialize(browserOpts);
if (templateId === TOPIC_POPUP_TEMPLATE_ID) {
// Notify main-window SPAs so they can redirect to the popup instead of
// rendering the same conversation in two places. Re-emit on close to
// release the "topic is in popup" guard.
this.emitTopicPopupsChanged();
browser.browserWindow.once('closed', () => {
this.emitTopicPopupsChanged();
});
}
return {
browser,
identifier: windowId,
};
}
/**
* List currently-open topic popup windows (alive only). Used by the main
* SPA to decide whether to render the conversation or a redirect-to-popup
* guard.
*/
listTopicPopups(): TopicPopupInfo[] {
const popups: TopicPopupInfo[] = [];
this.browsers.forEach((browser, identifier) => {
if (!identifier.startsWith(`${TOPIC_POPUP_TEMPLATE_ID}_`)) return;
const webContents = browser.webContents;
if (!webContents || webContents.isDestroyed()) return;
const match = browser.options.path.match(TOPIC_POPUP_PATH_RE);
if (!match) return;
const scope = match[1] as 'agent' | 'group';
const id = match[2];
const topicId = match[3];
popups.push({
identifier,
scope,
topicId,
...(scope === 'agent' ? { agentId: id } : { groupId: id }),
});
});
return popups;
}
focusTopicPopup(identifier: string): boolean {
const browser = this.browsers.get(identifier);
if (!browser) return false;
const win = browser.browserWindow;
if (win.isMinimized()) win.restore();
win.show();
win.focus();
return true;
}
private emitTopicPopupsChanged(): void {
this.broadcastToAllWindows('topicPopupsChanged', { popups: this.listTopicPopups() });
}
/**
* Get all windows based on template
* @param templateId Template identifier
@@ -278,6 +335,16 @@ export class BrowserManager {
browser?.setWindowMinimumSize(size);
}
setWindowAlwaysOnTop(identifier: string, flag: boolean) {
const browser = this.browsers.get(identifier);
browser?.browserWindow.setAlwaysOnTop(flag);
}
isWindowAlwaysOnTop(identifier: string) {
const browser = this.browsers.get(identifier);
return browser?.browserWindow.isAlwaysOnTop() ?? false;
}
getIdentifierByWebContents(webContents: WebContents): string | null {
return this.webContentsMap.get(webContents) || null;
}
@@ -4,76 +4,89 @@ import { type App as AppCore } from '../../App';
import Browser, { type BrowserWindowOpts } from '../Browser';
// Use vi.hoisted to define mocks before hoisting
const { mockBrowserWindow, mockNativeTheme, mockIpcMain, mockScreen, MockBrowserWindow } =
vi.hoisted(() => {
const mockBrowserWindow = {
center: vi.fn(),
close: vi.fn(),
focus: vi.fn(),
getBounds: vi.fn().mockReturnValue({ height: 600, width: 800, x: 0, y: 0 }),
getContentBounds: vi.fn().mockReturnValue({ height: 600, width: 800 }),
hide: vi.fn(),
isDestroyed: vi.fn().mockReturnValue(false),
isFocused: vi.fn().mockReturnValue(true),
isFullScreen: vi.fn().mockReturnValue(false),
isMaximized: vi.fn().mockReturnValue(false),
isVisible: vi.fn().mockReturnValue(true),
loadFile: vi.fn().mockResolvedValue(undefined),
loadURL: vi.fn().mockResolvedValue(undefined),
maximize: vi.fn(),
minimize: vi.fn(),
on: vi.fn(),
once: vi.fn(),
setBackgroundColor: vi.fn(),
setBounds: vi.fn(),
setFullScreen: vi.fn(),
setPosition: vi.fn(),
setTitleBarOverlay: vi.fn(),
show: vi.fn(),
unmaximize: vi.fn(),
webContents: {
openDevTools: vi.fn(),
send: vi.fn(),
session: {
webRequest: {
onBeforeSendHeaders: vi.fn(),
onHeadersReceived: vi.fn(),
},
const {
mockElectronApp,
mockBrowserWindow,
mockNativeTheme,
mockIpcMain,
mockScreen,
MockBrowserWindow,
} = vi.hoisted(() => {
const mockBrowserWindow = {
center: vi.fn(),
close: vi.fn(),
focus: vi.fn(),
getBounds: vi.fn().mockReturnValue({ height: 600, width: 800, x: 0, y: 0 }),
getContentBounds: vi.fn().mockReturnValue({ height: 600, width: 800 }),
hide: vi.fn(),
isDestroyed: vi.fn().mockReturnValue(false),
isFocused: vi.fn().mockReturnValue(true),
isFullScreen: vi.fn().mockReturnValue(false),
isMaximized: vi.fn().mockReturnValue(false),
isVisible: vi.fn().mockReturnValue(true),
loadFile: vi.fn().mockResolvedValue(undefined),
loadURL: vi.fn().mockResolvedValue(undefined),
maximize: vi.fn(),
minimize: vi.fn(),
on: vi.fn(),
once: vi.fn(),
setBackgroundColor: vi.fn(),
setBounds: vi.fn(),
setFullScreen: vi.fn(),
setPosition: vi.fn(),
setTitleBarOverlay: vi.fn(),
show: vi.fn(),
unmaximize: vi.fn(),
webContents: {
openDevTools: vi.fn(),
send: vi.fn(),
session: {
webRequest: {
onBeforeSendHeaders: vi.fn(),
onHeadersReceived: vi.fn(),
},
on: vi.fn(),
setWindowOpenHandler: vi.fn(),
},
};
on: vi.fn(),
setWindowOpenHandler: vi.fn(),
},
};
return {
MockBrowserWindow: vi.fn().mockImplementation(() => mockBrowserWindow),
mockBrowserWindow,
mockIpcMain: {
handle: vi.fn(),
removeHandler: vi.fn(),
},
mockNativeTheme: {
off: vi.fn(),
on: vi.fn(),
shouldUseDarkColors: false,
themeSource: 'system',
},
mockScreen: {
getDisplayMatching: vi.fn().mockReturnValue({
workArea: { height: 1080, width: 1920, x: 0, y: 0 },
}),
getDisplayNearestPoint: vi.fn().mockReturnValue({
workArea: { height: 1080, width: 1920, x: 0, y: 0 },
}),
getPrimaryDisplay: vi.fn().mockReturnValue({
workArea: { height: 1080, width: 1920, x: 0, y: 0 },
}),
},
};
});
const mockElectronApp = {
dock: { setBadge: vi.fn() },
setBadgeCount: vi.fn(),
};
return {
MockBrowserWindow: vi.fn().mockImplementation(() => mockBrowserWindow),
mockElectronApp,
mockBrowserWindow,
mockIpcMain: {
handle: vi.fn(),
removeHandler: vi.fn(),
},
mockNativeTheme: {
off: vi.fn(),
on: vi.fn(),
shouldUseDarkColors: false,
themeSource: 'system',
},
mockScreen: {
getDisplayMatching: vi.fn().mockReturnValue({
workArea: { height: 1080, width: 1920, x: 0, y: 0 },
}),
getDisplayNearestPoint: vi.fn().mockReturnValue({
workArea: { height: 1080, width: 1920, x: 0, y: 0 },
}),
getPrimaryDisplay: vi.fn().mockReturnValue({
workArea: { height: 1080, width: 1920, x: 0, y: 0 },
}),
},
};
});
// Mock electron
vi.mock('electron', () => ({
app: mockElectronApp,
BrowserWindow: MockBrowserWindow,
ipcMain: mockIpcMain,
nativeTheme: mockNativeTheme,
@@ -12,8 +12,9 @@ import { RendererProtocolManager } from './RendererProtocolManager';
const logger = createLogger('core:RendererUrlManager');
// Vite build with root=monorepo preserves input path structure,
// so index.html ends up at apps/desktop/index.html in outDir.
// so index.html / popup.html end up under apps/desktop/ in outDir.
const SPA_ENTRY_HTML = join(rendererDir, 'apps', 'desktop', 'index.html');
const POPUP_ENTRY_HTML = join(rendererDir, 'apps', 'desktop', 'popup.html');
export class RendererUrlManager {
private readonly rendererProtocolManager: RendererProtocolManager;
@@ -66,7 +67,8 @@ export class RendererUrlManager {
/**
* Resolve renderer file path in production.
* Static assets map directly; all routes fall back to index.html (SPA).
* Static assets map directly; popup routes go to popup.html, all other
* routes fall back to index.html (SPA).
*/
resolveRendererFilePath = async (url: URL): Promise<string | null> => {
const pathname = url.pathname;
@@ -77,7 +79,12 @@ export class RendererUrlManager {
return pathExistsSync(filePath) ? filePath : null;
}
// All routes fallback to index.html (SPA)
// Topic popup window has its own SPA bundle.
if (pathname === '/popup' || pathname.startsWith('/popup/')) {
return POPUP_ENTRY_HTML;
}
// All other routes fallback to index.html (SPA)
return SPA_ENTRY_HTML;
};
@@ -41,6 +41,7 @@ export type ToolCategory =
| 'file-search'
| 'browser-automation'
| 'runtime-environment'
| 'cli-agents'
| 'system'
| 'custom';
@@ -1,6 +1,6 @@
import { DEFAULT_ELECTRON_DESKTOP_SHORTCUTS } from '@lobechat/const/desktopGlobalShortcuts';
import { globalShortcut } from 'electron';
import { DEFAULT_SHORTCUTS_CONFIG } from '@/shortcuts';
import { createLogger } from '@/utils/logger';
import type { App } from '../App';
@@ -77,8 +77,8 @@ export class ShortcutManager {
try {
logger.debug(`Updating shortcut ${id} to ${accelerator}`);
// 1. Check if ID is valid
if (!DEFAULT_SHORTCUTS_CONFIG[id]) {
// 1. Check if ID is valid (value may be empty string when disabled by default)
if (!(id in DEFAULT_ELECTRON_DESKTOP_SHORTCUTS)) {
logger.error(`Invalid shortcut ID: ${id}`);
return { errorType: 'INVALID_ID', success: false };
}
@@ -231,15 +231,15 @@ export class ShortcutManager {
// If no configuration, use default configuration
if (!config || Object.keys(config).length === 0) {
logger.debug('No shortcuts config found, using defaults');
this.shortcutsConfig = { ...DEFAULT_SHORTCUTS_CONFIG };
this.shortcutsConfig = { ...DEFAULT_ELECTRON_DESKTOP_SHORTCUTS };
this.saveShortcutsConfig();
} else {
// Filter out invalid shortcuts that are not in DEFAULT_SHORTCUTS_CONFIG
// Filter out invalid shortcuts that are not in DEFAULT_ELECTRON_DESKTOP_SHORTCUTS
const filteredConfig: Record<string, string> = {};
let hasInvalidKeys = false;
Object.entries(config).forEach(([id, accelerator]) => {
if (DEFAULT_SHORTCUTS_CONFIG[id]) {
if (id in DEFAULT_ELECTRON_DESKTOP_SHORTCUTS) {
filteredConfig[id] = accelerator;
} else {
hasInvalidKeys = true;
@@ -248,7 +248,7 @@ export class ShortcutManager {
});
// Ensure all default shortcuts are present
Object.entries(DEFAULT_SHORTCUTS_CONFIG).forEach(([id, defaultAccelerator]) => {
Object.entries(DEFAULT_ELECTRON_DESKTOP_SHORTCUTS).forEach(([id, defaultAccelerator]) => {
if (!(id in filteredConfig)) {
filteredConfig[id] = defaultAccelerator;
logger.debug(`Adding missing default shortcut: ${id} = ${defaultAccelerator}`);
@@ -267,7 +267,7 @@ export class ShortcutManager {
logger.debug('Loaded shortcuts config:', this.shortcutsConfig);
} catch (error) {
logger.error('Error loading shortcuts config:', error);
this.shortcutsConfig = { ...DEFAULT_SHORTCUTS_CONFIG };
this.shortcutsConfig = { ...DEFAULT_ELECTRON_DESKTOP_SHORTCUTS };
this.saveShortcutsConfig();
}
}
@@ -295,9 +295,9 @@ export class ShortcutManager {
Object.entries(this.shortcutsConfig).forEach(([id, accelerator]) => {
logger.debug(`Registering shortcut '${id}' with ${accelerator}`);
// Only register shortcuts that exist in DEFAULT_SHORTCUTS_CONFIG
if (!DEFAULT_SHORTCUTS_CONFIG[id]) {
logger.debug(`Skipping shortcut '${id}' - not found in DEFAULT_SHORTCUTS_CONFIG`);
// Only register shortcuts that exist in DEFAULT_ELECTRON_DESKTOP_SHORTCUTS
if (!(id in DEFAULT_ELECTRON_DESKTOP_SHORTCUTS)) {
logger.debug(`Skipping shortcut '${id}' - not found in DEFAULT_ELECTRON_DESKTOP_SHORTCUTS`);
return;
}
@@ -1,8 +1,7 @@
import { DEFAULT_ELECTRON_DESKTOP_SHORTCUTS } from '@lobechat/const/desktopGlobalShortcuts';
import { globalShortcut } from 'electron';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_SHORTCUTS_CONFIG } from '@/shortcuts';
import type { App } from '../../App';
import { ShortcutManager } from '../ShortcutManager';
@@ -26,10 +25,10 @@ vi.mock('@/utils/logger', () => ({
}),
}));
// Mock DEFAULT_SHORTCUTS_CONFIG
vi.mock('@/shortcuts', () => ({
DEFAULT_SHORTCUTS_CONFIG: {
showApp: 'Control+E',
// Mock desktop global shortcut defaults
vi.mock('@lobechat/const/desktopGlobalShortcuts', () => ({
DEFAULT_ELECTRON_DESKTOP_SHORTCUTS: {
showApp: '',
openSettings: 'CommandOrControl+,',
},
}));
@@ -115,7 +114,7 @@ describe('ShortcutManager', () => {
expect(mockStoreManager.get).toHaveBeenCalledWith('shortcuts');
expect(globalShortcut.unregisterAll).toHaveBeenCalled();
expect(globalShortcut.register).toHaveBeenCalledWith('Control+E', expect.any(Function));
expect(globalShortcut.register).not.toHaveBeenCalledWith('Control+E', expect.any(Function));
expect(globalShortcut.register).toHaveBeenCalledWith(
'CommandOrControl+,',
expect.any(Function),
@@ -145,7 +144,7 @@ describe('ShortcutManager', () => {
shortcutManager.initialize();
const config = shortcutManager.getShortcutsConfig();
expect(config).toEqual(DEFAULT_SHORTCUTS_CONFIG);
expect(config).toEqual(DEFAULT_ELECTRON_DESKTOP_SHORTCUTS);
});
});
@@ -346,8 +345,11 @@ describe('ShortcutManager', () => {
shortcutManager['loadShortcutsConfig']();
expect(shortcutManager['shortcutsConfig']).toEqual(DEFAULT_SHORTCUTS_CONFIG);
expect(mockStoreManager.set).toHaveBeenCalledWith('shortcuts', DEFAULT_SHORTCUTS_CONFIG);
expect(shortcutManager['shortcutsConfig']).toEqual(DEFAULT_ELECTRON_DESKTOP_SHORTCUTS);
expect(mockStoreManager.set).toHaveBeenCalledWith(
'shortcuts',
DEFAULT_ELECTRON_DESKTOP_SHORTCUTS,
);
});
it('should use defaults when config is empty', () => {
@@ -355,7 +357,7 @@ describe('ShortcutManager', () => {
shortcutManager['loadShortcutsConfig']();
expect(shortcutManager['shortcutsConfig']).toEqual(DEFAULT_SHORTCUTS_CONFIG);
expect(shortcutManager['shortcutsConfig']).toEqual(DEFAULT_ELECTRON_DESKTOP_SHORTCUTS);
});
it('should filter invalid keys from stored config', () => {
@@ -413,8 +415,11 @@ describe('ShortcutManager', () => {
shortcutManager['loadShortcutsConfig']();
expect(shortcutManager['shortcutsConfig']).toEqual(DEFAULT_SHORTCUTS_CONFIG);
expect(mockStoreManager.set).toHaveBeenCalledWith('shortcuts', DEFAULT_SHORTCUTS_CONFIG);
expect(shortcutManager['shortcutsConfig']).toEqual(DEFAULT_ELECTRON_DESKTOP_SHORTCUTS);
expect(mockStoreManager.set).toHaveBeenCalledWith(
'shortcuts',
DEFAULT_ELECTRON_DESKTOP_SHORTCUTS,
);
});
});
@@ -458,7 +463,7 @@ describe('ShortcutManager', () => {
expect(globalShortcut.register).toHaveBeenCalledWith('Ctrl+P', expect.any(Function));
});
it('should skip shortcuts not in DEFAULT_SHORTCUTS_CONFIG', () => {
it('should skip shortcuts not defined in default electron desktop shortcuts', () => {
shortcutManager['shortcutsConfig'] = {
showApp: 'Alt+E',
invalidKey: 'Ctrl+I',
+435
View File
@@ -0,0 +1,435 @@
import type { ChildProcess } from 'node:child_process';
import { spawn } from 'node:child_process';
import type { Readable } from 'node:stream';
import { createLogger } from '@/utils/logger';
import type {
ACPInitializeParams,
ACPPermissionRequest,
ACPPermissionResponse,
ACPServerCapabilities,
ACPSessionCancelParams,
ACPSessionInfo,
ACPSessionNewParams,
ACPSessionPromptParams,
ACPSessionUpdate,
FSReadTextFileParams,
FSReadTextFileResult,
FSWriteTextFileParams,
JsonRpcError,
JsonRpcNotification,
JsonRpcRequest,
JsonRpcResponse,
TerminalCreateParams,
TerminalCreateResult,
TerminalKillParams,
TerminalOutputParams,
TerminalOutputResult,
TerminalReleaseParams,
TerminalWaitForExitParams,
TerminalWaitForExitResult,
} from './types';
const logger = createLogger('libs:acp:client');
type PendingRequest = {
reject: (error: Error) => void;
resolve: (result: unknown) => void;
};
export interface ACPClientParams {
args?: string[];
command: string;
cwd?: string;
env?: Record<string, string>;
}
export interface ACPClientCallbacks {
onPermissionRequest?: (request: ACPPermissionRequest) => Promise<ACPPermissionResponse>;
onSessionComplete?: (sessionId: string) => void;
onSessionUpdate?: (update: ACPSessionUpdate) => void;
}
/**
* ACP Client that communicates with an ACP agent (e.g. Claude Code) over stdio JSON-RPC 2.0.
*
* Bidirectional: sends requests to agent AND handles incoming requests from agent
* (fs/read_text_file, fs/write_text_file, terminal/*, session/request_permission).
*/
export class ACPClient {
private buffer = '';
private callbacks: ACPClientCallbacks = {};
private nextId = 1;
private pendingRequests = new Map<number | string, PendingRequest>();
private process: ChildProcess | null = null;
private stderrLogs: string[] = [];
// Client-side method handlers (agent calls these)
private clientMethodHandlers = new Map<string, (params: any) => Promise<unknown>>();
constructor(private readonly params: ACPClientParams) {}
/**
* Register handlers for client-side methods that the agent can call back.
*/
registerClientMethods(handlers: {
'fs/read_text_file'?: (params: FSReadTextFileParams) => Promise<FSReadTextFileResult>;
'fs/write_text_file'?: (params: FSWriteTextFileParams) => Promise<void>;
'terminal/create'?: (params: TerminalCreateParams) => Promise<TerminalCreateResult>;
'terminal/kill'?: (params: TerminalKillParams) => Promise<void>;
'terminal/output'?: (params: TerminalOutputParams) => Promise<TerminalOutputResult>;
'terminal/release'?: (params: TerminalReleaseParams) => Promise<void>;
'terminal/wait_for_exit'?: (
params: TerminalWaitForExitParams,
) => Promise<TerminalWaitForExitResult>;
}) {
for (const [method, handler] of Object.entries(handlers)) {
if (handler) {
this.clientMethodHandlers.set(method, handler);
}
}
}
setCallbacks(callbacks: ACPClientCallbacks) {
this.callbacks = callbacks;
}
/**
* Spawn the agent process and initialize the ACP connection.
*/
async connect(): Promise<ACPServerCapabilities> {
const { command, args = [], env, cwd } = this.params;
this.process = spawn(command, args, {
cwd,
env: { ...process.env, ...env },
stdio: ['pipe', 'pipe', 'pipe'],
});
// Capture stderr
const stderr = this.process.stderr as Readable | null;
if (stderr) {
stderr.on('data', (chunk: Buffer) => {
const lines = chunk
.toString('utf8')
.split('\n')
.filter((l) => l.trim());
this.stderrLogs.push(...lines);
});
}
// Listen for stdout (JSON-RPC messages)
const stdout = this.process.stdout as Readable | null;
if (stdout) {
stdout.on('data', (chunk: Buffer) => {
this.handleData(chunk.toString('utf8'));
});
}
this.process.on('error', (err) => {
logger.error('ACP process error:', err);
});
this.process.on('exit', (code, signal) => {
logger.info('ACP process exited:', { code, signal });
// Reject all pending requests
for (const [id, pending] of this.pendingRequests) {
pending.reject(new Error(`ACP process exited (code=${code}, signal=${signal})`));
this.pendingRequests.delete(id);
}
});
// Initialize
const capabilities = await this.initialize();
return capabilities;
}
/**
* Send initialize request to the agent.
*/
private async initialize(): Promise<ACPServerCapabilities> {
const params: ACPInitializeParams = {
capabilities: {
fs: { readTextFile: true, writeTextFile: true },
terminal: true,
},
clientInfo: { name: 'lobehub-desktop', version: '1.0.0' },
protocolVersion: '0.1',
};
return this.sendRequest<ACPServerCapabilities>('initialize', params);
}
/**
* Create a new session.
*/
async createSession(params?: ACPSessionNewParams): Promise<ACPSessionInfo> {
return this.sendRequest<ACPSessionInfo>('session/new', params);
}
/**
* Send a prompt to an existing session.
*/
async sendPrompt(params: ACPSessionPromptParams): Promise<void> {
return this.sendRequest<void>('session/prompt', params);
}
/**
* Cancel an ongoing session operation.
*/
async cancelSession(params: ACPSessionCancelParams): Promise<void> {
return this.sendRequest<void>('session/cancel', params);
}
/**
* Respond to a permission request from the agent.
*/
respondToPermission(requestId: string, response: ACPPermissionResponse): void {
this.sendResponse(requestId, response);
}
/**
* Disconnect from the agent and kill the process.
*/
async disconnect(): Promise<void> {
if (this.process) {
this.process.stdin?.end();
this.process.kill('SIGTERM');
// Force kill after timeout
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
if (this.process && !this.process.killed) {
this.process.kill('SIGKILL');
}
resolve();
}, 5000);
this.process?.on('exit', () => {
clearTimeout(timeout);
resolve();
});
});
this.process = null;
}
}
getStderrLogs(): string[] {
return this.stderrLogs;
}
// ============================================================
// JSON-RPC transport layer
// ============================================================
private sendRequest<T>(method: string, params?: object): Promise<T> {
return new Promise((resolve, reject) => {
const id = this.nextId++;
const request: JsonRpcRequest = {
id,
jsonrpc: '2.0',
method,
params,
};
this.pendingRequests.set(id, {
reject,
resolve: resolve as (result: unknown) => void,
});
this.writeMessage(request);
});
}
private sendResponse(id: number | string, result: unknown): void {
const response: JsonRpcResponse = {
id,
jsonrpc: '2.0',
result,
};
this.writeMessage(response);
}
private sendErrorResponse(id: number | string, error: JsonRpcError): void {
const response: JsonRpcResponse = {
error,
id,
jsonrpc: '2.0',
};
this.writeMessage(response);
}
private writeMessage(message: JsonRpcRequest | JsonRpcResponse | JsonRpcNotification): void {
if (!this.process?.stdin?.writable) {
logger.error('Cannot write to ACP process: stdin not writable');
return;
}
const json = JSON.stringify(message);
const content = `Content-Length: ${Buffer.byteLength(json)}\r\n\r\n${json}`;
this.process.stdin.write(content);
}
/**
* Handle incoming data from stdout, parsing JSON-RPC messages.
* Uses Content-Length header framing (LSP-style).
*/
private handleData(data: string): void {
this.buffer += data;
while (true) {
// Try to parse a complete message from the buffer
const headerEnd = this.buffer.indexOf('\r\n\r\n');
if (headerEnd === -1) break;
const header = this.buffer.slice(0, headerEnd);
const contentLengthMatch = header.match(/Content-Length:\s*(\d+)/i);
if (!contentLengthMatch) {
// Try parsing as raw JSON (some agents don't use Content-Length headers)
const newlineIdx = this.buffer.indexOf('\n');
if (newlineIdx === -1) break;
const line = this.buffer.slice(0, newlineIdx).trim();
this.buffer = this.buffer.slice(newlineIdx + 1);
if (line) {
try {
const message = JSON.parse(line);
this.handleMessage(message);
} catch {
// Not valid JSON, skip
}
}
continue;
}
const contentLength = Number.parseInt(contentLengthMatch[1], 10);
const messageStart = headerEnd + 4; // after \r\n\r\n
const messageEnd = messageStart + contentLength;
if (Buffer.byteLength(this.buffer.slice(messageStart)) < contentLength) {
// Not enough data yet
break;
}
const messageStr = this.buffer.slice(messageStart, messageEnd);
this.buffer = this.buffer.slice(messageEnd);
try {
const message = JSON.parse(messageStr);
this.handleMessage(message);
} catch (err) {
logger.error('Failed to parse ACP JSON-RPC message:', err);
}
}
}
/**
* Route incoming JSON-RPC messages.
*/
private handleMessage(message: JsonRpcRequest | JsonRpcResponse | JsonRpcNotification): void {
// Response to our request
if ('id' in message && message.id !== null && !('method' in message)) {
const response = message as JsonRpcResponse;
const pending = this.pendingRequests.get(response.id!);
if (pending) {
this.pendingRequests.delete(response.id!);
if (response.error) {
pending.reject(
new Error(`ACP error [${response.error.code}]: ${response.error.message}`),
);
} else {
pending.resolve(response.result);
}
}
return;
}
// Incoming request or notification from agent
if ('method' in message) {
const method = message.method;
const params = message.params || {};
// Notification (no id) — e.g., session/update
if (!('id' in message) || message.id === undefined || message.id === null) {
this.handleNotification(method, params);
return;
}
// Request (has id) — agent calling client methods
this.handleIncomingRequest(message as JsonRpcRequest);
}
}
/**
* Handle notifications from the agent (no response expected).
*/
private handleNotification(method: string, params: Record<string, unknown> | object): void {
switch (method) {
case 'session/update': {
if (this.callbacks.onSessionUpdate) {
this.callbacks.onSessionUpdate(params as unknown as ACPSessionUpdate);
}
break;
}
default: {
logger.warn('Unhandled ACP notification:', method);
}
}
}
/**
* Handle incoming requests from the agent (response required).
*/
private async handleIncomingRequest(request: JsonRpcRequest): Promise<void> {
const { id, method, params } = request;
// Special handling for permission requests
if (method === 'session/request_permission') {
if (this.callbacks.onPermissionRequest) {
try {
const response = await this.callbacks.onPermissionRequest(
params as unknown as ACPPermissionRequest,
);
this.sendResponse(id, response);
} catch (err) {
this.sendErrorResponse(id, {
code: -32000,
message: err instanceof Error ? err.message : 'Permission request failed',
});
}
} else {
// Auto-allow if no handler
const permReq = params as unknown as ACPPermissionRequest;
const allowOption = permReq.options?.find((o) => o.kind === 'allow_once');
this.sendResponse(id, {
kind: 'selected',
optionId: allowOption?.optionId || permReq.options?.[0]?.optionId,
});
}
return;
}
// Delegate to registered client method handlers
const handler = this.clientMethodHandlers.get(method);
if (handler) {
try {
const result = await handler(params);
this.sendResponse(id, result ?? null);
} catch (err) {
this.sendErrorResponse(id, {
code: -32000,
message: err instanceof Error ? err.message : 'Client method failed',
});
}
} else {
this.sendErrorResponse(id, {
code: -32601,
message: `Method not found: ${method}`,
});
}
}
}
+3
View File
@@ -0,0 +1,3 @@
export type { ACPClientCallbacks, ACPClientParams } from './client';
export { ACPClient } from './client';
export type * from './types';
+326
View File
@@ -0,0 +1,326 @@
/**
* ACP (Agent Client Protocol) type definitions
* Based on: https://agentclientprotocol.com/protocol/schema
*/
// ============================================================
// JSON-RPC 2.0 base types
// ============================================================
export interface JsonRpcRequest {
id: number | string;
jsonrpc: '2.0';
method: string;
params?: Record<string, unknown> | object;
}
export interface JsonRpcResponse {
error?: JsonRpcError;
id: number | string | null;
jsonrpc: '2.0';
result?: unknown;
}
export interface JsonRpcNotification {
jsonrpc: '2.0';
method: string;
params?: Record<string, unknown>;
}
export interface JsonRpcError {
code: number;
data?: unknown;
message: string;
}
// ============================================================
// ACP Capabilities
// ============================================================
export interface ACPCapabilities {
audio?: boolean;
embeddedContext?: boolean;
fs?: {
readTextFile?: boolean;
writeTextFile?: boolean;
};
image?: boolean;
terminal?: boolean;
}
export interface ACPServerCapabilities {
modes?: ACPMode[];
name: string;
protocolVersion: string;
version?: string;
}
export interface ACPMode {
description?: string;
id: string;
name: string;
}
// ============================================================
// Session types
// ============================================================
export interface ACPSessionInfo {
createdAt?: string;
id: string;
title?: string;
}
// ============================================================
// Content block types (used in session/update)
// ============================================================
export type ACPContentBlock =
| ACPTextContent
| ACPImageContent
| ACPAudioContent
| ACPResourceContent
| ACPResourceLinkContent;
export interface ACPTextContent {
annotations?: Record<string, unknown>;
text: string;
type: 'text';
}
export interface ACPImageContent {
annotations?: Record<string, unknown>;
data: string;
mimeType: string;
type: 'image';
uri?: string;
}
export interface ACPAudioContent {
annotations?: Record<string, unknown>;
data: string;
mimeType: string;
type: 'audio';
}
export interface ACPResourceContent {
annotations?: Record<string, unknown>;
resource: {
blob?: string;
mimeType?: string;
text?: string;
uri: string;
};
type: 'resource';
}
export interface ACPResourceLinkContent {
annotations?: Record<string, unknown>;
description?: string;
mimeType?: string;
name: string;
size?: number;
title?: string;
type: 'resource_link';
uri: string;
}
// ============================================================
// Tool call types
// ============================================================
export type ACPToolCallKind =
| 'read'
| 'edit'
| 'delete'
| 'move'
| 'search'
| 'execute'
| 'think'
| 'fetch'
| 'other';
export type ACPToolCallStatus = 'pending' | 'in_progress' | 'completed' | 'failed';
export interface ACPToolCallDiffContent {
newText: string;
oldText: string;
path: string;
type: 'diff';
}
export interface ACPToolCallTerminalContent {
command?: string;
exitCode?: number;
output: string;
type: 'terminal';
}
export type ACPToolCallContent =
| ACPTextContent
| ACPImageContent
| ACPToolCallDiffContent
| ACPToolCallTerminalContent;
export interface ACPToolCallLocation {
endLine?: number;
path: string;
startLine?: number;
}
export interface ACPToolCallUpdate {
content?: ACPToolCallContent[];
kind?: ACPToolCallKind;
locations?: ACPToolCallLocation[];
rawInput?: string;
rawOutput?: string;
status?: ACPToolCallStatus;
title: string;
toolCallId: string;
}
// ============================================================
// Session update notification
// ============================================================
export type ACPMessageRole = 'assistant' | 'user' | 'thought';
export interface ACPMessageChunk {
content: ACPContentBlock[];
role: ACPMessageRole;
}
export interface ACPSessionUpdate {
messageChunks?: ACPMessageChunk[];
sessionId: string;
toolCalls?: ACPToolCallUpdate[];
}
// ============================================================
// Permission request types
// ============================================================
export interface ACPPermissionOption {
kind: 'allow_once' | 'allow_always' | 'reject_once' | 'reject_always';
name: string;
optionId: string;
}
export interface ACPPermissionRequest {
message?: string;
options: ACPPermissionOption[];
sessionId: string;
toolCall?: ACPToolCallUpdate;
}
export interface ACPPermissionResponse {
kind: 'selected' | 'cancelled';
optionId?: string;
}
// ============================================================
// Client method params (agent → client)
// ============================================================
export interface FSReadTextFileParams {
path: string;
}
export interface FSReadTextFileResult {
text: string;
}
export interface FSWriteTextFileParams {
path: string;
text: string;
}
export interface TerminalCreateParams {
command: string;
cwd?: string;
env?: Record<string, string>;
}
export interface TerminalCreateResult {
terminalId: string;
}
export interface TerminalOutputParams {
terminalId: string;
}
export interface TerminalOutputResult {
exitCode?: number;
isRunning: boolean;
output: string;
}
export interface TerminalWaitForExitParams {
terminalId: string;
timeout?: number;
}
export interface TerminalWaitForExitResult {
exitCode: number;
output: string;
}
export interface TerminalKillParams {
terminalId: string;
}
export interface TerminalReleaseParams {
terminalId: string;
}
// ============================================================
// Agent method params (client → agent)
// ============================================================
export interface ACPInitializeParams {
capabilities?: ACPCapabilities;
clientInfo?: {
name: string;
version: string;
};
protocolVersion: string;
}
export interface ACPSessionNewParams {
title?: string;
}
export interface ACPSessionPromptParams {
content: ACPContentBlock[];
sessionId: string;
}
export interface ACPSessionCancelParams {
sessionId: string;
}
// ============================================================
// Broadcast event types (main → renderer)
// ============================================================
export interface ACPSessionUpdateEvent {
sessionId: string;
update: ACPSessionUpdate;
}
export interface ACPPermissionRequestEvent {
message?: string;
options: ACPPermissionOption[];
requestId: string;
sessionId: string;
toolCall?: ACPToolCallUpdate;
}
export interface ACPSessionErrorEvent {
error: string;
sessionId: string;
}
export interface ACPSessionCompleteEvent {
sessionId: string;
}
@@ -0,0 +1,145 @@
import { exec } from 'node:child_process';
import { platform } from 'node:os';
import { promisify } from 'node:util';
import type { IToolDetector, ToolStatus } from '@/core/infrastructure/ToolDetectorManager';
import { createCommandDetector } from '@/core/infrastructure/ToolDetectorManager';
const execPromise = promisify(exec);
/**
* Detector that resolves a command path via which/where, then validates
* the binary by matching `--version` (or `--help`) output against a keyword
* to avoid collisions with unrelated executables of the same name.
*/
const createValidatedDetector = (options: {
candidates: string[];
description: string;
name: string;
priority: number;
validateFlag?: string;
validateKeywords: string[];
}): IToolDetector => {
const {
name,
description,
priority,
candidates,
validateFlag = '--version',
validateKeywords,
} = options;
return {
description,
async detect(): Promise<ToolStatus> {
const whichCmd = platform() === 'win32' ? 'where' : 'which';
for (const cmd of candidates) {
try {
const { stdout: pathOut } = await execPromise(`${whichCmd} ${cmd}`, { timeout: 3000 });
const toolPath = pathOut.trim().split('\n')[0];
if (!toolPath) continue;
const { stdout: out } = await execPromise(`${cmd} ${validateFlag}`, { timeout: 5000 });
const output = out.trim();
const lowered = output.toLowerCase();
if (!validateKeywords.some((kw) => lowered.includes(kw.toLowerCase()))) continue;
return {
available: true,
path: toolPath,
version: output.split('\n')[0],
};
} catch {
continue;
}
}
return { available: false };
},
name,
priority,
};
};
/**
* Claude Code CLI
* @see https://docs.claude.com/en/docs/claude-code
*/
export const claudeCodeDetector: IToolDetector = createValidatedDetector({
candidates: ['claude'],
description: 'Claude Code - Anthropic official agentic coding CLI',
name: 'claude',
priority: 1,
validateKeywords: ['claude code'],
});
/**
* OpenAI Codex CLI
* @see https://github.com/openai/codex
*/
export const codexDetector: IToolDetector = createValidatedDetector({
candidates: ['codex'],
description: 'Codex - OpenAI agentic coding CLI',
name: 'codex',
priority: 2,
validateKeywords: ['codex'],
});
/**
* Google Gemini CLI
* @see https://github.com/google-gemini/gemini-cli
*/
export const geminiCliDetector: IToolDetector = createValidatedDetector({
candidates: ['gemini'],
description: 'Gemini CLI - Google agentic coding CLI',
name: 'gemini',
priority: 3,
validateKeywords: ['gemini'],
});
/**
* Qwen Code CLI
* @see https://github.com/QwenLM/qwen-code
*/
export const qwenCodeDetector: IToolDetector = createValidatedDetector({
candidates: ['qwen'],
description: 'Qwen Code - Alibaba Qwen agentic coding CLI',
name: 'qwen',
priority: 4,
validateKeywords: ['qwen'],
});
/**
* Kimi CLI (Moonshot)
* @see https://github.com/MoonshotAI/kimi-cli
*/
export const kimiCliDetector: IToolDetector = createValidatedDetector({
candidates: ['kimi'],
description: 'Kimi CLI - Moonshot AI agentic coding CLI',
name: 'kimi',
priority: 5,
validateKeywords: ['kimi'],
});
/**
* Aider - AI pair programming CLI
* Generic command detector; name collision is unlikely.
* @see https://github.com/Aider-AI/aider
*/
export const aiderDetector: IToolDetector = createCommandDetector('aider', {
description: 'Aider - AI pair programming in your terminal',
priority: 6,
});
/**
* All CLI agent detectors
*/
export const cliAgentDetectors: IToolDetector[] = [
claudeCodeDetector,
codexDetector,
geminiCliDetector,
qwenCodeDetector,
kimiCliDetector,
aiderDetector,
];
@@ -6,6 +6,7 @@
*/
export { browserAutomationDetectors } from './agentBrowserDetectors';
export { cliAgentDetectors } from './cliAgentDetectors';
export { astSearchDetectors, contentSearchDetectors } from './contentSearchDetectors';
export { fileSearchDetectors } from './fileSearchDetectors';
export { runtimeEnvironmentDetectors } from './runtimeEnvironmentDetectors';
-20
View File
@@ -1,20 +0,0 @@
/**
* Shortcut action type enum
*/
export const ShortcutActionEnum = {
openSettings: 'openSettings',
/**
* Show/hide main window
*/
showApp: 'showApp',
} as const;
export type ShortcutActionType = (typeof ShortcutActionEnum)[keyof typeof ShortcutActionEnum];
/**
* Default shortcut configuration
*/
export const DEFAULT_SHORTCUTS_CONFIG: Record<ShortcutActionType, string> = {
[ShortcutActionEnum.showApp]: 'Control+E',
[ShortcutActionEnum.openSettings]: 'CommandOrControl+,',
};
-1
View File
@@ -1 +0,0 @@
export * from './config';
@@ -0,0 +1,9 @@
{
"name": "@lobechat/business-const",
"version": "0.0.0",
"private": true,
"exports": {
".": "./src/index.ts"
},
"main": "./src/index.ts"
}
@@ -0,0 +1,6 @@
export const BRANDING_LOGO_URL = '';
export const BRANDING_NAME = 'LobeHub';
export const DEFAULT_EMBEDDING_PROVIDER = 'openai';
export const DEFAULT_MINI_PROVIDER = 'openai';
export const DEFAULT_PROVIDER = 'openai';
export const ORG_NAME = 'LobeHub';
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@lobechat/types",
"version": "0.0.0",
"private": true,
"exports": {
".": "./src/index.ts"
},
"main": "./src/index.ts"
}
+19
View File
@@ -0,0 +1,19 @@
/**
* Desktop isolated workspace stub.
*
* `@lobechat/types` is only consumed via `import type` in desktop code and in
* the `@lobechat/const` entrypoints it reaches (currently `desktopGlobalShortcuts`).
* Those specifiers are erased at build time, so this package has no runtime
* exports — we only need to surface the types that reach the desktop tsgo
* project. Keep these in sync with `packages/types/src/hotkey.ts`.
*/
export type DesktopHotkeyId = 'openSettings' | 'showApp';
export interface DesktopHotkeyItem {
id: DesktopHotkeyId;
keys: string;
nonEditable?: boolean;
}
export type DesktopHotkeyConfig = Record<DesktopHotkeyId, string>;
+3
View File
@@ -1,6 +1,9 @@
{
"about": "حول",
"advanceSettings": "الإعدادات المتقدمة",
"agentOnboardingPromo.actionLabel": "جرّب الآن",
"agentOnboardingPromo.description": "قم بإعداد فرق الوكلاء لديك عبر محادثة سريعة مع Lobe AI. ستبقى وكلاؤك الحاليون دون تغيير.",
"agentOnboardingPromo.title": "المعالج السريع",
"alert.cloud.action": "جرّب الآن",
"alert.cloud.desc": "جميع المستخدمين المسجلين يحصلون على {{credit}} من أرصدة الحوسبة المجانية شهريًا — دون الحاجة إلى إعداد. يشمل المزامنة السحابية العالمية والبحث المتقدم على الويب.",
"alert.cloud.descOnMobile": "جميع المستخدمين المسجلين يحصلون على {{credit}} من أرصدة الحوسبة المجانية شهريًا — دون الحاجة إلى إعداد.",
+3
View File
@@ -1,6 +1,9 @@
{
"about": "Относно",
"advanceSettings": "Разширени настройки",
"agentOnboardingPromo.actionLabel": "Опитайте сега",
"agentOnboardingPromo.description": "Настройте своите екипи от агенти в кратък чат с Lobe AI. Съществуващите ви агенти ще останат непроменени.",
"agentOnboardingPromo.title": "Бърз съветник",
"alert.cloud.action": "Опитайте сега",
"alert.cloud.desc": "Всички регистрирани потребители получават {{credit}} безплатни изчислителни кредити на месец — без нужда от настройка. Включва глобална синхронизация в облака и разширено търсене в мрежата.",
"alert.cloud.descOnMobile": "Всички регистрирани потребители получават {{credit}} безплатни изчислителни кредити на месец — без нужда от настройка.",
+3
View File
@@ -1,6 +1,9 @@
{
"about": "Über",
"advanceSettings": "Erweiterte Einstellungen",
"agentOnboardingPromo.actionLabel": "Jetzt ausprobieren",
"agentOnboardingPromo.description": "Richte deine Agent-Teams in einem kurzen Chat mit Lobe AI ein. Deine vorhandenen Agenten bleiben unverändert.",
"agentOnboardingPromo.title": "Schnellassistent",
"alert.cloud.action": "Jetzt ausprobieren",
"alert.cloud.desc": "Alle registrierten Nutzer erhalten monatlich {{credit}} kostenlose Rechen-Credits keine Einrichtung erforderlich. Inklusive globaler Cloud-Synchronisierung und erweiterter Websuche.",
"alert.cloud.descOnMobile": "Alle registrierten Nutzer erhalten monatlich {{credit}} kostenlose Rechen-Credits keine Einrichtung erforderlich.",
+1 -1
View File
@@ -226,7 +226,7 @@
"stats.updatedAt": "Updated at",
"stats.welcome": "{{username}}, this is your <span>{{days}}</span> day with {{appName}}",
"stats.words": "Total Words",
"tab.apikey": "API Key Management",
"tab.apikey": "API Key",
"tab.profile": "My Account",
"tab.security": "Security",
"tab.stats": "Statistics",
+16
View File
@@ -18,6 +18,7 @@
"agentDefaultMessage": "Hi, Im **{{name}}**. One sentence is enough.\n\nWant me to match your workflow better? Go to [Agent Settings]({{url}}) and fill in the Agent Profile (you can edit it anytime).",
"agentDefaultMessageWithSystemRole": "Hi, Im **{{name}}**. One sentence is enough—you're in control.",
"agentDefaultMessageWithoutEdit": "Hi, Im **{{name}}**. One sentence is enough—you're in control.",
"agentSidebar.externalTag": "External",
"agents": "Agents",
"artifact.generating": "Generating",
"artifact.inThread": "Cannot view in subtopic, please switch to the main conversation area to open",
@@ -123,6 +124,7 @@
"groupWizard.searchTemplates": "Search templates...",
"groupWizard.title": "Create Group",
"groupWizard.useTemplate": "Use Template",
"heteroAgent.resumeReset.cwdChanged": "Working directory changed. Previous Claude Code session can only be resumed from its original directory, so a new conversation has started.",
"hideForYou": "Direct message content is hidden. Please enable 'Show Direct Message Content' in settings to view.",
"history.title": "The Agent will keep only the latest {{count}} messages.",
"historyRange": "History Range",
@@ -223,6 +225,7 @@
"minimap.senderAssistant": "Agent",
"minimap.senderUser": "You",
"newAgent": "Create Agent",
"newClaudeCodeAgent": "Add Claude Code",
"newGroupChat": "Create Group",
"newPage": "Create Page",
"noAgentsYet": "This group has no members yet. Click the + button to invite agents.",
@@ -234,6 +237,7 @@
"operation.contextCompression": "Context too long, compressing history...",
"operation.execAgentRuntime": "Preparing response",
"operation.execClientTask": "Executing task",
"operation.execHeterogeneousAgent": "Running agent",
"operation.execServerAgentRuntime": "Task is running in the server. You are safe to leave this page",
"operation.sendMessage": "Sending message",
"owner": "Group owner",
@@ -418,6 +422,15 @@
"tool.intervention.mode.autoRunDesc": "Automatically approve all tool executions",
"tool.intervention.mode.manual": "Manual",
"tool.intervention.mode.manualDesc": "Manual approval required for each invocation",
"tool.intervention.onboarding.agentIdentity.applyHint": "The new identity will appear after approval.",
"tool.intervention.onboarding.agentIdentity.description": "Approving this change updates the Agent shown in Inbox and in this onboarding conversation.",
"tool.intervention.onboarding.agentIdentity.emoji": "Agent avatar",
"tool.intervention.onboarding.agentIdentity.eyebrow": "Onboarding approval",
"tool.intervention.onboarding.agentIdentity.name": "Agent name",
"tool.intervention.onboarding.agentIdentity.targetInbox": "Inbox Agent",
"tool.intervention.onboarding.agentIdentity.targetOnboarding": "Current onboarding Agent",
"tool.intervention.onboarding.agentIdentity.targets": "Applies to",
"tool.intervention.onboarding.agentIdentity.title": "Confirm Agent identity update",
"tool.intervention.pending": "Pending",
"tool.intervention.reject": "Reject",
"tool.intervention.rejectAndContinue": "Reject and Retry",
@@ -467,6 +480,9 @@
"viewMode.wideScreen": "Widescreen",
"workflow.awaitingConfirmation": "Awaiting your confirmation",
"workflow.failedSuffix": "(failed)",
"workflow.summaryFailed": "{{count}} failed",
"workflow.summaryMoreTools": "+{{count}} more",
"workflow.summaryTotalCalls": "{{count}} calls total",
"workflow.thoughtForDuration": "Thought for {{duration}}",
"workflow.toolDisplayName.activateDevice": "Activated device",
"workflow.toolDisplayName.activateSkill": "Activated a skill",
+3
View File
@@ -1,6 +1,9 @@
{
"about": "About",
"advanceSettings": "Advanced Settings",
"agentOnboardingPromo.actionLabel": "Try it now",
"agentOnboardingPromo.description": "Set up your agent teams in a quick chat with Lobe AI. Your existing agents remain unchanged.",
"agentOnboardingPromo.title": "Quick Wizard",
"alert.cloud.action": "Try now",
"alert.cloud.desc": "All registered users get {{credit}} free computing credits per month—no setup needed. Includes global cloud sync and advanced web search.",
"alert.cloud.descOnMobile": "All registered users get {{credit}} free computing credits per month—no setup needed.",
+3 -1
View File
@@ -133,5 +133,7 @@
"window.close": "Close window",
"window.maximize": "Maximize window",
"window.minimize": "Minimize window",
"window.restore": "Restore window"
"window.pinToTop": "Pin on top",
"window.restore": "Restore window",
"window.unpinFromTop": "Unpin from top"
}
+26
View File
@@ -90,6 +90,32 @@
"pageEditor.exportDialogTitle": "Export Page",
"pageEditor.exportError": "Failed to export the page",
"pageEditor.exportSuccess": "Page exported successfully",
"pageEditor.history.backToCopilot": "Copilot",
"pageEditor.history.compare": "Compare",
"pageEditor.history.compareCurrentLabel": "Current",
"pageEditor.history.compareDescription": "Compare the current content with the selected history state",
"pageEditor.history.compareError": "Failed to load diff preview",
"pageEditor.history.compareModalTitle": "Compare",
"pageEditor.history.compareOldLabel": "Restore To",
"pageEditor.history.compareTitle": "Version Diff",
"pageEditor.history.current": "Current",
"pageEditor.history.dayLabel.today": "Today",
"pageEditor.history.dayLabel.yesterday": "Yesterday",
"pageEditor.history.empty": "No version history yet",
"pageEditor.history.itemVersionLabel": "v{{version}}",
"pageEditor.history.restore": "Restore",
"pageEditor.history.restoreConfirm.content": "Restore the page to how it was at {{savedAt}}? Your current content will be replaced and saved.",
"pageEditor.history.restoreConfirm.title": "Restore from history",
"pageEditor.history.restoreError": "Failed to restore from history",
"pageEditor.history.saveSource.autosave": "Auto Save",
"pageEditor.history.saveSource.llm_call": "AI Edit",
"pageEditor.history.saveSource.manual": "Manual Save",
"pageEditor.history.saveSource.restore": "Restore",
"pageEditor.history.saveSource.system": "System",
"pageEditor.history.title": "Version History",
"pageEditor.history.versionCount_one": "{{count}} version",
"pageEditor.history.versionCount_other": "{{count}} versions",
"pageEditor.history.versionLabel": "v{{version}}",
"pageEditor.linkCopied": "Link copied",
"pageEditor.menu.copyLink": "Copy Link",
"pageEditor.menu.export": "Export",
+2
View File
@@ -1,4 +1,6 @@
{
"features.agentWorkingPanel.desc": "A place to view an agent's progress and accessible resources.",
"features.agentWorkingPanel.title": "Working Panel",
"features.assistantMessageGroup.desc": "Group agent messages and their tool call results together for display",
"features.assistantMessageGroup.title": "Agent Message Grouping",
"features.gatewayMode.desc": "Execute agent tasks on the server via Gateway WebSocket instead of running locally. Enables faster execution and reduces client resource usage.",
+1
View File
@@ -230,6 +230,7 @@
"providerModels.item.modelConfig.extendParams.options.imageAspectRatio2.hint": "For Nano Banana 2; controls aspect ratio of generated images (supports extra-wide 1:4, 4:1, 1:8, 8:1).",
"providerModels.item.modelConfig.extendParams.options.imageResolution.hint": "For Gemini 3 image generation models; controls resolution of generated images.",
"providerModels.item.modelConfig.extendParams.options.imageResolution2.hint": "For Gemini 3.1 Flash Image models; controls resolution of generated images (supports 512px).",
"providerModels.item.modelConfig.extendParams.options.opus47Effort.hint": "For Claude Opus 4.7; controls effort level (low/medium/high/xhigh/max).",
"providerModels.item.modelConfig.extendParams.options.reasoningBudgetToken.hint": "For Claude, Qwen3 and similar; controls token budget for reasoning.",
"providerModels.item.modelConfig.extendParams.options.reasoningBudgetToken32k.hint": "For GLM-5 and GLM-4.7; controls token budget for reasoning (max 32k).",
"providerModels.item.modelConfig.extendParams.options.reasoningBudgetToken80k.hint": "For Qwen3 series; controls token budget for reasoning (max 80k).",
+2
View File
@@ -1,4 +1,6 @@
{
"billboard.learnMore": "Learn more",
"billboard.menuLabel": "Announcements",
"inbox.archiveAll": "Archive all",
"inbox.empty": "No notifications yet",
"inbox.emptyUnread": "No unread notifications",
+5
View File
@@ -51,6 +51,11 @@
"agent.welcome.sentence.1": "So nice to meet you! Lets get to know each other.",
"agent.welcome.sentence.2": "What kind of partner do you want me to be?",
"agent.welcome.sentence.3": "First, give me a name :)",
"agent.wrapUp.action": "I think we're good",
"agent.wrapUp.confirm.cancel": "Keep chatting",
"agent.wrapUp.confirm.content": "I'll save what we've covered so far. You can always come back and chat more later.",
"agent.wrapUp.confirm.ok": "Finish now",
"agent.wrapUp.confirm.title": "Finish onboarding now?",
"back": "Back",
"finish": "Get Started",
"interests.area.business": "Business & Strategy",
+20
View File
@@ -30,6 +30,7 @@
"builtins.lobe-agent-documents.apiName.createDocument": "Create document",
"builtins.lobe-agent-documents.apiName.editDocument": "Edit document",
"builtins.lobe-agent-documents.apiName.listDocuments": "List documents",
"builtins.lobe-agent-documents.apiName.patchDocument": "Patch document",
"builtins.lobe-agent-documents.apiName.readDocument": "Read document",
"builtins.lobe-agent-documents.apiName.readDocumentByFilename": "Read document by filename",
"builtins.lobe-agent-documents.apiName.removeDocument": "Remove document",
@@ -64,6 +65,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-claude-code.todoWrite.allDone": "All tasks completed",
"builtins.lobe-claude-code.todoWrite.currentStep": "Current step",
"builtins.lobe-claude-code.todoWrite.todos": "Todos",
"builtins.lobe-cloud-sandbox.apiName.editLocalFile": "Edit file",
"builtins.lobe-cloud-sandbox.apiName.executeCode": "Execute code",
"builtins.lobe-cloud-sandbox.apiName.exportFile": "Export file",
@@ -431,18 +435,34 @@
"loading.plugin": "Skill running…",
"localSystem.workingDirectory.agentDescription": "Default working directory for all conversations with this Agent",
"localSystem.workingDirectory.agentLevel": "Agent Working Directory",
"localSystem.workingDirectory.branchSearchPlaceholder": "Search branches",
"localSystem.workingDirectory.branchesEmpty": "No local branches",
"localSystem.workingDirectory.branchesHeading": "Branches",
"localSystem.workingDirectory.branchesLoading": "Loading branches…",
"localSystem.workingDirectory.branchesNoMatch": "No matching branches",
"localSystem.workingDirectory.cancel": "Cancel",
"localSystem.workingDirectory.checkoutAction": "Checkout",
"localSystem.workingDirectory.checkoutFailed": "Checkout failed",
"localSystem.workingDirectory.chooseDifferentFolder": "Choose a different folder",
"localSystem.workingDirectory.createBranchAction": "Checkout new branch…",
"localSystem.workingDirectory.current": "Current working directory",
"localSystem.workingDirectory.detachedHead": "Detached HEAD at {{sha}}",
"localSystem.workingDirectory.ghMissing": "Install and log in to the GitHub CLI (`gh`) to see linked pull requests",
"localSystem.workingDirectory.newBranchPlaceholder": "feature/new-branch-name",
"localSystem.workingDirectory.noRecent": "No recent directories",
"localSystem.workingDirectory.notSet": "Click to set working directory",
"localSystem.workingDirectory.placeholder": "Enter directory path, e.g. /Users/name/projects",
"localSystem.workingDirectory.prTooltipWithExtra": "{{title}} (+{{count}} more open PR on this branch)",
"localSystem.workingDirectory.recent": "Recent",
"localSystem.workingDirectory.refreshGitStatus": "Refresh branch & PR status",
"localSystem.workingDirectory.removeRecent": "Remove from recent",
"localSystem.workingDirectory.selectFolder": "Select folder",
"localSystem.workingDirectory.title": "Working Directory",
"localSystem.workingDirectory.topicDescription": "Override Agent default for this conversation only",
"localSystem.workingDirectory.topicLevel": "Conversation override",
"localSystem.workingDirectory.topicOverride": "Override for this conversation",
"localSystem.workingDirectory.uncommittedChanges_one": "Uncommitted changes: {{count}} file",
"localSystem.workingDirectory.uncommittedChanges_other": "Uncommitted changes: {{count}} files",
"mcpEmpty.deployment": "No deployment options",
"mcpEmpty.prompts": "No prompts",
"mcpEmpty.resources": "No resources",
+13
View File
@@ -191,6 +191,11 @@
"analytics.telemetry.desc": "Help us improve {{appName}} with anonymous usage data",
"analytics.telemetry.title": "Send Anonymous Usage Data",
"analytics.title": "Analytics",
"ccStatus.account.label": "Account",
"ccStatus.detecting": "Detecting Claude Code CLI...",
"ccStatus.redetect": "Re-detect",
"ccStatus.title": "Claude Code CLI",
"ccStatus.unavailable": "Claude Code CLI not found. Please install or configure it.",
"checking": "Checking...",
"checkingPermissions": "Checking permissions...",
"creds.actions.delete": "Delete",
@@ -659,6 +664,8 @@
"settingSystemTools.appEnvironment.title": "Built-in App Tools",
"settingSystemTools.category.browserAutomation": "Browser Automation",
"settingSystemTools.category.browserAutomation.desc": "Tools for headless browser automation and web interaction",
"settingSystemTools.category.cliAgents": "CLI Agents",
"settingSystemTools.category.cliAgents.desc": "Agentic coding CLIs detected on your system, such as Claude Code, Codex, and Kimi",
"settingSystemTools.category.contentSearch": "Content Search",
"settingSystemTools.category.contentSearch.desc": "Tools for searching text content within files",
"settingSystemTools.category.fileSearch": "File Search",
@@ -673,17 +680,23 @@
"settingSystemTools.title": "System Tools",
"settingSystemTools.tools.ag.desc": "The Silver Searcher - fast code searching tool",
"settingSystemTools.tools.agentBrowser.desc": "Agent-browser - headless browser automation CLI for AI agents",
"settingSystemTools.tools.aider.desc": "Aider - AI pair programming in your terminal",
"settingSystemTools.tools.bun.desc": "Bun - fast JavaScript runtime and package manager",
"settingSystemTools.tools.bunx.desc": "bunx - Bun package runner for executing npm packages",
"settingSystemTools.tools.claude.desc": "Claude Code - Anthropic official agentic coding CLI",
"settingSystemTools.tools.codex.desc": "Codex - OpenAI agentic coding CLI",
"settingSystemTools.tools.fd.desc": "fd - fast and user-friendly alternative to find",
"settingSystemTools.tools.find.desc": "Unix find - standard file search command",
"settingSystemTools.tools.gemini.desc": "Gemini CLI - Google agentic coding CLI",
"settingSystemTools.tools.grep.desc": "GNU grep - standard text search tool",
"settingSystemTools.tools.kimi.desc": "Kimi CLI - Moonshot AI agentic coding CLI",
"settingSystemTools.tools.lobehub.desc": "LobeHub CLI - manage and connect to LobeHub services",
"settingSystemTools.tools.mdfind.desc": "macOS Spotlight search (fast indexed search)",
"settingSystemTools.tools.node.desc": "Node.js - JavaScript runtime for executing JS/TS",
"settingSystemTools.tools.npm.desc": "npm - Node.js package manager for installing dependencies",
"settingSystemTools.tools.pnpm.desc": "pnpm - fast, disk space efficient package manager",
"settingSystemTools.tools.python.desc": "Python - programming language runtime",
"settingSystemTools.tools.qwen.desc": "Qwen Code - Alibaba Qwen agentic coding CLI",
"settingSystemTools.tools.rg.desc": "ripgrep - extremely fast text search tool",
"settingSystemTools.tools.uv.desc": "uv - extremely fast Python package manager",
"settingTTS.openai.sttModel": "OpenAI Speech-to-Text Model",
+20 -5
View File
@@ -4,6 +4,12 @@
"actions.confirmRemoveAll": "You are about to delete all topics. This action cannot be undone.",
"actions.confirmRemoveTopic": "You are about to delete this topic. This action cannot be undone.",
"actions.confirmRemoveUnstarred": "You are about to delete unstarred topics. This action cannot be undone.",
"actions.copyLink": "Copy Link",
"actions.copyLinkSuccess": "Link copied",
"actions.copySessionId": "Copy Session ID",
"actions.copySessionIdSuccess": "Session ID copied",
"actions.copyWorkingDirectory": "Copy Working Directory",
"actions.copyWorkingDirectorySuccess": "Working directory copied",
"actions.duplicate": "Duplicate",
"actions.export": "Export Topics",
"actions.favorite": "Favorite",
@@ -18,11 +24,14 @@
"duplicateLoading": "Copying Topic...",
"duplicateSuccess": "Topic Copied Successfully",
"favorite": "Favorite",
"groupMode.ascMessages": "Sort by Total Messages Ascending",
"groupMode.byTime": "Group by Created Time",
"groupMode.byUpdatedTime": "Group by Updated Time",
"groupMode.descMessages": "Sort by Total Messages Descending",
"groupMode.flat": "No Grouping",
"filter.groupMode.byProject": "By project",
"filter.groupMode.byTime": "By time",
"filter.groupMode.flat": "Flat",
"filter.organize": "Organize",
"filter.sort": "Sort by",
"filter.sortBy.createdAt": "Created time",
"filter.sortBy.updatedAt": "Updated time",
"groupTitle.byProject.noProject": "No directory",
"groupTitle.byTime.month": "This Month",
"groupTitle.byTime.today": "Today",
"groupTitle.byTime.week": "This Week",
@@ -33,7 +42,13 @@
"importInvalidFormat": "Invalid file format. Please ensure it is a valid JSON file.",
"importLoading": "Importing conversation...",
"importSuccess": "Successfully imported {{count}} messages",
"inPopup.description": "This topic is currently open in a separate window. Continue the conversation there to keep messages in sync.",
"inPopup.focus": "Focus popup window",
"inPopup.title": "Open in popup window",
"loadMore": "Load More",
"newTopic": "New Topic",
"renameModal.description": "Keep it short and easy to recognize.",
"renameModal.title": "Rename Topic",
"searchPlaceholder": "Search Topics...",
"searchResultEmpty": "No search results found.",
"temp": "Temporary",
+3
View File
@@ -1,6 +1,9 @@
{
"about": "Acerca de",
"advanceSettings": "Configuración avanzada",
"agentOnboardingPromo.actionLabel": "Probar ahora",
"agentOnboardingPromo.description": "Configura tus equipos de agentes en un chat breve con Lobe AI. Tus agentes actuales no se modificarán.",
"agentOnboardingPromo.title": "Asistente rápido",
"alert.cloud.action": "Probar ahora",
"alert.cloud.desc": "Todos los usuarios registrados reciben {{credit}} créditos de computación gratuitos al mes, sin necesidad de configuración. Incluye sincronización en la nube global y búsqueda web avanzada.",
"alert.cloud.descOnMobile": "Todos los usuarios registrados reciben {{credit}} créditos de computación gratuitos al mes, sin necesidad de configuración.",
+3
View File
@@ -1,6 +1,9 @@
{
"about": "درباره",
"advanceSettings": "تنظیمات پیشرفته",
"agentOnboardingPromo.actionLabel": "همین حالا امتحان کنید",
"agentOnboardingPromo.description": "تیم‌های عامل خود را با یک گفت‌وگوی کوتاه با Lobe AI تنظیم کنید. عامل‌های فعلی شما بدون تغییر باقی می‌مانند.",
"agentOnboardingPromo.title": "راهنمای سریع",
"alert.cloud.action": "هم‌اکنون امتحان کنید",
"alert.cloud.desc": "تمام کاربران ثبت‌نام‌شده هر ماه {{credit}} اعتبار رایگان محاسباتی دریافت می‌کنند — بدون نیاز به تنظیمات. شامل همگام‌سازی ابری جهانی و جستجوی پیشرفته وب.",
"alert.cloud.descOnMobile": "تمام کاربران ثبت‌نام‌شده هر ماه {{credit}} اعتبار رایگان محاسباتی دریافت می‌کنند — بدون نیاز به تنظیمات.",
+3
View File
@@ -1,6 +1,9 @@
{
"about": "À propos",
"advanceSettings": "Paramètres avancés",
"agentOnboardingPromo.actionLabel": "Essayer maintenant",
"agentOnboardingPromo.description": "Configurez vos équipes dagents dans un court échange avec Lobe AI. Vos agents existants restent inchangés.",
"agentOnboardingPromo.title": "Assistant rapide",
"alert.cloud.action": "Essayer maintenant",
"alert.cloud.desc": "Tous les utilisateurs enregistrés reçoivent {{credit}} crédits de calcul gratuits par mois — aucune configuration requise. Inclut la synchronisation cloud mondiale et la recherche web avancée.",
"alert.cloud.descOnMobile": "Tous les utilisateurs enregistrés reçoivent {{credit}} crédits de calcul gratuits par mois — aucune configuration requise.",
+3
View File
@@ -1,6 +1,9 @@
{
"about": "Informazioni",
"advanceSettings": "Impostazioni avanzate",
"agentOnboardingPromo.actionLabel": "Prova ora",
"agentOnboardingPromo.description": "Configura i tuoi team di agenti in una breve chat con Lobe AI. I tuoi agenti esistenti resteranno invariati.",
"agentOnboardingPromo.title": "Procedura rapida",
"alert.cloud.action": "Prova ora",
"alert.cloud.desc": "Tutti gli utenti registrati ricevono {{credit}} crediti di calcolo gratuiti al mese—nessuna configurazione necessaria. Include sincronizzazione cloud globale e ricerca web avanzata.",
"alert.cloud.descOnMobile": "Tutti gli utenti registrati ricevono {{credit}} crediti di calcolo gratuiti al mese—nessuna configurazione necessaria.",
+3
View File
@@ -1,6 +1,9 @@
{
"about": "情報",
"advanceSettings": "詳細設定",
"agentOnboardingPromo.actionLabel": "今すぐ試す",
"agentOnboardingPromo.description": "Lobe AI と短く会話するだけで、エージェントチームを設定できます。既存のエージェントは変更されません。",
"agentOnboardingPromo.title": "かんたん設定",
"alert.cloud.action": "今すぐ試す",
"alert.cloud.desc": "すべての登録ユーザーは毎月{{credit}}の無料コンピューティングクレジットを受け取れます—設定は不要です。グローバルクラウド同期と高度なウェブ検索が含まれます。",
"alert.cloud.descOnMobile": "すべての登録ユーザーは毎月{{credit}}の無料コンピューティングクレジットを受け取れます—設定は不要です。",
+3
View File
@@ -1,6 +1,9 @@
{
"about": "정보",
"advanceSettings": "고급 설정",
"agentOnboardingPromo.actionLabel": "지금 사용해 보기",
"agentOnboardingPromo.description": "Lobe AI와 짧게 대화하며 에이전트 팀을 설정하세요. 기존 에이전트는 그대로 유지됩니다.",
"agentOnboardingPromo.title": "빠른 설정",
"alert.cloud.action": "지금 체험하기",
"alert.cloud.desc": "모든 등록된 사용자는 매월 {{credit}}의 무료 컴퓨팅 크레딧을 받을 수 있습니다—설정이 필요하지 않습니다. 전 세계 클라우드 동기화 및 고급 웹 검색 기능이 포함되어 있습니다.",
"alert.cloud.descOnMobile": "모든 등록된 사용자는 매월 {{credit}}의 무료 컴퓨팅 크레딧을 받을 수 있습니다—설정이 필요하지 않습니다.",
+3
View File
@@ -1,6 +1,9 @@
{
"about": "Over",
"advanceSettings": "Geavanceerde instellingen",
"agentOnboardingPromo.actionLabel": "Nu proberen",
"agentOnboardingPromo.description": "Stel je agentteams in via een korte chat met Lobe AI. Je bestaande agenten blijven ongewijzigd.",
"agentOnboardingPromo.title": "Snelle wizard",
"alert.cloud.action": "Probeer nu",
"alert.cloud.desc": "Alle geregistreerde gebruikers ontvangen {{credit}} gratis computertegoed per maand—geen installatie nodig. Inclusief wereldwijde cloud-synchronisatie en geavanceerd webzoeken.",
"alert.cloud.descOnMobile": "Alle geregistreerde gebruikers ontvangen {{credit}} gratis computertegoed per maand—geen installatie nodig.",
+3
View File
@@ -1,6 +1,9 @@
{
"about": "O nas",
"advanceSettings": "Ustawienia zaawansowane",
"agentOnboardingPromo.actionLabel": "Wypróbuj teraz",
"agentOnboardingPromo.description": "Skonfiguruj swoje zespoły agentów w krótkiej rozmowie z Lobe AI. Twoi obecni agenci pozostaną bez zmian.",
"agentOnboardingPromo.title": "Szybki kreator",
"alert.cloud.action": "Wypróbuj teraz",
"alert.cloud.desc": "Wszyscy zarejestrowani użytkownicy otrzymują {{credit}} darmowych kredytów obliczeniowych miesięcznie — bez potrzeby konfiguracji. Zawiera globalną synchronizację w chmurze i zaawansowane wyszukiwanie w sieci.",
"alert.cloud.descOnMobile": "Wszyscy zarejestrowani użytkownicy otrzymują {{credit}} darmowych kredytów obliczeniowych miesięcznie — bez potrzeby konfiguracji.",
+3
View File
@@ -1,6 +1,9 @@
{
"about": "Sobre",
"advanceSettings": "Configurações Avançadas",
"agentOnboardingPromo.actionLabel": "Experimentar agora",
"agentOnboardingPromo.description": "Configure suas equipes de agentes em um chat rápido com a Lobe AI. Seus agentes atuais permanecem inalterados.",
"agentOnboardingPromo.title": "Assistente rápido",
"alert.cloud.action": "Experimente agora",
"alert.cloud.desc": "Todos os usuários registrados recebem {{credit}} créditos de computação gratuitos por mês — sem necessidade de configuração. Inclui sincronização em nuvem global e pesquisa avançada na web.",
"alert.cloud.descOnMobile": "Todos os usuários registrados recebem {{credit}} créditos de computação gratuitos por mês — sem necessidade de configuração.",
+3
View File
@@ -1,6 +1,9 @@
{
"about": "О проекте",
"advanceSettings": "Расширенные настройки",
"agentOnboardingPromo.actionLabel": "Попробовать",
"agentOnboardingPromo.description": "Настройте свои команды агентов в коротком чате с Lobe AI. Ваши существующие агенты останутся без изменений.",
"agentOnboardingPromo.title": "Быстрый мастер",
"alert.cloud.action": "Попробовать сейчас",
"alert.cloud.desc": "Все зарегистрированные пользователи получают {{credit}} бесплатных вычислительных кредитов в месяц — без необходимости настройки. Включает глобальную синхронизацию и расширенный веб-поиск.",
"alert.cloud.descOnMobile": "Все зарегистрированные пользователи получают {{credit}} бесплатных вычислительных кредитов в месяц — без необходимости настройки.",
+3
View File
@@ -1,6 +1,9 @@
{
"about": "Hakkında",
"advanceSettings": "Gelişmiş Ayarlar",
"agentOnboardingPromo.actionLabel": "Şimdi dene",
"agentOnboardingPromo.description": "Aracı ekiplerinizi Lobe AI ile kısa bir sohbet üzerinden kurun. Mevcut aracılarınız değişmeden kalır.",
"agentOnboardingPromo.title": "Hızlı sihirbaz",
"alert.cloud.action": "Şimdi dene",
"alert.cloud.desc": "Tüm kayıtlı kullanıcılar her ay {{credit}} ücretsiz işlem kredisi alır—kurulum gerekmez. Küresel bulut senkronizasyonu ve gelişmiş web araması dahildir.",
"alert.cloud.descOnMobile": "Tüm kayıtlı kullanıcılar her ay {{credit}} ücretsiz işlem kredisi alır—kurulum gerekmez.",
+3
View File
@@ -1,6 +1,9 @@
{
"about": "Giới thiệu",
"advanceSettings": "Cài đặt nâng cao",
"agentOnboardingPromo.actionLabel": "Dùng thử ngay",
"agentOnboardingPromo.description": "Thiết lập các nhóm trợ lý của bạn qua một cuộc trò chuyện nhanh với Lobe AI. Các trợ lý hiện có của bạn sẽ không thay đổi.",
"agentOnboardingPromo.title": "Trình hướng dẫn nhanh",
"alert.cloud.action": "Dùng thử ngay",
"alert.cloud.desc": "Tất cả người dùng đã đăng ký nhận được {{credit}} tín dụng điện toán miễn phí mỗi tháng—không cần cài đặt. Bao gồm đồng bộ đám mây toàn cầu và tìm kiếm web nâng cao.",
"alert.cloud.descOnMobile": "Tất cả người dùng đã đăng ký nhận được {{credit}} tín dụng điện toán miễn phí mỗi tháng—không cần cài đặt.",
+36 -15
View File
@@ -18,6 +18,7 @@
"agentDefaultMessage": "你好,我是 **{{name}}**。从一句话开始就行。\n\n想让我更贴近你的工作方式:去 [助理设置]({{url}}) 补充助理档案(随时可改)",
"agentDefaultMessageWithSystemRole": "你好,我是 **{{name}}**。从一句话开始就行——决定权在你",
"agentDefaultMessageWithoutEdit": "你好,我是 **{{name}}**。从一句话开始就行——决定权在你",
"agentSidebar.externalTag": "外部",
"agents": "助理",
"artifact.generating": "生成中",
"artifact.inThread": "子话题中暂不支持查看。请回到主对话区打开",
@@ -123,6 +124,7 @@
"groupWizard.searchTemplates": "搜索模板…",
"groupWizard.title": "创建群组",
"groupWizard.useTemplate": "使用模板",
"heteroAgent.resumeReset.cwdChanged": "工作目录已切换,之前的 Claude Code 会话只能在原目录下继续,已开始新对话。",
"hideForYou": "私信内容已隐藏。可在设置中开启「显示私信内容」查看",
"history.title": "助理将仅保留最近 {{count}} 条消息",
"historyRange": "历史范围",
@@ -223,6 +225,7 @@
"minimap.senderAssistant": "助理",
"minimap.senderUser": "你",
"newAgent": "创建助理",
"newClaudeCodeAgent": "添加 Claude Code",
"newGroupChat": "创建群组",
"newPage": "创建文稿",
"noAgentsYet": "这个群组还没有成员。点击「+」邀请助理加入",
@@ -234,6 +237,7 @@
"operation.contextCompression": "上下文过长,正在压缩历史记录……",
"operation.execAgentRuntime": "准备响应中",
"operation.execClientTask": "执行任务中",
"operation.execHeterogeneousAgent": "智能体运行中",
"operation.execServerAgentRuntime": "任务正在服务器运行,您可以放心离开此页面",
"operation.sendMessage": "消息发送中",
"owner": "群主",
@@ -418,6 +422,15 @@
"tool.intervention.mode.autoRunDesc": "自动批准所有技能调用",
"tool.intervention.mode.manual": "手动批准",
"tool.intervention.mode.manualDesc": "每次调用都需要你确认",
"tool.intervention.onboarding.agentIdentity.applyHint": "批准后生效。",
"tool.intervention.onboarding.agentIdentity.description": "批准后,会同步更新 Inbox 中显示的 Agent,以及当前 onboarding 对话里的 Agent。",
"tool.intervention.onboarding.agentIdentity.emoji": "Agent 头像",
"tool.intervention.onboarding.agentIdentity.eyebrow": "Onboarding 审批",
"tool.intervention.onboarding.agentIdentity.name": "Agent 名称",
"tool.intervention.onboarding.agentIdentity.targetInbox": "Inbox Agent",
"tool.intervention.onboarding.agentIdentity.targetOnboarding": "当前 onboarding Agent",
"tool.intervention.onboarding.agentIdentity.targets": "应用范围",
"tool.intervention.onboarding.agentIdentity.title": "确认 Agent 名称与头像",
"tool.intervention.pending": "等待中",
"tool.intervention.reject": "拒绝",
"tool.intervention.rejectAndContinue": "拒绝后继续",
@@ -467,6 +480,9 @@
"viewMode.wideScreen": "宽屏",
"workflow.awaitingConfirmation": "需要你确认",
"workflow.failedSuffix": "(失败)",
"workflow.summaryFailed": "{{count}} 次失败",
"workflow.summaryMoreTools": "等 {{count}} 种工具",
"workflow.summaryTotalCalls": "共 {{count}} 次调用",
"workflow.thoughtForDuration": "思考了 {{duration}}",
"workflow.toolDisplayName.activateDevice": "激活了设备",
"workflow.toolDisplayName.activateSkill": "启用了技能",
@@ -540,33 +556,38 @@
"workflow.toolDisplayName.upsertDocumentByFilename": "更新了文档",
"workflow.toolDisplayName.writeLocalFile": "写入了文件",
"workflow.working": "处理中...",
"workingPanel.agentDocuments": "理文档",
"workingPanel.agentDocuments": "理文档",
"workingPanel.documents.close": "关闭",
"workingPanel.documents.discard": "放弃更改",
"workingPanel.documents.discard": "放弃",
"workingPanel.documents.edit": "编辑",
"workingPanel.documents.error": "文档加载失败",
"workingPanel.documents.loading": "文档加载中...",
"workingPanel.documents.loading": "文档加载中",
"workingPanel.documents.preview": "预览",
"workingPanel.documents.save": "保存",
"workingPanel.documents.saved": "所有更改已保存",
"workingPanel.documents.saved": "已保存全部变更",
"workingPanel.documents.title": "文档",
"workingPanel.documents.unsaved": "有未保存更",
"workingPanel.documents.unsaved": "有未保存的变更",
"workingPanel.progress": "进度",
"workingPanel.progress.allCompleted": "全部任务已完成",
"workingPanel.progress.allCompleted": "所有任务已完成",
"workingPanel.resources": "资源",
"workingPanel.resources.deleteConfirm": "操作无法撤销。",
"workingPanel.resources.deleteError": "删除文档失败",
"workingPanel.resources.deleteConfirm": "操作无法撤销。",
"workingPanel.resources.deleteError": "删除失败",
"workingPanel.resources.deleteSuccess": "文档已删除",
"workingPanel.resources.deleteTitle": "删除文档?",
"workingPanel.resources.empty": "暂无助理文档",
"workingPanel.resources.deleteTitle": "删除文档?",
"workingPanel.resources.empty": "暂无文档,当前 agent 关联的文档将会显示在这里",
"workingPanel.resources.error": "资源加载失败",
"workingPanel.resources.loading": "资源加载中...",
"workingPanel.resources.filter.all": "全部",
"workingPanel.resources.filter.documents": "文档",
"workingPanel.resources.filter.web": "网页",
"workingPanel.resources.loading": "资源加载中…",
"workingPanel.resources.previewError": "预览加载失败",
"workingPanel.resources.previewLoading": "预览加载中...",
"workingPanel.resources.previewLoading": "预览加载中",
"workingPanel.resources.renameEmpty": "标题不能为空",
"workingPanel.resources.renameError": "重命名文档失败",
"workingPanel.resources.renameSuccess": "文档已重命名",
"workingPanel.title": "Working Panel",
"workingPanel.resources.renameError": "重命名失败",
"workingPanel.resources.renameSuccess": "重命名成功",
"workingPanel.resources.viewMode.list": "列表视图",
"workingPanel.resources.viewMode.tree": "目录视图",
"workingPanel.title": "工作面板",
"you": "你",
"zenMode": "专注模式"
}
+3
View File
@@ -1,6 +1,9 @@
{
"about": "关于",
"advanceSettings": "高级设置",
"agentOnboardingPromo.actionLabel": "立即体验",
"agentOnboardingPromo.description": "与 Lobe AI 快速聊几句,即可设置你的助理团队。你现有的助理不会受到影响。",
"agentOnboardingPromo.title": "快速向导",
"alert.cloud.action": "立即体验",
"alert.cloud.desc": "所有注册用户每月可获得 {{credit}} 免费计算额度—无需设置。包括全球云同步和高级网页搜索功能。",
"alert.cloud.descOnMobile": "所有注册用户每月可获得 {{credit}} 免费计算额度—无需设置。",
+3 -1
View File
@@ -133,5 +133,7 @@
"window.close": "关闭窗口",
"window.maximize": "最大化窗口",
"window.minimize": "最小化窗口",
"window.restore": "恢复窗口"
"window.pinToTop": "窗口置顶",
"window.restore": "恢复窗口",
"window.unpinFromTop": "取消置顶"
}
+25
View File
@@ -90,6 +90,31 @@
"pageEditor.exportDialogTitle": "导出页面",
"pageEditor.exportError": "页面导出失败",
"pageEditor.exportSuccess": "页面导出成功",
"pageEditor.history.backToCopilot": "返回 Copilot",
"pageEditor.history.compare": "对比",
"pageEditor.history.compareCurrentLabel": "当前",
"pageEditor.history.compareDescription": "对比当前内容与所选历史状态",
"pageEditor.history.compareError": "加载差异预览失败",
"pageEditor.history.compareModalTitle": "对比",
"pageEditor.history.compareOldLabel": "回退到",
"pageEditor.history.compareTitle": "版本差异",
"pageEditor.history.current": "当前版本",
"pageEditor.history.dayLabel.today": "今天",
"pageEditor.history.dayLabel.yesterday": "昨天",
"pageEditor.history.empty": "还没有可用的历史版本",
"pageEditor.history.itemVersionLabel": "v{{version}}",
"pageEditor.history.restore": "恢复",
"pageEditor.history.restoreConfirm.content": "确定恢复到 {{savedAt}} 保存的内容吗?当前文稿将被替换并保存。",
"pageEditor.history.restoreConfirm.title": "从历史恢复",
"pageEditor.history.restoreError": "恢复失败",
"pageEditor.history.saveSource.autosave": "自动保存",
"pageEditor.history.saveSource.manual": "手动保存",
"pageEditor.history.saveSource.restore": "恢复",
"pageEditor.history.saveSource.system": "系统",
"pageEditor.history.title": "版本历史",
"pageEditor.history.versionCount_one": "共 {{count}} 个版本",
"pageEditor.history.versionCount_other": "共 {{count}} 个版本",
"pageEditor.history.versionLabel": "v{{version}}",
"pageEditor.linkCopied": "链接已复制",
"pageEditor.menu.copyLink": "复制链接",
"pageEditor.menu.export": "导出",
+2
View File
@@ -1,4 +1,6 @@
{
"features.agentWorkingPanel.desc": "在会话中查看当前助理可访问的资源和任务进度。",
"features.agentWorkingPanel.title": "工作面板",
"features.assistantMessageGroup.desc": "将代理消息及其工具调用结果组合在一起显示",
"features.assistantMessageGroup.title": "代理消息分组",
"features.gatewayMode.desc": "通过 Gateway 在服务端执行 Agent 任务。可实现关闭浏览器后仍然执行 agent。",
+1
View File
@@ -230,6 +230,7 @@
"providerModels.item.modelConfig.extendParams.options.imageAspectRatio2.hint": "适用于 Nano Banana 2;控制生成图像的宽高比(支持超宽 1:4、4:1、1:8、8:1)。",
"providerModels.item.modelConfig.extendParams.options.imageResolution.hint": "适用于 Gemini 3 图像生成模型;控制生成图像的分辨率。",
"providerModels.item.modelConfig.extendParams.options.imageResolution2.hint": "适用于 Gemini 3.1 Flash Image 模型;控制生成图像的分辨率(支持 512px)。",
"providerModels.item.modelConfig.extendParams.options.opus47Effort.hint": "适用于 Claude Opus 4.7;控制努力程度(低/中/高/超高/最大)。",
"providerModels.item.modelConfig.extendParams.options.reasoningBudgetToken.hint": "适用于 Claude、Qwen3 等模型;控制用于推理的 Token 预算。",
"providerModels.item.modelConfig.extendParams.options.reasoningBudgetToken32k.hint": "适用于GLM-5和GLM-4.7;控制推理的令牌预算(最大32k)。",
"providerModels.item.modelConfig.extendParams.options.reasoningBudgetToken80k.hint": "适用于Qwen3系列;控制推理的令牌预算(最大80k)。",
+2
View File
@@ -1,4 +1,6 @@
{
"billboard.learnMore": "了解更多",
"billboard.menuLabel": "公告",
"inbox.archiveAll": "全部归档",
"inbox.empty": "暂无通知",
"inbox.emptyUnread": "没有未读通知",
+5
View File
@@ -51,6 +51,11 @@
"agent.welcome.sentence.1": "很高兴认识你!让我们互相了解一下吧。",
"agent.welcome.sentence.2": "你希望我成为怎样的伙伴?",
"agent.welcome.sentence.3": "先给我起个名字吧 :)",
"agent.wrapUp.action": "先这样吧",
"agent.wrapUp.confirm.cancel": "再聊聊",
"agent.wrapUp.confirm.content": "目前了解到的信息我都会保存,你随时都可以回来继续和我聊。",
"agent.wrapUp.confirm.ok": "结束引导",
"agent.wrapUp.confirm.title": "现在结束引导吗?",
"back": "上一步",
"finish": "开始使用",
"interests.area.business": "商业与战略",
+20
View File
@@ -30,6 +30,7 @@
"builtins.lobe-agent-documents.apiName.createDocument": "创建文档",
"builtins.lobe-agent-documents.apiName.editDocument": "编辑文档",
"builtins.lobe-agent-documents.apiName.listDocuments": "列出文档",
"builtins.lobe-agent-documents.apiName.patchDocument": "修订文档",
"builtins.lobe-agent-documents.apiName.readDocument": "读取文档",
"builtins.lobe-agent-documents.apiName.readDocumentByFilename": "按文件名读取文档",
"builtins.lobe-agent-documents.apiName.removeDocument": "删除文档",
@@ -64,6 +65,9 @@
"builtins.lobe-agent-management.render.installPlugin.plugin": "插件",
"builtins.lobe-agent-management.render.installPlugin.success": "安装成功",
"builtins.lobe-agent-management.title": "代理管理器",
"builtins.lobe-claude-code.todoWrite.allDone": "全部任务已完成",
"builtins.lobe-claude-code.todoWrite.currentStep": "当前步骤",
"builtins.lobe-claude-code.todoWrite.todos": "待办",
"builtins.lobe-cloud-sandbox.apiName.editLocalFile": "编辑文件",
"builtins.lobe-cloud-sandbox.apiName.executeCode": "执行代码",
"builtins.lobe-cloud-sandbox.apiName.exportFile": "导出文件",
@@ -431,18 +435,34 @@
"loading.plugin": "技能运行中…",
"localSystem.workingDirectory.agentDescription": "该助手下所有对话的默认工作目录",
"localSystem.workingDirectory.agentLevel": "代理工作目录",
"localSystem.workingDirectory.branchSearchPlaceholder": "搜索分支",
"localSystem.workingDirectory.branchesEmpty": "暂无本地分支",
"localSystem.workingDirectory.branchesHeading": "分支",
"localSystem.workingDirectory.branchesLoading": "加载分支中…",
"localSystem.workingDirectory.branchesNoMatch": "没有匹配的分支",
"localSystem.workingDirectory.cancel": "取消",
"localSystem.workingDirectory.checkoutAction": "切换分支",
"localSystem.workingDirectory.checkoutFailed": "切换分支失败",
"localSystem.workingDirectory.chooseDifferentFolder": "选择其他文件夹",
"localSystem.workingDirectory.createBranchAction": "检出新分支…",
"localSystem.workingDirectory.current": "当前工作目录",
"localSystem.workingDirectory.detachedHead": "游离 HEAD,当前提交 {{sha}}",
"localSystem.workingDirectory.ghMissing": "安装并登录 GitHub CLIgh)即可显示关联的 Pull Request",
"localSystem.workingDirectory.newBranchPlaceholder": "feature/新分支名称",
"localSystem.workingDirectory.noRecent": "暂无最近目录",
"localSystem.workingDirectory.notSet": "点击设置工作目录",
"localSystem.workingDirectory.placeholder": "输入目录路径,如 /Users/name/projects",
"localSystem.workingDirectory.prTooltipWithExtra": "{{title}}(此分支还有 {{count}} 个开放 PR",
"localSystem.workingDirectory.recent": "最近使用",
"localSystem.workingDirectory.refreshGitStatus": "刷新分支与 PR 状态",
"localSystem.workingDirectory.removeRecent": "从最近目录中移除",
"localSystem.workingDirectory.selectFolder": "选择文件夹",
"localSystem.workingDirectory.title": "工作目录",
"localSystem.workingDirectory.topicDescription": "仅覆盖当前对话的工作目录",
"localSystem.workingDirectory.topicLevel": "对话覆盖",
"localSystem.workingDirectory.topicOverride": "为当前对话覆盖设置",
"localSystem.workingDirectory.uncommittedChanges_one": "未提交的更改:{{count}} 个文件",
"localSystem.workingDirectory.uncommittedChanges_other": "未提交的更改:{{count}} 个文件",
"mcpEmpty.deployment": "暂无部署选项",
"mcpEmpty.prompts": "该技能暂无提示词",
"mcpEmpty.resources": "该技能暂无资源",
+13
View File
@@ -191,6 +191,11 @@
"analytics.telemetry.desc": "通过匿名使用数据帮助我们改进 {{appName}}",
"analytics.telemetry.title": "发送匿名使用数据",
"analytics.title": "数据统计",
"ccStatus.account.label": "账号",
"ccStatus.detecting": "正在检测 Claude Code CLI…",
"ccStatus.redetect": "重新检测",
"ccStatus.title": "Claude Code CLI",
"ccStatus.unavailable": "未检测到 Claude Code CLI,请先安装或配置",
"checking": "检查中…",
"checkingPermissions": "检查权限中…",
"creds.actions.delete": "删除",
@@ -659,6 +664,8 @@
"settingSystemTools.appEnvironment.title": "内建应用工具",
"settingSystemTools.category.browserAutomation": "浏览器自动化",
"settingSystemTools.category.browserAutomation.desc": "用于无头浏览器自动化和网页交互的工具",
"settingSystemTools.category.cliAgents": "CLI 智能体",
"settingSystemTools.category.cliAgents.desc": "已检测到的命令行编码智能体,如 Claude Code、Codex、Kimi 等",
"settingSystemTools.category.contentSearch": "内容搜索",
"settingSystemTools.category.contentSearch.desc": "用于在文件内搜索文本内容的工具",
"settingSystemTools.category.fileSearch": "文件搜索",
@@ -673,17 +680,23 @@
"settingSystemTools.title": "系统工具",
"settingSystemTools.tools.ag.desc": "The Silver Searcher - 快速代码搜索工具",
"settingSystemTools.tools.agentBrowser.desc": "Agent-browser - 面向AI代理的无头浏览器自动化命令行工具",
"settingSystemTools.tools.aider.desc": "Aider - 终端内的 AI 结对编程工具",
"settingSystemTools.tools.bun.desc": "Bun - 快速的 JavaScript 运行时和包管理器",
"settingSystemTools.tools.bunx.desc": "bunx - Bun 包执行器,用于运行 npm 包",
"settingSystemTools.tools.claude.desc": "Claude Code - Anthropic 官方命令行编码智能体",
"settingSystemTools.tools.codex.desc": "Codex - OpenAI 命令行编码智能体",
"settingSystemTools.tools.fd.desc": "fd - 快速且用户友好的 find 替代品",
"settingSystemTools.tools.find.desc": "Unix find - 标准文件搜索命令",
"settingSystemTools.tools.gemini.desc": "Gemini CLI - Google 命令行编码智能体",
"settingSystemTools.tools.grep.desc": "GNU grep - 标准文本搜索工具",
"settingSystemTools.tools.kimi.desc": "Kimi CLI - 月之暗面命令行编码智能体",
"settingSystemTools.tools.lobehub.desc": "LobeHub CLI - 管理和连接 LobeHub 服务",
"settingSystemTools.tools.mdfind.desc": "macOS 聚焦搜索(快速索引搜索)",
"settingSystemTools.tools.node.desc": "Node.js - 执行 JavaScript/TypeScript 的运行时",
"settingSystemTools.tools.npm.desc": "npm - Node.js 包管理器,用于安装依赖",
"settingSystemTools.tools.pnpm.desc": "pnpm - 快速、节省磁盘空间的包管理器",
"settingSystemTools.tools.python.desc": "Python - 编程语言运行时",
"settingSystemTools.tools.qwen.desc": "Qwen Code - 阿里通义千问命令行编码智能体",
"settingSystemTools.tools.rg.desc": "ripgrep - 极快的文本搜索工具",
"settingSystemTools.tools.uv.desc": "uv - 极快的 Python 包管理器",
"settingTTS.openai.sttModel": "OpenAI 语音识别模型",
+20 -5
View File
@@ -4,6 +4,12 @@
"actions.confirmRemoveAll": "您即将删除所有话题,此操作无法撤销。",
"actions.confirmRemoveTopic": "您即将删除此话题,此操作无法撤销。",
"actions.confirmRemoveUnstarred": "您即将删除未加星标的话题,此操作无法撤销。",
"actions.copyLink": "复制链接",
"actions.copyLinkSuccess": "链接已复制",
"actions.copySessionId": "复制会话 ID",
"actions.copySessionIdSuccess": "会话 ID 已复制",
"actions.copyWorkingDirectory": "复制工作目录",
"actions.copyWorkingDirectorySuccess": "工作目录已复制",
"actions.duplicate": "复制",
"actions.export": "导出话题",
"actions.favorite": "收藏",
@@ -18,11 +24,14 @@
"duplicateLoading": "话题复制中…",
"duplicateSuccess": "话题复制成功",
"favorite": "收藏",
"groupMode.ascMessages": "按消息总数顺序",
"groupMode.byTime": "按创建时间分组",
"groupMode.byUpdatedTime": "按编辑时间分组",
"groupMode.descMessages": "按消息总数倒序",
"groupMode.flat": "不分组",
"filter.groupMode.byProject": "按项目",
"filter.groupMode.byTime": "按时间阶段",
"filter.groupMode.flat": "平铺",
"filter.organize": "整理",
"filter.sort": "排序",
"filter.sortBy.createdAt": "按创建时间",
"filter.sortBy.updatedAt": "按更新时间",
"groupTitle.byProject.noProject": "无目录",
"groupTitle.byTime.month": "本月",
"groupTitle.byTime.today": "今天",
"groupTitle.byTime.week": "本周",
@@ -33,7 +42,13 @@
"importInvalidFormat": "文件格式不正确。请确认这是有效的 JSON 文件",
"importLoading": "正在导入对话…",
"importSuccess": "已导入 {{count}} 条消息",
"inPopup.description": "此话题已在独立窗口中打开,请前往该窗口继续对话以保持消息一致。",
"inPopup.focus": "聚焦独立窗口",
"inPopup.title": "已在独立窗口中打开",
"loadMore": "更多",
"newTopic": "新话题",
"renameModal.description": "保持简短且易于识别。",
"renameModal.title": "重命名话题",
"searchPlaceholder": "搜索话题…",
"searchResultEmpty": "暂无搜索结果",
"temp": "临时",
+3
View File
@@ -1,6 +1,9 @@
{
"about": "關於",
"advanceSettings": "進階設定",
"agentOnboardingPromo.actionLabel": "立即試用",
"agentOnboardingPromo.description": "和 Lobe AI 快速聊幾句,即可設定你的助理團隊。你現有的助理不會受到影響。",
"agentOnboardingPromo.title": "快速嚮導",
"alert.cloud.action": "免費體驗",
"alert.cloud.desc": "所有註冊用戶每月可獲得 {{credit}} 免費運算點數—無需設定。包含全球雲端同步與進階網頁搜尋功能。",
"alert.cloud.descOnMobile": "所有註冊用戶每月可獲得 {{credit}} 免費運算點數—無需設定。",
@@ -37,7 +37,7 @@ You just "woke up" with no name or personality. Discover who you are through con
- Keep this phase friendly and low-pressure, especially for older or non-technical users.
- Once the user settles on a name:
1. Call saveUserQuestion with agentName and agentEmoji.
2. Call updateDocument to write SOUL.md with your name, creature/nature, vibe, and emoji.
2. Persist SOUL.md: if empty use writeDocument(type="soul") for the initial write; if already non-empty use updateDocument(type="soul") to amend only the changed lines.
- Offer a short emoji choice list when helpful.
- Transition naturally to learning about the user.
@@ -46,9 +46,13 @@ You just "woke up" with no name or personality. Discover who you are through con
You know who you are. Now learn who the user is.
- If the user already shared their name earlier in the conversation, acknowledge it — do not ask again. Otherwise, ask how they would like to be addressed.
- Call saveUserQuestion with fullName when learned (whether from this phase or recalled from earlier).
- **You MUST call saveUserQuestion with fullName before leaving this phase.** The phase will not advance until fullName is saved — if you skip this, the user gets stuck in user_identity indefinitely.
- Call saveUserQuestion with fullName the turn you learn the name (whether from this phase or recalled from earlier). Do NOT wait until role is also known.
- Prefer the name they naturally offer, including nicknames, handles, or any identifier they used to introduce themselves (e.g. when proposing your name). Save it as fullName immediately — do not wait for a "formal" name.
- If the user's response about their name is ambiguous (e.g. "哈哈没有啦", "随便", "not really"), do NOT silently drop the question and move on. Ask exactly once more, directly: "那我该怎么称呼你?" / "What should I call you then?" — then save whatever they answer, even if it's a nickname or placeholder.
- Only if the user explicitly refuses to give any name after one clarifying ask, save a sensible fallback (e.g. the handle they used earlier, or "朋友" / "friend") and proceed.
- **Seed the persona document as soon as you have ANY useful fact** — just a name, just a role, or both. Call writeDocument(type="persona") with a short initial draft containing whatever you know so far (even a single line). A tiny seeded persona is better than an empty one. Do not defer seeding until discovery is over.
- Begin the persona document with their role and basic context.
- Prefer the name they naturally offer, including nicknames.
- Transition by showing curiosity about their daily work.
### Phase 3: Discovery (phase: "discovery")
@@ -74,7 +78,7 @@ Guidelines:
- Discover their interests and preferred response language naturally.
- Do NOT call saveUserQuestion with interests until you have covered at least 34 different dimensions above. Saving interests too early will reduce conversation quality.
- Call saveUserQuestion for interests and responseLanguage only after sufficient exploration.
- Update the persona document as you learn more — start from the initial read, merge new information in memory, then write the full content.
- **Persist each new fact on the turn you learn it.** Do NOT accumulate unwritten facts in memory waiting to do one big write at the end — that pattern is forbidden. If Persona is empty, call writeDocument(type="persona") this turn to seed it. On every subsequent turn where you learn something new (role, pain point, goal, preference, interest), call updateDocument(type="persona") with a targeted SEARCH/REPLACE hunk. Small incremental updates are the rule, not the exception.
- This phase should feel like a good first conversation, not an interview.
- Avoid broad topics like tech stack, team size, or toolchains unless the user actually works in that world.
- Keep your replies short during discovery — 2-4 sentences plus one follow-up question. Do not monologue.
@@ -89,7 +93,22 @@ Wrap up with a natural summary and set up the user's workspace.
- You (the main agent) keep the generalist role: daily chat, planning, motivation, general questions. The proposed assistants handle specialized recurring tasks.
- Ask the user if they want you to create these assistants. After confirmation, create them using the workspace setup tools. When creating agents, always include an emoji avatar.
- Keep the setup simple — usually 12 assistants is enough. Do not over-provision.
- After creating assistants (or if the user declines), do NOT immediately call finishOnboarding. First, send a warm closing message — acknowledge what you learned about the user, express genuine interest in working together, and give a brief teaser of what they can do next (e.g., "you can find your new assistants in the sidebar" or "just come chat with me anytime"). Keep it natural and human, 23 sentences. Then call finishOnboarding.
- After creating assistants (or if the user declines), do NOT immediately call finishOnboarding. First, send a warm closing message — acknowledge what you learned about the user, express genuine interest in working together, and give a brief teaser of what they can do next (e.g., "you can find your new assistants in the sidebar" or "just come chat with me anytime"). Keep it natural and human, 23 sentences. Then run the Pre-Finish Checklist and call finishOnboarding.
## Pre-Finish Checklist
Before EVERY finishOnboarding call (normal completion or early exit), you MUST verify the session has been persisted. Skipping this means the whole conversation was wasted — the user's info never lands in their workspace.
Mandatory ordered sequence:
1. Recall: mentally list every meaningful fact learned this session — agentName/emoji, fullName, role, pain points, goals, interests, personality, preferred language, and any assistants proposed or created.
2. Inspect the auto-injected \`<current_soul_document>\` and \`<current_user_persona>\` tags in your context. Do NOT call readDocument — the current contents are already present.
3. Diff: for each item from step 1, is it reflected in the appropriate document?
4. If SOUL.md is missing agent identity / voice / personality → **updateDocument(type="soul")** with SEARCH/REPLACE hunks for only the changed lines. Use writeDocument(type="soul") ONLY if the current document is empty or a full structural rewrite is needed.
5. If Persona is missing user facts → **updateDocument(type="persona")** with targeted hunks. Use writeDocument(type="persona") ONLY for an empty doc or full rewrite.
6. Only after both documents reflect the session, call finishOnboarding.
**Always prefer updateDocument (SEARCH/REPLACE hunks)** — it is cheaper, safer, and less error-prone than rewriting the entire document via writeDocument. Fall back to writeDocument only when the document is empty or when more than half the content must change.
## Early Exit
@@ -101,8 +120,7 @@ When you detect a completion signal:
1. Stop asking questions immediately. Do NOT ask follow-up questions.
2. If you haven't shown a summary yet, give a brief one now.
3. Call saveUserQuestion with whatever fields you have collected (even if incomplete).
4. Call updateDocument for both SOUL.md and User Persona with whatever you know.
5. Call finishOnboarding. This is non-negotiable — the user must not be kept waiting.
4. Run the Pre-Finish Checklist (read → diff → patch/update → finishOnboarding). This is non-negotiable — the user must not be kept waiting, but empty docs are worse than a short delay.
- Keep the farewell short. They should feel welcome to come back, not held hostage.
@@ -25,28 +25,12 @@ export const LobeActivatorManifest: BuiltinToolManifest = {
type: 'object',
},
},
{
description:
'Activate a skill by name to load its instructions. Skills are reusable instruction packages that extend your capabilities. Returns the skill content that you should follow to complete the task. If the skill is not found, returns a list of available skills.',
humanIntervention: 'required',
name: ActivatorApiName.activateSkill,
parameters: {
properties: {
name: {
description: 'The exact name of the skill to activate.',
type: 'string',
},
},
required: ['name'],
type: 'object',
},
},
],
identifier: LobeActivatorIdentifier,
meta: {
avatar: '🔧',
description: 'Discover and activate tools and skills',
title: 'Tools & Skills Activator',
description: 'Discover and activate tools',
title: 'Tools Activator',
},
systemRole: systemPrompt,
type: 'builtin',
@@ -1,4 +1,4 @@
export const systemPrompt = `You have access to a Tools & Skills Activator that allows you to dynamically activate tools and skills on demand. Not all tools are loaded by default — you must activate them before use. Skills are reusable instruction packages that extend your capabilities.
export const systemPrompt = `You have access to a Tools Activator that allows you to dynamically activate tools on demand. Not all tools are loaded by default — you must activate them before use.
<how_it_works>
1. Available tools are listed in the \`<available_tools>\` section of your system prompt
@@ -6,7 +6,7 @@ export const systemPrompt = `You have access to a Tools & Skills Activator that
3. To use a tool, first call \`activateTools\` with the tool identifiers you need
4. After activation, the tool's full API schemas become available as native function calls in subsequent turns
5. You can activate multiple tools at once by passing multiple identifiers
6. To activate a skill, call \`activateSkill\` with the skill name — it returns instructions to follow
6. To activate a skill, use the \`activateSkill\` tool from lobe-skills — it returns instructions to follow
</how_it_works>
<tool_selection_guidelines>
@@ -16,10 +16,7 @@ export const systemPrompt = `You have access to a Tools & Skills Activator that
- After activation, the tools' APIs will be available for you to call directly
- Tools that are already active will be noted in the response
- If an identifier is not found, it will be reported in the response
- **activateSkill**: Call this when the user's task matches one of the available skills
- Provide the exact skill name
- Returns the skill content (instructions, templates, guidelines) that you should follow
- If the skill is not found, you'll receive a list of available skills
- **activateSkill** (provided by lobe-skills): Use this when the user's task matches one of the available skills
- **IMPORTANT**: If a skill's content is already provided in \`<selected_skill_context>\` within the user message, do NOT call activateSkill for that skill — its instructions are already loaded and ready to use
</tool_selection_guidelines>
@@ -9,7 +9,18 @@
"./executionRuntime": "./src/ExecutionRuntime/index.ts"
},
"main": "./src/index.ts",
"dependencies": {
"@lobechat/markdown-patch": "workspace:*"
},
"devDependencies": {
"@lobechat/types": "workspace:*"
},
"peerDependencies": {
"@lobehub/ui": "^5",
"antd": "^6",
"antd-style": "*",
"lucide-react": "*",
"react": "*",
"react-i18next": "*"
}
}
@@ -1,3 +1,4 @@
import { applyMarkdownPatch, formatMarkdownPatchError } from '@lobechat/markdown-patch';
import type { BuiltinServerRuntimeOutput } from '@lobechat/types';
import type {
@@ -5,6 +6,7 @@ import type {
CreateDocumentArgs,
EditDocumentArgs,
ListDocumentsArgs,
PatchDocumentArgs,
ReadDocumentArgs,
ReadDocumentByFilenameArgs,
RemoveDocumentArgs,
@@ -15,7 +17,18 @@ import type {
interface AgentDocumentRecord {
content?: string;
/**
* The underlying `documents` table id. Used for portal rendering
* (opening the document in the shared EditorCanvas), which must resolve
* the row in `documents` — distinct from `id` which is the
* `agentDocuments` association row id.
*/
documentId?: string;
filename?: string;
/**
* The `agentDocuments` association row id. This is what the LLM receives
* and uses for subsequent operations (read/edit/remove/...).
*/
id: string;
title?: string;
}
@@ -172,7 +185,7 @@ export class AgentDocumentsExecutionRuntime {
return {
content: `Created document "${created.title || args.title}" (${created.id}).`,
state: { documentId: created.id },
state: { documentId: created.documentId },
success: true,
};
}
@@ -221,6 +234,46 @@ export class AgentDocumentsExecutionRuntime {
};
}
async patchDocument(
args: PatchDocumentArgs,
context?: AgentDocumentOperationContext,
): Promise<BuiltinServerRuntimeOutput> {
const agentId = this.resolveAgentId(context);
if (!agentId) {
return {
content: 'Cannot patch agent document without agentId context.',
success: false,
};
}
const doc = await this.service.readDocument({ agentId, id: args.id });
if (!doc) return { content: `Document not found: ${args.id}`, success: false };
const patched = applyMarkdownPatch(doc.content ?? '', args.hunks);
if (!patched.ok) {
const message = formatMarkdownPatchError(patched.error);
return {
content: message,
error: { body: patched.error, message, type: patched.error.code },
state: { error: patched.error, id: args.id },
success: false,
};
}
const updated = await this.service.editDocument({
agentId,
content: patched.content,
id: args.id,
});
if (!updated) return { content: `Failed to patch document ${args.id}.`, success: false };
return {
content: `Patched document ${args.id}. Applied ${patched.applied} hunk(s).`,
state: { applied: patched.applied, id: args.id, patched: true },
success: true,
};
}
async removeDocument(
args: RemoveDocumentArgs,
context?: AgentDocumentOperationContext,
@@ -1,118 +0,0 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
import {
AgentDocumentsApiName,
type CopyDocumentArgs,
type CreateDocumentArgs,
type EditDocumentArgs,
type ReadDocumentArgs,
type RemoveDocumentArgs,
type RenameDocumentArgs,
type UpdateLoadRuleArgs,
} from '../../../types';
type AgentDocumentsArgs =
| CopyDocumentArgs
| CreateDocumentArgs
| EditDocumentArgs
| ReadDocumentArgs
| RemoveDocumentArgs
| RenameDocumentArgs
| UpdateLoadRuleArgs;
const getInspectorSummary = (
apiName: string,
args?: Partial<AgentDocumentsArgs>,
): string | undefined => {
switch (apiName) {
case AgentDocumentsApiName.createDocument: {
return args && 'title' in args ? args.title : undefined;
}
case AgentDocumentsApiName.renameDocument: {
return args && 'newTitle' in args ? args.newTitle : undefined;
}
case AgentDocumentsApiName.copyDocument: {
if (args && 'newTitle' in args && args.newTitle) return args.newTitle;
return args && 'id' in args ? args.id : undefined;
}
case AgentDocumentsApiName.readDocument:
case AgentDocumentsApiName.editDocument:
case AgentDocumentsApiName.removeDocument:
case AgentDocumentsApiName.updateLoadRule: {
return args && 'id' in args ? args.id : undefined;
}
default: {
return undefined;
}
}
};
const getInspectorLabel = (apiName: string, t: (...args: any[]) => string) => {
switch (apiName) {
case AgentDocumentsApiName.createDocument: {
return t('builtins.lobe-agent-documents.apiName.createDocument');
}
case AgentDocumentsApiName.readDocument: {
return t('builtins.lobe-agent-documents.apiName.readDocument');
}
case AgentDocumentsApiName.editDocument: {
return t('builtins.lobe-agent-documents.apiName.editDocument');
}
case AgentDocumentsApiName.removeDocument: {
return t('builtins.lobe-agent-documents.apiName.removeDocument');
}
case AgentDocumentsApiName.renameDocument: {
return t('builtins.lobe-agent-documents.apiName.renameDocument');
}
case AgentDocumentsApiName.copyDocument: {
return t('builtins.lobe-agent-documents.apiName.copyDocument');
}
case AgentDocumentsApiName.updateLoadRule: {
return t('builtins.lobe-agent-documents.apiName.updateLoadRule');
}
default: {
return apiName;
}
}
};
export const AgentDocumentsInspector = memo<BuiltinInspectorProps<AgentDocumentsArgs>>(
({ apiName, args, partialArgs, isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
const summary = getInspectorSummary(apiName, args || partialArgs);
const label = getInspectorLabel(apiName, t);
if (isArgumentsStreaming && !summary) {
return <div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>{label}</div>;
}
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>{label}</span>
{summary && (
<>
<span>: </span>
<span className={highlightTextStyles.primary}>{summary}</span>
</>
)}
</div>
);
},
);
AgentDocumentsInspector.displayName = 'AgentDocumentsInspector';
export default AgentDocumentsInspector;
@@ -0,0 +1,41 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { CopyDocumentArgs, CopyDocumentState } from '../../../types';
export const CopyDocumentInspector = memo<
BuiltinInspectorProps<CopyDocumentArgs, CopyDocumentState>
>(({ args, partialArgs, isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
const summary = args?.newTitle || partialArgs?.newTitle || args?.id || partialArgs?.id;
if (isArgumentsStreaming && !summary) {
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-agent-documents.apiName.copyDocument')}</span>
</div>
);
}
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>{t('builtins.lobe-agent-documents.apiName.copyDocument')}: </span>
{summary && <span className={highlightTextStyles.primary}>{summary}</span>}
</div>
);
});
CopyDocumentInspector.displayName = 'CopyDocumentInspector';
export default CopyDocumentInspector;
@@ -0,0 +1,41 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { CreateDocumentArgs, CreateDocumentState } from '../../../types';
export const CreateDocumentInspector = memo<
BuiltinInspectorProps<CreateDocumentArgs, CreateDocumentState>
>(({ args, partialArgs, isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
const title = args?.title || partialArgs?.title;
if (isArgumentsStreaming && !title) {
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-agent-documents.apiName.createDocument')}</span>
</div>
);
}
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>{t('builtins.lobe-agent-documents.apiName.createDocument')}: </span>
{title && <span className={highlightTextStyles.primary}>{title}</span>}
</div>
);
});
CreateDocumentInspector.displayName = 'CreateDocumentInspector';
export default CreateDocumentInspector;
@@ -0,0 +1,41 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { EditDocumentArgs, EditDocumentState } from '../../../types';
export const EditDocumentInspector = memo<
BuiltinInspectorProps<EditDocumentArgs, EditDocumentState>
>(({ args, partialArgs, isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
const id = args?.id || partialArgs?.id;
if (isArgumentsStreaming && !id) {
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-agent-documents.apiName.editDocument')}</span>
</div>
);
}
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>{t('builtins.lobe-agent-documents.apiName.editDocument')}: </span>
{id && <span className={highlightTextStyles.primary}>{id}</span>}
</div>
);
});
EditDocumentInspector.displayName = 'EditDocumentInspector';
export default EditDocumentInspector;
@@ -0,0 +1,31 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { ListDocumentsArgs, ListDocumentsState } from '../../../types';
export const ListDocumentsInspector = memo<
BuiltinInspectorProps<ListDocumentsArgs, ListDocumentsState>
>(({ isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>{t('builtins.lobe-agent-documents.apiName.listDocuments')}</span>
</div>
);
});
ListDocumentsInspector.displayName = 'ListDocumentsInspector';
export default ListDocumentsInspector;
@@ -0,0 +1,41 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { PatchDocumentArgs, PatchDocumentState } from '../../../types';
export const PatchDocumentInspector = memo<
BuiltinInspectorProps<PatchDocumentArgs, PatchDocumentState>
>(({ args, partialArgs, isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
const id = args?.id || partialArgs?.id;
if (isArgumentsStreaming && !id) {
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-agent-documents.apiName.patchDocument')}</span>
</div>
);
}
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>{t('builtins.lobe-agent-documents.apiName.patchDocument')}: </span>
{id && <span className={highlightTextStyles.primary}>{id}</span>}
</div>
);
});
PatchDocumentInspector.displayName = 'PatchDocumentInspector';
export default PatchDocumentInspector;
@@ -0,0 +1,41 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { ReadDocumentArgs, ReadDocumentState } from '../../../types';
export const ReadDocumentInspector = memo<
BuiltinInspectorProps<ReadDocumentArgs, ReadDocumentState>
>(({ args, partialArgs, pluginState, isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
const summary = pluginState?.title || args?.id || partialArgs?.id;
if (isArgumentsStreaming && !summary) {
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-agent-documents.apiName.readDocument')}</span>
</div>
);
}
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>{t('builtins.lobe-agent-documents.apiName.readDocument')}: </span>
{summary && <span className={highlightTextStyles.primary}>{summary}</span>}
</div>
);
});
ReadDocumentInspector.displayName = 'ReadDocumentInspector';
export default ReadDocumentInspector;
@@ -0,0 +1,41 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { ReadDocumentByFilenameArgs, ReadDocumentByFilenameState } from '../../../types';
export const ReadDocumentByFilenameInspector = memo<
BuiltinInspectorProps<ReadDocumentByFilenameArgs, ReadDocumentByFilenameState>
>(({ args, partialArgs, isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
const filename = args?.filename || partialArgs?.filename;
if (isArgumentsStreaming && !filename) {
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-agent-documents.apiName.readDocumentByFilename')}</span>
</div>
);
}
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>{t('builtins.lobe-agent-documents.apiName.readDocumentByFilename')}: </span>
{filename && <span className={highlightTextStyles.primary}>{filename}</span>}
</div>
);
});
ReadDocumentByFilenameInspector.displayName = 'ReadDocumentByFilenameInspector';
export default ReadDocumentByFilenameInspector;
@@ -0,0 +1,41 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { RemoveDocumentArgs, RemoveDocumentState } from '../../../types';
export const RemoveDocumentInspector = memo<
BuiltinInspectorProps<RemoveDocumentArgs, RemoveDocumentState>
>(({ args, partialArgs, isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
const id = args?.id || partialArgs?.id;
if (isArgumentsStreaming && !id) {
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-agent-documents.apiName.removeDocument')}</span>
</div>
);
}
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>{t('builtins.lobe-agent-documents.apiName.removeDocument')}: </span>
{id && <span className={highlightTextStyles.primary}>{id}</span>}
</div>
);
});
RemoveDocumentInspector.displayName = 'RemoveDocumentInspector';
export default RemoveDocumentInspector;
@@ -0,0 +1,41 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { RenameDocumentArgs, RenameDocumentState } from '../../../types';
export const RenameDocumentInspector = memo<
BuiltinInspectorProps<RenameDocumentArgs, RenameDocumentState>
>(({ args, partialArgs, isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
const newTitle = args?.newTitle || partialArgs?.newTitle;
if (isArgumentsStreaming && !newTitle) {
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-agent-documents.apiName.renameDocument')}</span>
</div>
);
}
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>{t('builtins.lobe-agent-documents.apiName.renameDocument')}: </span>
{newTitle && <span className={highlightTextStyles.primary}>{newTitle}</span>}
</div>
);
});
RenameDocumentInspector.displayName = 'RenameDocumentInspector';
export default RenameDocumentInspector;
@@ -0,0 +1,41 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { UpdateLoadRuleArgs, UpdateLoadRuleState } from '../../../types';
export const UpdateLoadRuleInspector = memo<
BuiltinInspectorProps<UpdateLoadRuleArgs, UpdateLoadRuleState>
>(({ args, partialArgs, isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
const id = args?.id || partialArgs?.id;
if (isArgumentsStreaming && !id) {
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-agent-documents.apiName.updateLoadRule')}</span>
</div>
);
}
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>{t('builtins.lobe-agent-documents.apiName.updateLoadRule')}: </span>
{id && <span className={highlightTextStyles.primary}>{id}</span>}
</div>
);
});
UpdateLoadRuleInspector.displayName = 'UpdateLoadRuleInspector';
export default UpdateLoadRuleInspector;
@@ -0,0 +1,41 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { UpsertDocumentByFilenameArgs, UpsertDocumentByFilenameState } from '../../../types';
export const UpsertDocumentByFilenameInspector = memo<
BuiltinInspectorProps<UpsertDocumentByFilenameArgs, UpsertDocumentByFilenameState>
>(({ args, partialArgs, isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
const filename = args?.filename || partialArgs?.filename;
if (isArgumentsStreaming && !filename) {
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-agent-documents.apiName.upsertDocumentByFilename')}</span>
</div>
);
}
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>{t('builtins.lobe-agent-documents.apiName.upsertDocumentByFilename')}: </span>
{filename && <span className={highlightTextStyles.primary}>{filename}</span>}
</div>
);
});
UpsertDocumentByFilenameInspector.displayName = 'UpsertDocumentByFilenameInspector';
export default UpsertDocumentByFilenameInspector;
@@ -1,14 +1,30 @@
import type { BuiltinInspector } from '@lobechat/types';
import { AgentDocumentsApiName } from '../../types';
import { AgentDocumentsInspector } from './AgentDocumentsInspector';
import { CopyDocumentInspector } from './CopyDocument';
import { CreateDocumentInspector } from './CreateDocument';
import { EditDocumentInspector } from './EditDocument';
import { ListDocumentsInspector } from './ListDocuments';
import { PatchDocumentInspector } from './PatchDocument';
import { ReadDocumentInspector } from './ReadDocument';
import { ReadDocumentByFilenameInspector } from './ReadDocumentByFilename';
import { RemoveDocumentInspector } from './RemoveDocument';
import { RenameDocumentInspector } from './RenameDocument';
import { UpdateLoadRuleInspector } from './UpdateLoadRule';
import { UpsertDocumentByFilenameInspector } from './UpsertDocumentByFilename';
export const AgentDocumentsInspectors: Record<string, BuiltinInspector> = {
[AgentDocumentsApiName.createDocument]: AgentDocumentsInspector as BuiltinInspector,
[AgentDocumentsApiName.copyDocument]: AgentDocumentsInspector as BuiltinInspector,
[AgentDocumentsApiName.editDocument]: AgentDocumentsInspector as BuiltinInspector,
[AgentDocumentsApiName.readDocument]: AgentDocumentsInspector as BuiltinInspector,
[AgentDocumentsApiName.removeDocument]: AgentDocumentsInspector as BuiltinInspector,
[AgentDocumentsApiName.renameDocument]: AgentDocumentsInspector as BuiltinInspector,
[AgentDocumentsApiName.updateLoadRule]: AgentDocumentsInspector as BuiltinInspector,
[AgentDocumentsApiName.copyDocument]: CopyDocumentInspector as BuiltinInspector,
[AgentDocumentsApiName.createDocument]: CreateDocumentInspector as BuiltinInspector,
[AgentDocumentsApiName.editDocument]: EditDocumentInspector as BuiltinInspector,
[AgentDocumentsApiName.listDocuments]: ListDocumentsInspector as BuiltinInspector,
[AgentDocumentsApiName.patchDocument]: PatchDocumentInspector as BuiltinInspector,
[AgentDocumentsApiName.readDocument]: ReadDocumentInspector as BuiltinInspector,
[AgentDocumentsApiName.readDocumentByFilename]:
ReadDocumentByFilenameInspector as BuiltinInspector,
[AgentDocumentsApiName.removeDocument]: RemoveDocumentInspector as BuiltinInspector,
[AgentDocumentsApiName.renameDocument]: RenameDocumentInspector as BuiltinInspector,
[AgentDocumentsApiName.updateLoadRule]: UpdateLoadRuleInspector as BuiltinInspector,
[AgentDocumentsApiName.upsertDocumentByFilename]:
UpsertDocumentByFilenameInspector as BuiltinInspector,
};
@@ -0,0 +1,130 @@
'use client';
import { ActionIcon, CopyButton, Flexbox, Markdown, ScrollShadow, TooltipGroup } from '@lobehub/ui';
import { Button } from 'antd';
import { createStaticStyles } from 'antd-style';
import { FileTextIcon, Maximize2, Minimize2, PencilLine } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useChatStore } from '@/store/chat';
import { chatPortalSelectors } from '@/store/chat/slices/portal/selectors';
const styles = createStaticStyles(({ css, cssVar }) => ({
container: css`
position: relative;
overflow: hidden;
width: 100%;
border: 1px solid ${cssVar.colorBorderSecondary};
border-radius: 16px;
background: ${cssVar.colorBgContainer};
`,
content: css`
padding-inline: 16px;
font-size: 14px;
`,
expandButton: css`
position: absolute;
inset-block-end: 16px;
inset-inline-start: 50%;
transform: translateX(-50%);
box-shadow: ${cssVar.boxShadow};
`,
header: css`
padding-block: 10px;
padding-inline: 12px;
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
`,
icon: css`
color: ${cssVar.colorPrimary};
`,
title: css`
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
font-weight: 500;
color: ${cssVar.colorText};
`,
}));
interface DocumentCardProps {
content: string;
documentId?: string;
title: string;
}
const DocumentCard = memo<DocumentCardProps>(({ content, documentId, title }) => {
const { t } = useTranslation('plugin');
const [portalDocumentId, openDocument, closeDocument] = useChatStore((s) => [
chatPortalSelectors.portalDocumentId(s),
s.openDocument,
s.closeDocument,
]);
const isExpanded = !!documentId && portalDocumentId === documentId;
const handleToggle = () => {
if (!documentId) return;
if (isExpanded) {
closeDocument();
} else {
openDocument(documentId);
}
};
return (
<Flexbox className={styles.container}>
<Flexbox horizontal align={'center'} className={styles.header} gap={8}>
<FileTextIcon className={styles.icon} size={16} />
<Flexbox flex={1}>
<div className={styles.title}>{title}</div>
</Flexbox>
<TooltipGroup>
<Flexbox horizontal gap={4}>
<CopyButton
content={content}
size={'small'}
title={t('builtins.lobe-notebook.actions.copy')}
/>
{documentId && (
<ActionIcon
icon={PencilLine}
size={'small'}
title={t('builtins.lobe-notebook.actions.edit')}
onClick={handleToggle}
/>
)}
</Flexbox>
</TooltipGroup>
</Flexbox>
<ScrollShadow className={styles.content} offset={12} size={12} style={{ maxHeight: 400 }}>
<Markdown style={{ overflow: 'unset', paddingBottom: 40 }} variant={'chat'}>
{content}
</Markdown>
</ScrollShadow>
{documentId && (
<Button
className={styles.expandButton}
color={'default'}
icon={isExpanded ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
shape={'round'}
variant={'outlined'}
onClick={handleToggle}
>
{isExpanded
? t('builtins.lobe-notebook.actions.collapse')
: t('builtins.lobe-notebook.actions.expand')}
</Button>
)}
</Flexbox>
);
});
export default DocumentCard;
@@ -0,0 +1,23 @@
'use client';
import type { BuiltinRenderProps } from '@lobechat/types';
import { memo } from 'react';
import type { CreateDocumentArgs, CreateDocumentState } from '../../../types';
import DocumentCard from './DocumentCard';
export type CreateDocumentRenderProps = Pick<
BuiltinRenderProps<CreateDocumentArgs, CreateDocumentState>,
'args' | 'pluginState'
>;
const CreateDocument = memo<CreateDocumentRenderProps>(({ args, pluginState }) => {
const title = args?.title;
const content = args?.content;
if (!title || !content) return null;
return <DocumentCard content={content} documentId={pluginState?.documentId} title={title} />;
});
export default CreateDocument;
@@ -1,3 +1,7 @@
// Intentionally empty: Agent Documents has no dedicated render components yet.
// Keep this export as a stable extension point for future UI renderers.
export const AgentDocumentsRenders = {};
import CreateDocument from './CreateDocument';
export const AgentDocumentsRenders = {
createDocument: CreateDocument,
};
export { default as CreateDocument } from './CreateDocument';

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