Compare commits

..

1 Commits

Author SHA1 Message Date
Arvin Xu 0469464c37 feat: optimize summary title prompt with few-shot and language rules
Add few-shot examples, explicit locale-to-language mapping, anti-filler
constraints, emotion-vs-intent guidance, and technical term preservation
rules. Improves pass rate from 80% to 100% on gpt-5-mini across 10 eval cases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 18:41:40 +08:00
1572 changed files with 15555 additions and 105979 deletions
-298
View File
@@ -1,298 +0,0 @@
---
name: bot
description: 'Bot platform architecture (Discord, Slack, Telegram, Feishu/Lark, QQ, WeChat). Use when working on inbound webhooks, Chat SDK message routing, agent execution from chat platforms, queue-mode callbacks, gateway lifecycle (websocket/polling), bot provider CRUD/credentials, or platform-specific clients/adapters/schemas. Triggers on bot, channel, webhook, mention, Chat SDK, agent bot provider, gateway, bot-callback, qstash bot.'
---
# Bot System
> **Last updated: 2026-04-08.** Implementation evolves quickly — this doc is a map, not the source of truth. Always read the key files below to verify behavior, especially per-platform quirks. Update this doc when the architecture changes.
LobeChat agents can answer inside external chat platforms. Inbound messages flow through the Chat SDK (`chat` npm package), get routed to the right agent by `(platform, applicationId)`, executed via `AiAgentService`, and replied back through a per-platform `PlatformClient`. There are **two execution modes** (in-memory vs queue/QStash) and **three connection modes** (`webhook`, `websocket`, `polling`).
## Supported Platforms
| Platform | id | Default mode | Markdown | Edit | Notes |
| -------- | ---------- | ------------------------------- | ----------------- | ------ | -------------------------------------------------------------------------------------- |
| Discord | `discord` | `websocket` | yes | yes | Persistent gateway via Chat SDK adapter; reaction-thread quirks; native slash commands |
| Slack | `slack` | `websocket` (Socket Mode) | yes (mrkdwn) | yes | Multi-mode — user can pick `webhook` per provider |
| Telegram | `telegram` | `webhook` | yes (HTML) | yes | `setMyCommands` menu via `registerBotCommands` |
| Feishu | `feishu` | `websocket` (Lark SDK WSClient) | **no** (stripped) | yes | Multi-mode; shared client with Lark |
| Lark | `lark` | `websocket` | **no** | yes | Same client/schema as Feishu, different domain |
| QQ | `qq` | `websocket` | **no** | **no** | All replies are final-only |
| WeChat | `wechat` | `polling` (iLink long-poll) | **no** | **no** | 10-minute gateway window |
`supportsMarkdown=false` ⇒ outbound markdown is stripped to plain text via `stripMarkdown` and the AI is told not to use markdown. `supportsMessageEdit=false` ⇒ no progress edits — only the final reply is sent.
**Multi-mode connection** — Slack/Feishu/Lark/QQ shipped as websocket but support `webhook` per-provider via `settings.connectionMode`. Legacy rows without that field stay on `webhook` (see `LEGACY_WEBHOOK_PLATFORMS` in `platforms/utils.ts`) — **never add new platforms to that list**.
## Inbound Flow (one webhook → reply)
```
Platform server
│ POST /api/agent/webhooks/[platform]/[appId]
route.ts ── catch-all `[[...appId]]` route
BotMessageRouter (singleton)
│ • lazy-loads bot per `platform:applicationId`
│ • merges schema defaults + provider.settings (mergeWithDefaults)
│ • builds Chat SDK Chat<any> with createIoRedisState (if Redis available)
│ • registerHandlers: onNewMention / onSubscribedMessage / onNewMessage(/.dm)
│ • registerCommands: /new (reset topic), /stop (interrupt)
chatBot.webhooks[platform](req) ← Chat SDK parses → fires events
AgentBridgeService.handleMention / handleSubscribedMessage
│ • activeThreads guard (no duplicate runs per thread)
│ • adds 👀 reaction (eyes), startTyping
│ • merges debounced/queued skipped messages (mergeSkippedMessages)
│ • extractFiles (buffer → fetchData → url)
│ • formatPrompt (sanitize mention + speaker tag + referenced_message)
├── In-memory mode ──► AiAgentService.execAgent({ stepCallbacks })
│ → onAfterStep edits progress message live
│ → onComplete edits final reply, splits via splitMessage(charLimit)
└── Queue mode (isQueueAgentRuntimeEnabled) ──► execAgent({ stepWebhook, completionWebhook, webhookDelivery: 'qstash' })
→ returns immediately, callbacks land at /api/agent/webhooks/bot-callback
```
The router caches loaded bots in memory. Cache is **invalidated** by `BotMessageRouter.invalidateBot(platform, appId)` whenever the TRPC `update`/`delete` mutations run, so new credentials/settings take effect on the next webhook.
## Execution Modes
### In-memory (default)
`AgentBridgeService.executeWithInMemoryCallbacks` wraps `execAgent` with `stepCallbacks`. Lives in one process — Promise-based wait, 30-min timeout, edits the same `progressMessage` after every step. Topic title is summarized inline via `SystemAgentService`.
### Queue (`isQueueAgentRuntimeEnabled`)
`AgentBridgeService.executeWithWebhooks`:
1. Posts the `renderStart` placeholder, captures `progressMessageId`.
2. Calls `execAgent` with `stepWebhook` and `completionWebhook` pointing at `${INTERNAL_APP_URL ?? APP_URL}/api/agent/webhooks/bot-callback`, plus `webhookDelivery: 'qstash'`.
3. Returns immediately; the bridge `finally` block keeps the active-thread marker held until the `completion` callback fires.
`/api/agent/webhooks/bot-callback/route.ts` verifies the QStash signature and hands off to `BotCallbackService.handleCallback`:
- `type: 'step'``handleStep` re-renders `renderStepProgress`, edits `progressMessageId` (skipped if `displayToolCalls=false` or platform `supportsMessageEdit=false`).
- `type: 'completion'``handleCompletion` writes the final reply (or error/interrupted message), removes the 👀 reaction, clears active-thread tracker, fires async `summarizeTopicTitle`.
`BotCallbackService.createMessenger` reloads provider + credentials from DB and rebuilds a `PlatformClient` per call (no in-memory state).
## Commands
Defined in `BotMessageRouter.buildCommands` and registered via two paths:
- **Native slash commands** (Slack/Discord): `bot.onSlashCommand('/<name>', ...)`
- **Text-based fallback** (Telegram/Feishu/QQ/Lark/WeChat): `bot.onNewMessage(/^\/(new|stop)(\s|$|@)/, ...)` plus a per-mention `tryDispatch` so commands work even before subscribe.
Built-in commands:
- `/new` — clears `topicId` in thread state, next message starts a fresh topic.
- `/stop` — interrupts the active execution (calls `AiAgentService.interruptTask` if `operationId` is known; otherwise queues a deferred stop via `requestStop`/`pendingStopThreads`, also aborts the startup phase via `startupControllers`).
To add a command, append to `buildCommands` — it auto-registers everywhere; on Telegram it also surfaces in the `/` menu via `client.registerBotCommands``setMyCommands`.
## Active-thread State (statics on `AgentBridgeService`)
- `activeThreads: Set<threadId>` — prevents duplicate runs per thread (must guard before stale-topic check, otherwise concurrent messages can drop).
- `activeOperations: Map<threadId, operationId>` — needed by `/stop` once `execAgent` returns.
- `startupControllers: Map<threadId, AbortController>` — cancels pre-`operationId` work (topic/tool prep).
- `pendingStopThreads: Set<threadId>``/stop` arrived before `operationId` existed; consumed once available.
In **queue mode**, the bridge `finally` skips cleanup so the marker persists until `BotCallbackService.handleCompletion` calls `clearActiveThread`.
## Topic Lifecycle in Threads
- `handleMention` always treats the message as the start of a new conversation.
- `handleSubscribedMessage` reads `topicId` from `thread.state`. If the topic is stale (`> 4 hours` since `updatedAt`), state is cleared and it retries as a fresh mention.
- If `execAgent` fails with a Postgres FK violation on `topic_id` (cached topic was deleted), the bridge clears state and retries as a mention.
- `subscribe()` is gated by `client.shouldSubscribe(threadId)` — Discord top-level channels return `false` so we don't follow up there.
## Attachments
`AgentBridgeService.extractFiles` resolves attachments in priority order:
1. `att.buffer` — already downloaded by the adapter (WeChat/Feishu inbound).
2. `att.fetchData()` — adapter-provided lazy download with auth (Telegram, Slack, Feishu history). **Required** when URLs are token-protected — naive `fetch(url)` later in `ingestAttachment.ts` has no credentials.
3. `att.url` — public CDN fallback (Discord, public QQ).
`inferMimeType` / `inferName` patch Telegram-style `photo` payloads (no `mimeType`/`name` from Bot API → defaults to `image/jpeg`) so vision models actually see them. Quoted-message attachments are also pulled from `raw.referenced_message.attachments` (Discord).
## Concurrency
`settings.concurrency` is `'queue'` or `'debounce'`:
- `debounce` → Chat SDK debounces inbound messages by `debounceMs`; `mergeSkippedMessages` joins skipped texts/attachments into the current message before handing to the agent.
- `queue` → Chat SDK serializes per-thread; the bridge's own `activeThreads` set is still required because in queue mode the SDK lock releases before the agent finishes.
## Gateway (persistent platforms)
Webhook platforms run fine in serverless functions. Persistent platforms (`websocket`, `polling`) need a long-running listener — that's the **gateway**.
**`GatewayService.startClient(platform, appId, userId)`** (`src/server/services/gateway/index.ts`):
- On Vercel + persistent mode → `BotConnectQueue.push` (Redis hash) and mark runtime status `queued`. The cron picks it up.
- On Vercel + webhook mode → start the client inline (one HTTP call).
- Off-Vercel → `GatewayManager` singleton holds long-lived clients in process.
**`GET /api/agent/gateway/route.ts`** (cron, `Bearer ${CRON_SECRET}`):
- Iterates registered platforms and starts every enabled persistent provider with `durationMs = 10min`, then in `after(...)` polls `BotConnectQueue` every 30s for new connect requests, until the window expires.
- `getEffectiveConnectionMode(platform, settings)` is the only place that resolves per-provider mode — respect it everywhere.
**`POST /api/agent/gateway/start/route.ts`** is the non-Vercel `ensureRunning` entry point (`Bearer ${KEY_VAULTS_SECRET}`).
**Runtime status** is stored in Redis at `bot:runtime-status:platform:appId` with TTL ≈ `durationMs + 60s`. States: `starting | connected | disconnected | failed | queued`. Updated by each `PlatformClient.start/stop` and by the gateway service.
## Platform Definitions
Each platform exposes a `PlatformDefinition` registered in `platforms/index.ts`:
```ts
{
id: 'discord',
name: 'Discord',
connectionMode: 'websocket', // recommended default
schema: FieldSchema[], // applicationId + credentials + settings
clientFactory: new DiscordClientFactory(),
supportsMarkdown?: boolean, // default true
supportsMessageEdit?: boolean, // default true
documentation?: { portalUrl, setupGuideUrl },
}
```
`schema` drives both server validation (`mergeWithDefaults`, `extractDefaults`) **and** the auto-generated UI form. Top-level keys `applicationId` / `credentials` / `settings` map to DB columns. Common settings fields live in `platforms/const.ts` (`displayToolCallsField`, `serverIdField`, `userIdField`).
Each platform implements `PlatformClient` (see `platforms/types.ts`):
- Lifecycle: `start(opts?)`, `stop()`
- Inbound: `createAdapter()` → Chat SDK adapter map
- Outbound: `getMessenger(platformThreadId)``{ createMessage, editMessage, removeReaction, triggerTyping, updateThreadName? }`
- Formatting: `formatMarkdown?`, `formatReply?` (usage-stats footer when `showUsageStats`)
- Helpers: `extractChatId`, `parseMessageId`, `sanitizeUserInput`, `shouldSubscribe`, `resolveReactionThreadId`
- Optional patches: `applyChatPatches(chatBot)` (Discord uses this for `forwardedInteractions` + `threadRecovery`)
- Optional menu: `registerBotCommands(commands)` (Telegram `setMyCommands`)
`ClientFactory.validateCredentials` is called from the TRPC `testConnection` mutation — implement it to hit the platform API and return useful per-field errors.
## Database
**Schema** (`packages/database/src/schemas/agentBotProvider.ts`):
```ts
agent_bot_providers (
id uuid pk,
agent_id text fk agents.id (cascade),
user_id text fk users.id (cascade),
platform varchar(50), // 'discord' | 'slack' | …
application_id varchar(255),
credentials text, // KeyVaults-encrypted JSON
settings jsonb default '{}',
enabled boolean default true,
timestamps
)
unique (platform, application_id)
```
**Model** (`packages/database/src/models/agentBotProvider.ts`):
- User-scoped: `create / update / delete / query / findById / findByAgentId / findEnabledByApplicationId`. Credentials are encrypted/decrypted via the injected `KeyVaultsGateKeeper`.
- Static (system-wide): `findByPlatformAndAppId`, `findEnabledByPlatform` — used by webhook routing & gateway sync, since they don't have a user context yet.
**TRPC router** (`src/server/routers/lambda/agentBotProvider.ts`):
| Procedure | Notes | |
| -------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------ |
| `listPlatforms` | Returns `SerializedPlatformDefinition[]` (no `clientFactory`) | |
| `create` / `update` / `delete` | Calls `BotMessageRouter.invalidateBot` + `GatewayService.stopClient` so changes take effect | |
| `list` / `getByAgentId` / `getRuntimeStatus` | Decorate rows with Redis runtime status | |
| `connectBot` | Returns \`{ status: 'started' | 'queued' }\` |
| `testConnection` | Calls `clientFactory.validateCredentials` | |
| `wechatGetQrCode` / `wechatPollQrStatus` | iLink onboarding flow | |
Client service: `src/services/agentBotProvider.ts`. Store actions: `src/store/agent/slices/bot/action.ts`. UI: `src/routes/(main)/agent/channel/{list,detail}` — settings form is auto-generated from each platform's `schema`.
## Reply Templates
`src/server/services/bot/replyTemplate.ts` exports `renderStart`, `renderStepProgress`, `renderFinalReply`, `renderError`, `renderStopped`, `splitMessage`. Step progress carries elapsed time, last LLM content, last tools, totals; final reply uses `client.formatMarkdown` then `client.formatReply` (which optionally appends `formatUsageStats`). `splitMessage(text, charLimit)` chunks at paragraph → line → hard cut.
`src/server/services/bot/ackPhrases/` provides randomized ack phrases.
## Key Files
```plaintext
Webhook routes:
src/app/(backend)/api/agent/webhooks/[platform]/[[...appId]]/route.ts — inbound catch-all
src/app/(backend)/api/agent/webhooks/bot-callback/route.ts — qstash bot callback
src/app/(backend)/api/agent/gateway/route.ts — cron gateway (10min window)
src/app/(backend)/api/agent/gateway/start/route.ts — non-Vercel ensureRunning
Bot service:
src/server/services/bot/index.ts — barrel
src/server/services/bot/BotMessageRouter.ts — lazy bot loading + handler registration + commands
src/server/services/bot/AgentBridgeService.ts — Chat SDK ↔ AiAgentService bridge, both exec modes
src/server/services/bot/BotCallbackService.ts — qstash callback handler
src/server/services/bot/formatPrompt.ts — speaker tag + referenced_message + sanitize
src/server/services/bot/replyTemplate.ts — render*/splitMessage
src/server/services/bot/ackPhrases/ — randomized acks
src/server/services/bot/__tests__/ — unit tests for the above
Platform abstraction:
src/server/services/bot/platforms/index.ts — registry singleton + exports
src/server/services/bot/platforms/types.ts — PlatformClient/Definition/FieldSchema/ClientFactory
src/server/services/bot/platforms/registry.ts — PlatformRegistry class
src/server/services/bot/platforms/utils.ts — mergeWithDefaults, getEffectiveConnectionMode, formatUsageStats, runtimeKey
src/server/services/bot/platforms/const.ts — shared FieldSchema fragments (displayToolCalls, serverId, userId)
src/server/services/bot/platforms/stripMarkdown.ts — used by no-markdown platforms
Per-platform (each ships definition.ts, schema.ts, client.ts, const.ts, protocol-spec.md):
src/server/services/bot/platforms/discord/ — websocket gateway + chat patches
src/server/services/bot/platforms/slack/ — multi-mode (Socket Mode / webhook), markdownToMrkdwn
src/server/services/bot/platforms/telegram/ — webhook, markdownToHTML, registerBotCommands
src/server/services/bot/platforms/feishu/ — feishu + lark share client/schema (definitions/{feishu,lark,shared}.ts)
src/server/services/bot/platforms/qq/ — websocket, no markdown, no edit
src/server/services/bot/platforms/wechat/ — long-poll, no markdown, no edit
Gateway:
src/server/services/gateway/index.ts — GatewayService (Vercel-aware startClient/stopClient)
src/server/services/gateway/GatewayManager.ts — long-running client registry (non-Vercel)
src/server/services/gateway/botConnectQueue.ts — Redis hash queue with TTL
src/server/services/gateway/runtimeStatus.ts — Redis bot:runtime-status keys
Database:
packages/database/src/schemas/agentBotProvider.ts — agent_bot_providers table
packages/database/src/models/agentBotProvider.ts — encrypted CRUD + system-wide finders
TRPC + client:
src/server/routers/lambda/agentBotProvider.ts — TRPC router
src/services/agentBotProvider.ts — client wrapper
src/store/agent/slices/bot/action.ts — Zustand actions
UI:
src/routes/(main)/agent/channel/list.tsx — channel list
src/routes/(main)/agent/channel/detail/ — auto-generated form (Header/Body/Footer)
src/routes/(main)/agent/channel/const.ts — platform icons
Types & runtime status:
src/types/botRuntimeStatus.ts — BOT_RUNTIME_STATUSES enum + snapshot type
```
## Adding a New Platform
1. Create `src/server/services/bot/platforms/<id>/`:
- `definition.ts``PlatformDefinition` registered in `platforms/index.ts`
- `schema.ts``FieldSchema[]` (`applicationId` + `credentials` + `settings`); reuse fragments from `../const.ts`
- `client.ts``class XClientFactory extends ClientFactory` returning a `PlatformClient` (lifecycle + adapter + messenger + helpers)
- `const.ts``DEFAULT_X_CONNECTION_MODE`, history limits, etc.
- `protocol-spec.md` — protocol notes (every existing platform has one)
2. Pick the right `connectionMode` — webhook is much simpler if the platform supports it.
3. If the platform can't render markdown, set `supportsMarkdown: false` and implement `formatMarkdown` via `stripMarkdown`.
4. If it can't edit messages, set `supportsMessageEdit: false``BotCallbackService` will skip step edits and only send the final reply.
5. Implement `validateCredentials` so the UI's "Test connection" button gives useful errors.
6. Add the platform icon in `src/routes/(main)/agent/channel/const.ts` and register the platform in `src/server/services/bot/platforms/index.ts`.
7. Add i18n keys under `channel.*` in `src/locales/default/setting.ts` (or wherever the channel namespace lives) — the schema's `label`/`description`/`placeholder`/`enumLabels` are i18n keys.
+1 -1
View File
@@ -46,7 +46,7 @@ description: 'Code review checklist for LobeHub. Use when reviewing PRs, diffs,
- Newly written code duplicates existing utilities in `packages/utils` or shared modules?
- Copy-pasted blocks with slight variation — extract into shared function
- `antd` imports replaceable with `@lobehub/ui` wrapped components (`Input`, `Button`, `Modal`, `Avatar`, etc.)
- Use `antd-style` token system, not hardcoded colors; prefer `createStaticStyles` + `cssVar.*` over `createStyles` + `token` unless runtime computation is required
- Use `antd-style` token system, not hardcoded colors
### Database
-155
View File
@@ -1,155 +0,0 @@
---
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
+635 -30
View File
@@ -173,10 +173,6 @@ 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
@@ -383,30 +379,621 @@ agent-browser --auto-connect snapshot -i
# Part 2: osascript (Native macOS App Bot Testing)
Use AppleScript via `osascript` to control native macOS desktop apps for bot testing. Works with any app that supports macOS Accessibility, no CDP or Chromium needed.
Use AppleScript via `osascript` to control native macOS desktop apps for bot testing. This works with any app that supports macOS Accessibility, without needing CDP or Chromium.
The pattern is the same for every platform:
## Core osascript Patterns
1. **Activate** the app (`tell application "X" to activate`)
2. **Navigate** to a channel/chat (Quick Switcher `Cmd+K` or Search `Cmd+F`)
3. **Send** a message (clipboard paste `Cmd+V` + Enter)
4. **Wait** for the bot response
5. **Screenshot** for verification (`screencapture` + `Read` tool)
### Activate an App
## Per-Platform References
```bash
osascript -e 'tell application "Discord" to activate'
```
Pick the file for your target platform — each contains activation, navigation, send-message, and verification snippets specific to that app:
### Type Text
| Platform | Reference | Quick switcher |
| ------------- | -------------------------------------------------- | -------------- |
| Discord | [references/discord.md](./references/discord.md) | `Cmd+K` |
| Slack | [references/slack.md](./references/slack.md) | `Cmd+K` |
| Telegram | [references/telegram.md](./references/telegram.md) | `Cmd+F` |
| WeChat / 微信 | [references/wechat.md](./references/wechat.md) | `Cmd+F` |
| Lark / 飞书 | [references/lark.md](./references/lark.md) | `Cmd+K` |
| QQ | [references/qq.md](./references/qq.md) | `Cmd+F` |
```bash
# Type character by character (reliable, but slow for long text)
osascript -e 'tell application "System Events" to keystroke "Hello world"'
For **shared osascript patterns** (activate, type, paste, screenshot, read accessibility, common workflow template, gotchas), see [references/osascript-common.md](./references/osascript-common.md). Read this first if you're new to osascript automation.
# Press Enter
osascript -e 'tell application "System Events" to key code 36'
# Press Tab
osascript -e 'tell application "System Events" to key code 48'
# Press Escape
osascript -e 'tell application "System Events" to key code 53'
```
### Paste from Clipboard (fast, for long text)
```bash
# Set clipboard and paste — much faster than keystroke for long messages
osascript -e 'set the clipboard to "Your long message here"'
osascript -e 'tell application "System Events" to keystroke "v" using command down'
```
Or in one shot:
```bash
osascript -e '
set the clipboard to "Your long message here"
tell application "System Events" to keystroke "v" using command down
'
```
### Keyboard Shortcuts
```bash
# Cmd+K (quick switcher in Discord/Slack)
osascript -e 'tell application "System Events" to keystroke "k" using command down'
# Cmd+F (search)
osascript -e 'tell application "System Events" to keystroke "f" using command down'
# Cmd+N (new message/chat)
osascript -e 'tell application "System Events" to keystroke "n" using command down'
# Cmd+Shift+K (example: multi-modifier)
osascript -e 'tell application "System Events" to keystroke "k" using {command down, shift down}'
```
### Click at Position
```bash
# Click at absolute screen coordinates
osascript -e '
tell application "System Events"
click at {500, 300}
end tell
'
```
### Get Window Info
```bash
# Get window position and size
osascript -e '
tell application "System Events"
tell process "Discord"
get {position, size} of window 1
end tell
end tell
'
```
### Screenshot
```bash
# Full screen
screencapture /tmp/screenshot.png
# Interactive region select
screencapture -i /tmp/screenshot.png
# Specific window (by window ID from CGWindowList)
screencapture -l < WINDOW_ID > /tmp/screenshot.png
```
To get window ID for a specific app:
```bash
osascript -e '
tell application "System Events"
tell process "Discord"
get id of window 1
end tell
end tell
'
```
### Read Accessibility Elements
```bash
# Get all UI elements of the frontmost window (can be slow/large)
osascript -e '
tell application "System Events"
tell process "Discord"
entire contents of window 1
end tell
end tell
'
# Get a specific element's value
osascript -e '
tell application "System Events"
tell process "Discord"
get value of text field 1 of window 1
end tell
end tell
'
```
> **Warning:** `entire contents` can be extremely slow on complex UIs. Prefer screenshots + `Read` tool for visual verification.
### Read Screen Text via Clipboard
For reading the latest message or response from an app:
```bash
# Select all text in the focused area and copy
osascript -e '
tell application "System Events"
keystroke "a" using command down
keystroke "c" using command down
end tell
'
sleep 0.5
# Read clipboard
pbpaste
```
---
## Client: Discord
**App name:** `Discord` | **Process name:** `Discord`
### Activate & Navigate
```bash
# Activate Discord
osascript -e 'tell application "Discord" to activate'
sleep 1
# Open Quick Switcher (Cmd+K) to navigate to a channel
osascript -e 'tell application "System Events" to keystroke "k" using command down'
sleep 0.5
osascript -e 'tell application "System Events" to keystroke "bot-testing"'
sleep 1
osascript -e 'tell application "System Events" to key code 36' # Enter
sleep 2
```
### Send Message to Bot
```bash
# The message input is focused after navigating to a channel
# Type a message
osascript -e 'tell application "System Events" to keystroke "/hello"'
sleep 0.5
osascript -e 'tell application "System Events" to key code 36' # Enter
```
### Send Long Message (via clipboard)
```bash
osascript -e '
tell application "Discord" to activate
delay 0.5
set the clipboard to "Write a 3000 word essay about space exploration"
tell application "System Events"
keystroke "v" using command down
delay 0.3
key code 36 -- Enter
end tell
'
```
### Verify Bot Response
```bash
# Wait for bot to respond, then screenshot
sleep 10
screencapture /tmp/discord-bot-response.png
# Read with the Read tool for visual verification
```
### Full Bot Test Example
```bash
#!/usr/bin/env bash
# test-discord-bot.sh — Send message and verify bot response
# 1. Activate Discord and navigate to channel
osascript -e '
tell application "Discord" to activate
delay 1
-- Quick Switcher
tell application "System Events" to keystroke "k" using command down
delay 0.5
tell application "System Events" to keystroke "bot-testing"
delay 1
tell application "System Events" to key code 36
delay 2
'
# 2. Send test message
osascript -e '
set the clipboard to "!ping"
tell application "System Events"
keystroke "v" using command down
delay 0.3
key code 36
end tell
'
# 3. Wait for response and capture
sleep 5
screencapture /tmp/discord-test-result.png
echo "Screenshot saved to /tmp/discord-test-result.png"
```
---
## Client: Slack
**App name:** `Slack` | **Process name:** `Slack`
### Activate & Navigate
```bash
# Activate Slack
osascript -e 'tell application "Slack" to activate'
sleep 1
# Quick Switcher (Cmd+K)
osascript -e 'tell application "System Events" to keystroke "k" using command down'
sleep 0.5
osascript -e 'tell application "System Events" to keystroke "bot-testing"'
sleep 1
osascript -e 'tell application "System Events" to key code 36' # Enter
sleep 2
```
### Send Message to Bot
```bash
# Direct message input (focused after channel nav)
osascript -e 'tell application "System Events" to keystroke "@mybot hello"'
sleep 0.3
osascript -e 'tell application "System Events" to key code 36'
```
### Send Long Message
```bash
osascript -e '
tell application "Slack" to activate
delay 0.5
set the clipboard to "A long test message for the bot..."
tell application "System Events"
keystroke "v" using command down
delay 0.3
key code 36
end tell
'
```
### Slash Command Test
```bash
osascript -e '
tell application "Slack" to activate
delay 0.5
tell application "System Events"
keystroke "/ask What is the meaning of life?"
delay 0.5
key code 36
end tell
'
```
### Verify Response
```bash
sleep 10
screencapture /tmp/slack-bot-response.png
```
---
## Client: Telegram
**App name:** `Telegram` | **Process name:** `Telegram`
### Activate & Navigate
```bash
# Activate Telegram
osascript -e 'tell application "Telegram" to activate'
sleep 1
# Search for a bot (Cmd+F or click search)
osascript -e '
tell application "System Events"
keystroke "f" using command down
delay 0.5
keystroke "MyTestBot"
delay 1
key code 36 -- Enter to select
end tell
'
sleep 2
```
### Send Message to Bot
```bash
# After navigating to bot chat, input is focused
osascript -e '
tell application "System Events"
keystroke "/start"
delay 0.3
key code 36
end tell
'
```
### Send Long Message
```bash
osascript -e '
tell application "Telegram" to activate
delay 0.5
set the clipboard to "Tell me about quantum computing in detail"
tell application "System Events"
keystroke "v" using command down
delay 0.3
key code 36
end tell
'
```
### Verify Response
```bash
sleep 10
screencapture /tmp/telegram-bot-response.png
```
### Telegram Bot API (programmatic alternative)
For sending messages directly to the bot's chat without UI:
```bash
# Send message as the bot (for testing webhooks/responses)
curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage" \
-d "chat_id=$CHAT_ID&text=test message"
# Get recent updates
curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getUpdates?limit=5" | jq .
```
---
## Client: WeChat / 微信
**App name:** `微信` or `WeChat` | **Process name:** `WeChat`
### Activate & Navigate
```bash
# Activate WeChat
osascript -e 'tell application "微信" to activate'
sleep 1
# Search for a contact/bot (Cmd+F)
osascript -e '
tell application "System Events"
keystroke "f" using command down
delay 0.5
keystroke "TestBot"
delay 1
key code 36 -- Enter to select
end tell
'
sleep 2
```
### Send Message
```bash
# After navigating to a chat, the input is focused
osascript -e '
tell application "System Events"
keystroke "Hello bot!"
delay 0.3
key code 36
end tell
'
```
### Send Long Message (clipboard)
```bash
osascript -e '
tell application "微信" to activate
delay 0.5
set the clipboard to "Please help me with this task..."
tell application "System Events"
keystroke "v" using command down
delay 0.3
key code 36
end tell
'
```
### Verify Response
```bash
sleep 10
screencapture /tmp/wechat-bot-response.png
```
### WeChat-Specific Notes
- WeChat macOS app name can be `微信` or `WeChat` depending on system language. Try both:
```bash
osascript -e 'tell application "微信" to activate' 2> /dev/null \
|| osascript -e 'tell application "WeChat" to activate'
```
- WeChat uses **Enter** to send (not Cmd+Enter by default, but configurable)
- For multi-line messages without sending, use **Shift+Enter**:
```bash
osascript -e 'tell application "System Events" to key code 36 using shift down'
```
---
## Client: Lark / 飞书
**App name:** `Lark` or `飞书` | **Process name:** `Lark` or `飞书`
### Activate & Navigate
```bash
# Activate Lark (auto-detects Lark or 飞书)
osascript -e 'tell application "Lark" to activate' 2> /dev/null \
|| osascript -e 'tell application "飞书" to activate'
sleep 1
# Quick Switcher / Search (Cmd+K)
osascript -e 'tell application "System Events" to keystroke "k" using command down'
sleep 0.5
osascript -e '
set the clipboard to "bot-testing"
tell application "System Events"
keystroke "v" using command down
delay 1.5
key code 36 -- Enter
end tell
'
sleep 2
```
### Send Message to Bot
```bash
osascript -e '
set the clipboard to "@MyBot help me with this task"
tell application "System Events"
keystroke "v" using command down
delay 0.3
key code 36 -- Enter
end tell
'
```
### Verify Response
```bash
sleep 10
screencapture /tmp/lark-bot-response.png
```
### Lark-Specific Notes
- App name varies: `Lark` (international) vs `飞书` (China mainland) — the script auto-detects
- Uses `Cmd+K` for quick search (same as Discord/Slack)
- Enter sends message by default
---
## Client: QQ
**App name:** `QQ` | **Process name:** `QQ`
### Activate & Navigate
```bash
osascript -e 'tell application "QQ" to activate'
sleep 1
# Search for contact/group (Cmd+F)
osascript -e '
tell application "System Events"
keystroke "f" using command down
delay 0.8
end tell
'
osascript -e '
set the clipboard to "bot-testing"
tell application "System Events"
keystroke "v" using command down
delay 1.5
key code 36 -- Enter
end tell
'
sleep 2
```
### Send Message to Bot
```bash
osascript -e '
set the clipboard to "Hello bot!"
tell application "System Events"
keystroke "v" using command down
delay 0.3
key code 36 -- Enter
end tell
'
```
### Verify Response
```bash
sleep 10
screencapture /tmp/qq-bot-response.png
```
### QQ-Specific Notes
- Enter sends message by default; Shift+Enter for newlines
- Uses `Cmd+F` for search
- Always use clipboard paste for CJK characters
---
## Common Bot Testing Workflow (osascript)
Regardless of platform, the pattern is:
```bash
APP_NAME="Discord" # or "Slack", "Telegram", "微信"
CHANNEL="bot-testing"
MESSAGE="Hello bot!"
WAIT_SECONDS=10
# 1. Activate
osascript -e "tell application \"$APP_NAME\" to activate"
sleep 1
# 2. Navigate to channel/chat (via Quick Switcher or Search)
osascript -e 'tell application "System Events" to keystroke "k" using command down'
sleep 0.5
osascript -e "tell application \"System Events\" to keystroke \"$CHANNEL\""
sleep 1
osascript -e 'tell application "System Events" to key code 36'
sleep 2
# 3. Send message
osascript -e "set the clipboard to \"$MESSAGE\""
osascript -e '
tell application "System Events"
keystroke "v" using command down
delay 0.3
key code 36
end tell
'
# 4. Wait for bot response
sleep "$WAIT_SECONDS"
# 5. Screenshot for verification
screencapture /tmp/"${APP_NAME,,}"-bot-test.png
echo "Result saved to /tmp/${APP_NAME,,}-bot-test.png"
```
### Tips
- **Use clipboard paste** (`Cmd+V`) for messages containing special characters or long text — `keystroke` can mangle non-ASCII
- **Add `delay`** between actions — apps need time to process UI events
- **Screenshot for verification** — use `screencapture` + `Read` tool for visual checks
- **Use a dedicated test channel/chat** — avoid polluting real conversations
- **Check app name** — some apps have different names in different locales (e.g., `微信` vs `WeChat`)
- **Accessibility permissions required** — System Events automation requires granting Accessibility access in System Preferences > Privacy & Security > Accessibility
---
@@ -419,7 +1006,6 @@ Ready-to-use scripts in `.agents/skills/local-testing/scripts/`:
| `electron-dev.sh` | Manage Electron dev env (start/stop/status/restart) |
| `capture-app-window.sh` | Capture screenshot of a specific app window |
| `record-electron-demo.sh` | Record Electron app demo with ffmpeg |
| `record-app-screen.sh` | Record app screen (video + screenshots, start/stop) |
| `test-discord-bot.sh` | Send message to Discord bot via osascript |
| `test-slack-bot.sh` | Send message to Slack bot via osascript |
| `test-telegram-bot.sh` | Send message to Telegram bot via osascript |
@@ -482,16 +1068,25 @@ Each script: activates the app, navigates to the channel/contact, pastes the mes
# Screen Recording
Record automated demos using `record-app-screen.sh` (start/stop lifecycle, CDP screenshots + ffmpeg assembly). See [references/record-app-screen.md](references/record-app-screen.md) for full documentation.
Record automated demos by combining `ffmpeg` screen capture with `agent-browser` automation. The script `.agents/skills/local-testing/scripts/record-electron-demo.sh` handles the full lifecycle for Electron.
### Usage
```bash
./.agents/skills/local-testing/scripts/electron-dev.sh start
./.agents/skills/local-testing/scripts/record-app-screen.sh start my-demo
# ... run automation ...
./.agents/skills/local-testing/scripts/record-app-screen.sh stop
# Run the built-in demo (queue-edit feature)
./.agents/skills/local-testing/scripts/record-electron-demo.sh
# Run a custom automation script
./.agents/skills/local-testing/scripts/record-electron-demo.sh ./my-demo.sh /tmp/my-demo.mp4
```
Outputs to `.records/` directory (gitignored): `<name>.mp4` (video) + `<name>/` (screenshots every 3s).
The script automatically:
1. Starts Electron with CDP and waits for SPA to load
2. Detects window position, screen, and Retina scale via Swift/CGWindowList
3. Records only the Electron window region using `ffmpeg -f avfoundation` with crop
4. Runs the demo (built-in or custom script receiving CDP port as `$1`)
5. Stops recording and cleans up
---
@@ -517,4 +1112,14 @@ Outputs to `.records/` directory (gitignored): `<name>.mp4` (video) + `<name>/`
### osascript
See [references/osascript-common.md](./references/osascript-common.md#gotchas) for the full osascript gotchas list (accessibility permissions, `keystroke` non-ASCII issues, locale-specific app names, rate limiting, etc.).
- **Accessibility permission required** — first run will prompt for access; grant it in System Preferences > Privacy & Security > Accessibility for Terminal / iTerm / Claude Code
- **`keystroke` is slow for long text** — always use clipboard paste (`Cmd+V`) for messages over \~20 characters
- **`keystroke` can mangle non-ASCII** — use clipboard paste for Chinese, emoji, or special characters
- **`key code 36` is Enter** — this is the hardware key code, works regardless of keyboard layout
- **`entire contents` is extremely slow** — avoid for complex UIs; use screenshots instead
- **App name varies by locale** — `微信` vs `WeChat`, `企业微信` vs `WeCom`; handle both
- **WeChat Enter sends immediately** — use `Shift+Enter` for newlines within a message
- **Rate limiting** — don't send messages too fast; platforms may throttle or flag automated input
- **Lark / 飞书 app name varies** — `Lark` (international) vs `飞书` (China mainland); scripts auto-detect
- **QQ uses `Cmd+F` for search** — not `Cmd+K` like Discord/Slack/Lark
- **Bot response times vary** — AI-powered bots may take 10-60s; use generous sleep values
@@ -1,110 +0,0 @@
# 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).
@@ -1,97 +0,0 @@
# Discord Bot Testing
**App name:** `Discord` | **Process name:** `Discord`
See [osascript-common.md](./osascript-common.md) for shared patterns.
## Activate & Navigate
```bash
# Activate Discord
osascript -e 'tell application "Discord" to activate'
sleep 1
# Open Quick Switcher (Cmd+K) to navigate to a channel
osascript -e 'tell application "System Events" to keystroke "k" using command down'
sleep 0.5
osascript -e 'tell application "System Events" to keystroke "bot-testing"'
sleep 1
osascript -e 'tell application "System Events" to key code 36' # Enter
sleep 2
```
## Send Message to Bot
```bash
# The message input is focused after navigating to a channel
# Type a message
osascript -e 'tell application "System Events" to keystroke "/hello"'
sleep 0.5
osascript -e 'tell application "System Events" to key code 36' # Enter
```
## Send Long Message (via clipboard)
```bash
osascript -e '
tell application "Discord" to activate
delay 0.5
set the clipboard to "Write a 3000 word essay about space exploration"
tell application "System Events"
keystroke "v" using command down
delay 0.3
key code 36 -- Enter
end tell
'
```
## Verify Bot Response
```bash
# Wait for bot to respond, then screenshot
sleep 10
screencapture /tmp/discord-bot-response.png
# Read with the Read tool for visual verification
```
## Full Bot Test Example
```bash
#!/usr/bin/env bash
# test-discord-bot.sh — Send message and verify bot response
# 1. Activate Discord and navigate to channel
osascript -e '
tell application "Discord" to activate
delay 1
-- Quick Switcher
tell application "System Events" to keystroke "k" using command down
delay 0.5
tell application "System Events" to keystroke "bot-testing"
delay 1
tell application "System Events" to key code 36
delay 2
'
# 2. Send test message
osascript -e '
set the clipboard to "!ping"
tell application "System Events"
keystroke "v" using command down
delay 0.3
key code 36
end tell
'
# 3. Wait for response and capture
sleep 5
screencapture /tmp/discord-test-result.png
echo "Screenshot saved to /tmp/discord-test-result.png"
```
## Script
```bash
./.agents/skills/local-testing/scripts/test-discord-bot.sh "bot-testing" "!ping"
./.agents/skills/local-testing/scripts/test-discord-bot.sh "bot-testing" "/ask Tell me a joke" 30
```
@@ -1,61 +0,0 @@
# Lark / 飞书 Bot Testing
**App name:** `Lark` or `飞书` | **Process name:** `Lark` or `飞书`
See [osascript-common.md](./osascript-common.md) for shared patterns.
## Activate & Navigate
```bash
# Activate Lark (auto-detects Lark or 飞书)
osascript -e 'tell application "Lark" to activate' 2> /dev/null \
|| osascript -e 'tell application "飞书" to activate'
sleep 1
# Quick Switcher / Search (Cmd+K)
osascript -e 'tell application "System Events" to keystroke "k" using command down'
sleep 0.5
osascript -e '
set the clipboard to "bot-testing"
tell application "System Events"
keystroke "v" using command down
delay 1.5
key code 36 -- Enter
end tell
'
sleep 2
```
## Send Message to Bot
```bash
osascript -e '
set the clipboard to "@MyBot help me with this task"
tell application "System Events"
keystroke "v" using command down
delay 0.3
key code 36 -- Enter
end tell
'
```
## Verify Response
```bash
sleep 10
screencapture /tmp/lark-bot-response.png
```
## Lark-Specific Notes
- App name varies: `Lark` (international) vs `飞书` (China mainland) — the script auto-detects
- Uses `Cmd+K` for quick search (same as Discord/Slack)
- Enter sends message by default
- Always use clipboard paste for CJK characters
## Script
```bash
./.agents/skills/local-testing/scripts/test-lark-bot.sh "bot-testing" "@MyBot hello"
./.agents/skills/local-testing/scripts/test-lark-bot.sh "bot-testing" "Help me with this" 30
```
@@ -1,217 +0,0 @@
# osascript Common Patterns
Shared AppleScript / `osascript` patterns used by all platform bot tests. Read this first, then refer to the per-platform file for app-specific quirks.
## Core Patterns
### Activate an App
```bash
osascript -e 'tell application "Discord" to activate'
```
### Type Text
```bash
# Type character by character (reliable, but slow for long text)
osascript -e 'tell application "System Events" to keystroke "Hello world"'
# Press Enter
osascript -e 'tell application "System Events" to key code 36'
# Press Tab
osascript -e 'tell application "System Events" to key code 48'
# Press Escape
osascript -e 'tell application "System Events" to key code 53'
```
### Paste from Clipboard (fast, for long text)
```bash
# Set clipboard and paste — much faster than keystroke for long messages
osascript -e 'set the clipboard to "Your long message here"'
osascript -e 'tell application "System Events" to keystroke "v" using command down'
```
Or in one shot:
```bash
osascript -e '
set the clipboard to "Your long message here"
tell application "System Events" to keystroke "v" using command down
'
```
### Keyboard Shortcuts
```bash
# Cmd+K (quick switcher in Discord/Slack)
osascript -e 'tell application "System Events" to keystroke "k" using command down'
# Cmd+F (search)
osascript -e 'tell application "System Events" to keystroke "f" using command down'
# Cmd+N (new message/chat)
osascript -e 'tell application "System Events" to keystroke "n" using command down'
# Cmd+Shift+K (example: multi-modifier)
osascript -e 'tell application "System Events" to keystroke "k" using {command down, shift down}'
```
### Click at Position
```bash
# Click at absolute screen coordinates
osascript -e '
tell application "System Events"
click at {500, 300}
end tell
'
```
### Get Window Info
```bash
# Get window position and size
osascript -e '
tell application "System Events"
tell process "Discord"
get {position, size} of window 1
end tell
end tell
'
```
### Screenshot
```bash
# Full screen
screencapture /tmp/screenshot.png
# Interactive region select
screencapture -i /tmp/screenshot.png
# Specific window (by window ID from CGWindowList)
screencapture -l < WINDOW_ID > /tmp/screenshot.png
```
To get window ID for a specific app:
```bash
osascript -e '
tell application "System Events"
tell process "Discord"
get id of window 1
end tell
end tell
'
```
### Read Accessibility Elements
```bash
# Get all UI elements of the frontmost window (can be slow/large)
osascript -e '
tell application "System Events"
tell process "Discord"
entire contents of window 1
end tell
end tell
'
# Get a specific element's value
osascript -e '
tell application "System Events"
tell process "Discord"
get value of text field 1 of window 1
end tell
end tell
'
```
> **Warning:** `entire contents` can be extremely slow on complex UIs. Prefer screenshots + `Read` tool for visual verification.
### Read Screen Text via Clipboard
For reading the latest message or response from an app:
```bash
# Select all text in the focused area and copy
osascript -e '
tell application "System Events"
keystroke "a" using command down
keystroke "c" using command down
end tell
'
sleep 0.5
# Read clipboard
pbpaste
```
---
## Common Bot Testing Workflow
Regardless of platform, the pattern is:
```bash
APP_NAME="Discord" # or "Slack", "Telegram", "微信"
CHANNEL="bot-testing"
MESSAGE="Hello bot!"
WAIT_SECONDS=10
# 1. Activate
osascript -e "tell application \"$APP_NAME\" to activate"
sleep 1
# 2. Navigate to channel/chat (via Quick Switcher or Search)
osascript -e 'tell application "System Events" to keystroke "k" using command down'
sleep 0.5
osascript -e "tell application \"System Events\" to keystroke \"$CHANNEL\""
sleep 1
osascript -e 'tell application "System Events" to key code 36'
sleep 2
# 3. Send message
osascript -e "set the clipboard to \"$MESSAGE\""
osascript -e '
tell application "System Events"
keystroke "v" using command down
delay 0.3
key code 36
end tell
'
# 4. Wait for bot response
sleep "$WAIT_SECONDS"
# 5. Screenshot for verification
screencapture /tmp/"${APP_NAME,,}"-bot-test.png
echo "Result saved to /tmp/${APP_NAME,,}-bot-test.png"
```
### Tips
- **Use clipboard paste** (`Cmd+V`) for messages containing special characters or long text — `keystroke` can mangle non-ASCII
- **Add `delay`** between actions — apps need time to process UI events
- **Screenshot for verification** — use `screencapture` + `Read` tool for visual checks
- **Use a dedicated test channel/chat** — avoid polluting real conversations
- **Check app name** — some apps have different names in different locales (e.g., `微信` vs `WeChat`)
- **Accessibility permissions required** — System Events automation requires granting Accessibility access in System Preferences > Privacy & Security > Accessibility
---
## Gotchas
- **Accessibility permission required** — first run will prompt for access; grant it in System Preferences > Privacy & Security > Accessibility for Terminal / iTerm / Claude Code
- **`keystroke` is slow for long text** — always use clipboard paste (`Cmd+V`) for messages over \~20 characters
- **`keystroke` can mangle non-ASCII** — use clipboard paste for Chinese, emoji, or special characters
- **`key code 36` is Enter** — this is the hardware key code, works regardless of keyboard layout
- **`entire contents` is extremely slow** — avoid for complex UIs; use screenshots instead
- **App name varies by locale** — `微信` vs `WeChat`, `企业微信` vs `WeCom`; handle both
- **WeChat Enter sends immediately** — use `Shift+Enter` for newlines within a message
- **Rate limiting** — don't send messages too fast; platforms may throttle or flag automated input
- **Lark / 飞书 app name varies** — `Lark` (international) vs `飞书` (China mainland); scripts auto-detect
- **QQ uses `Cmd+F` for search** — not `Cmd+K` like Discord/Slack/Lark
- **Bot response times vary** — AI-powered bots may take 10-60s; use generous sleep values
@@ -1,62 +0,0 @@
# QQ Bot Testing
**App name:** `QQ` | **Process name:** `QQ`
See [osascript-common.md](./osascript-common.md) for shared patterns.
## Activate & Navigate
```bash
osascript -e 'tell application "QQ" to activate'
sleep 1
# Search for contact/group (Cmd+F)
osascript -e '
tell application "System Events"
keystroke "f" using command down
delay 0.8
end tell
'
osascript -e '
set the clipboard to "bot-testing"
tell application "System Events"
keystroke "v" using command down
delay 1.5
key code 36 -- Enter
end tell
'
sleep 2
```
## Send Message to Bot
```bash
osascript -e '
set the clipboard to "Hello bot!"
tell application "System Events"
keystroke "v" using command down
delay 0.3
key code 36 -- Enter
end tell
'
```
## Verify Response
```bash
sleep 10
screencapture /tmp/qq-bot-response.png
```
## QQ-Specific Notes
- Enter sends message by default; Shift+Enter for newlines
- Uses `Cmd+F` for search (not `Cmd+K` like Discord/Slack/Lark)
- Always use clipboard paste for CJK characters
## Script
```bash
./.agents/skills/local-testing/scripts/test-qq-bot.sh "bot-testing" "Hello bot" 15
./.agents/skills/local-testing/scripts/test-qq-bot.sh "MyBot" "/help" 10
```
@@ -1,142 +0,0 @@
# record-app-screen.sh
General-purpose screen recording tool for the Electron app. Captures CDP screenshots as video frames and gallery snapshots, then assembles into an MP4 on stop.
## Why CDP Screenshots Instead of ffmpeg Screen Capture
- **Works on any screen** — CDP screenshots capture the browser viewport directly, so external monitors, Retina scaling, and window positioning are all handled automatically
- **No signal handling issues** — ffmpeg-static (npm) produces corrupt MP4 files when killed (missing moov atom). CDP screenshots avoid this entirely
- **Consistent output** — Screenshots are resolution-independent and don't require crop coordinate calculations
## Commands
```bash
# Start recording (Electron must be running with CDP)
.agents/skills/local-testing/scripts/record-app-screen.sh start [output_name]
# Stop recording and assemble video
.agents/skills/local-testing/scripts/record-app-screen.sh stop
# Check if recording is active
.agents/skills/local-testing/scripts/record-app-screen.sh status
```
### Arguments
| Argument | Default | Description |
| ------------- | --------------------------- | -------------------------- |
| `output_name` | `recording-YYYYMMDD-HHMMSS` | Base name for output files |
### Environment Variables
| Variable | Default | Description |
| ---------------------- | ------- | -------------------------------------- |
| `CDP_PORT` | `9222` | Chrome DevTools Protocol port |
| `SCREENSHOT_INTERVAL` | `3` | Seconds between gallery screenshots |
| `VIDEO_FRAME_INTERVAL` | `0.5` | Seconds between video frames (\~2 fps) |
## Output Structure
```
.records/
<name>.mp4 # Video assembled from frames (~2 fps)
<name>/ # Gallery screenshots (every 3s)
0000.png
0001.png
0002.png
...
```
The `.records/` directory is at the project root and is gitignored.
## How It Works
### Start
1. Creates two background loops:
- **Video frames** — `agent-browser screenshot` every `VIDEO_FRAME_INTERVAL` seconds into a temp directory (`/tmp/record-frames-XXXXXX/`)
- **Gallery screenshots** — `agent-browser screenshot` every `SCREENSHOT_INTERVAL` seconds into `.records/<name>/`
2. Saves PIDs and paths to `/tmp/record-app-screen.pids` and `/tmp/record-app-screen.state`
### Stop
1. Kills both background loops
2. Assembles video frames into MP4 using ffmpeg:
```
ffmpeg -framerate 2 -i frame_%06d.png -c:v libx264 -crf 23 -pix_fmt yuv420p <output>.mp4
```
3. Cleans up temp frame directory
4. Reports file sizes and paths
## Usage Examples
### Basic Test Recording
```bash
# Start Electron
.agents/skills/local-testing/scripts/electron-dev.sh start
# Start recording
.agents/skills/local-testing/scripts/record-app-screen.sh start my-test
# Run automation
agent-browser --cdp 9222 click @e61
agent-browser --cdp 9222 type @e42 "hello"
agent-browser --cdp 9222 press Enter
sleep 10
# Stop and get results
.agents/skills/local-testing/scripts/record-app-screen.sh stop
# → .records/my-test.mp4 + .records/my-test/*.png
```
### Gateway Streaming Demo
```bash
.agents/skills/local-testing/scripts/electron-dev.sh start
# Inject gateway URL
agent-browser --cdp 9222 eval --stdin << 'EOF'
(function() {
var store = window.global_serverConfigStore;
store.setState({ serverConfig: { ...store.getState().serverConfig,
agentGatewayUrl: 'https://agent-gateway.lobehub.com' } });
return 'ready';
})()
EOF
# Record
.agents/skills/local-testing/scripts/record-app-screen.sh start gateway-demo
# Navigate to agent, send message, wait for completion...
# (automation commands here)
.agents/skills/local-testing/scripts/record-app-screen.sh stop
open .records/gateway-demo.mp4
```
### Check Active Recording
```bash
.agents/skills/local-testing/scripts/record-app-screen.sh status
# [record] Active recording
# Frames: 42 captured (running: yes)
# Screenshots: 14 captured (running: yes)
# Output: .records/my-test.mp4
```
## Prerequisites
- **ffmpeg** — For video assembly. Install via `bun add -g ffmpeg-static` or `brew install ffmpeg`
- **agent-browser** — For CDP screenshots. Install via `npm i -g agent-browser`
- **Electron app running** — With CDP enabled (use `electron-dev.sh start`)
## Troubleshooting
| Problem | Solution |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| "No active recording found" on stop | PID file was cleaned up. Check if background processes are still running with `ps aux \| grep agent-browser` |
| "A recording is already active" | Run `stop` first, or manually clean: `rm /tmp/record-app-screen.pids /tmp/record-app-screen.state` |
| Video is 0 bytes | No frames were captured. Ensure Electron is running and CDP port is correct |
| Screenshots are blank/white | SPA may not have loaded yet. Wait for `electron-dev.sh` to report "Renderer ready" |
| ffmpeg assembly fails | Check `/tmp/ffmpeg-assemble.log`. Ensure ffmpeg is installed and frames exist |
@@ -1,73 +0,0 @@
# Slack Bot Testing
**App name:** `Slack` | **Process name:** `Slack`
See [osascript-common.md](./osascript-common.md) for shared patterns.
## Activate & Navigate
```bash
# Activate Slack
osascript -e 'tell application "Slack" to activate'
sleep 1
# Quick Switcher (Cmd+K)
osascript -e 'tell application "System Events" to keystroke "k" using command down'
sleep 0.5
osascript -e 'tell application "System Events" to keystroke "bot-testing"'
sleep 1
osascript -e 'tell application "System Events" to key code 36' # Enter
sleep 2
```
## Send Message to Bot
```bash
# Direct message input (focused after channel nav)
osascript -e 'tell application "System Events" to keystroke "@mybot hello"'
sleep 0.3
osascript -e 'tell application "System Events" to key code 36'
```
## Send Long Message
```bash
osascript -e '
tell application "Slack" to activate
delay 0.5
set the clipboard to "A long test message for the bot..."
tell application "System Events"
keystroke "v" using command down
delay 0.3
key code 36
end tell
'
```
## Slash Command Test
```bash
osascript -e '
tell application "Slack" to activate
delay 0.5
tell application "System Events"
keystroke "/ask What is the meaning of life?"
delay 0.5
key code 36
end tell
'
```
## Verify Response
```bash
sleep 10
screencapture /tmp/slack-bot-response.png
```
## Script
```bash
./.agents/skills/local-testing/scripts/test-slack-bot.sh "bot-testing" "@mybot hello"
./.agents/skills/local-testing/scripts/test-slack-bot.sh "bot-testing" "/ask What is 2+2?" 20
```
@@ -1,80 +0,0 @@
# Telegram Bot Testing
**App name:** `Telegram` | **Process name:** `Telegram`
See [osascript-common.md](./osascript-common.md) for shared patterns.
## Activate & Navigate
```bash
# Activate Telegram
osascript -e 'tell application "Telegram" to activate'
sleep 1
# Search for a bot (Cmd+F or click search)
osascript -e '
tell application "System Events"
keystroke "f" using command down
delay 0.5
keystroke "MyTestBot"
delay 1
key code 36 -- Enter to select
end tell
'
sleep 2
```
## Send Message to Bot
```bash
# After navigating to bot chat, input is focused
osascript -e '
tell application "System Events"
keystroke "/start"
delay 0.3
key code 36
end tell
'
```
## Send Long Message
```bash
osascript -e '
tell application "Telegram" to activate
delay 0.5
set the clipboard to "Tell me about quantum computing in detail"
tell application "System Events"
keystroke "v" using command down
delay 0.3
key code 36
end tell
'
```
## Verify Response
```bash
sleep 10
screencapture /tmp/telegram-bot-response.png
```
## Telegram Bot API (programmatic alternative)
For sending messages directly to the bot's chat without UI:
```bash
# Send message as the bot (for testing webhooks/responses)
curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage" \
-d "chat_id=$CHAT_ID&text=test message"
# Get recent updates
curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getUpdates?limit=5" | jq .
```
## Script
```bash
./.agents/skills/local-testing/scripts/test-telegram-bot.sh "MyTestBot" "/start"
./.agents/skills/local-testing/scripts/test-telegram-bot.sh "GPTBot" "Hello" 60
```
@@ -1,81 +0,0 @@
# WeChat / 微信 Bot Testing
**App name:** `微信` or `WeChat` | **Process name:** `WeChat`
See [osascript-common.md](./osascript-common.md) for shared patterns.
## Activate & Navigate
```bash
# Activate WeChat
osascript -e 'tell application "微信" to activate'
sleep 1
# Search for a contact/bot (Cmd+F)
osascript -e '
tell application "System Events"
keystroke "f" using command down
delay 0.5
keystroke "TestBot"
delay 1
key code 36 -- Enter to select
end tell
'
sleep 2
```
## Send Message
```bash
# After navigating to a chat, the input is focused
osascript -e '
tell application "System Events"
keystroke "Hello bot!"
delay 0.3
key code 36
end tell
'
```
## Send Long Message (clipboard)
```bash
osascript -e '
tell application "微信" to activate
delay 0.5
set the clipboard to "Please help me with this task..."
tell application "System Events"
keystroke "v" using command down
delay 0.3
key code 36
end tell
'
```
## Verify Response
```bash
sleep 10
screencapture /tmp/wechat-bot-response.png
```
## WeChat-Specific Notes
- WeChat macOS app name can be `微信` or `WeChat` depending on system language. Try both:
```bash
osascript -e 'tell application "微信" to activate' 2> /dev/null \
|| osascript -e 'tell application "WeChat" to activate'
```
- WeChat uses **Enter** to send (not Cmd+Enter by default, but configurable)
- For multi-line messages without sending, use **Shift+Enter**:
```bash
osascript -e 'tell application "System Events" to key code 36 using shift down'
```
- Always use clipboard paste for CJK characters — `keystroke` mangles non-ASCII
## Script
```bash
./.agents/skills/local-testing/scripts/test-wechat-bot.sh "文件传输助手" "test message" 5
./.agents/skills/local-testing/scripts/test-wechat-bot.sh "MyBot" "Tell me a joke" 30
```
@@ -1,189 +0,0 @@
#!/usr/bin/env bash
#
# record-app-screen.sh — Record the Electron app window (video + screenshots)
#
# Captures screenshots via agent-browser (CDP), then assembles into video on stop.
# Works on any screen (including external monitors) since it uses CDP, not screen capture.
#
# Usage:
# ./record-app-screen.sh start [output_name] # Begin recording
# ./record-app-screen.sh stop # Stop and save
# ./record-app-screen.sh status # Check recording state
#
# Outputs to .records/ directory:
# .records/<name>.mp4 — Video assembled from screenshots (~2 fps)
# .records/<name>/ — Screenshots every SCREENSHOT_INTERVAL seconds
#
# Prerequisites:
# - ffmpeg installed (bun add -g ffmpeg-static, or brew install ffmpeg)
# - agent-browser CLI installed
# - Electron app already running with CDP enabled
#
# Environment variables:
# CDP_PORT — Chrome DevTools Protocol port (default: 9222)
# SCREENSHOT_INTERVAL — Seconds between gallery screenshots (default: 3)
# VIDEO_FRAME_INTERVAL — Seconds between video frames (default: 0.5)
#
# Examples:
# ./electron-dev.sh start
# ./record-app-screen.sh start gateway-demo
# # ... run automation via agent-browser ...
# ./record-app-screen.sh stop
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
RECORDS_DIR="$PROJECT_DIR/.records"
PID_FILE="/tmp/record-app-screen.pids"
STATE_FILE="/tmp/record-app-screen.state"
CDP_PORT="${CDP_PORT:-9222}"
SCREENSHOT_INTERVAL="${SCREENSHOT_INTERVAL:-3}"
VIDEO_FRAME_INTERVAL="${VIDEO_FRAME_INTERVAL:-0.5}"
AB="agent-browser --cdp $CDP_PORT"
# ─── Commands ───
cmd_start() {
local output_name="${1:-recording-$(date +%Y%m%d-%H%M%S)}"
local output_video="$RECORDS_DIR/${output_name}.mp4"
local screenshot_dir="$RECORDS_DIR/${output_name}"
local frames_dir
frames_dir=$(mktemp -d /tmp/record-frames-XXXXXX)
if [ -f "$PID_FILE" ]; then
echo "[record] A recording is already active. Run '$0 stop' first."
exit 1
fi
mkdir -p "$RECORDS_DIR" "$screenshot_dir"
# Video frames loop (~2 fps via agent-browser CDP screenshots)
(
local idx=0
while true; do
local fname
fname=$(printf "%s/frame_%06d.png" "$frames_dir" "$idx")
$AB screenshot "$fname" 2>/dev/null || true
idx=$((idx + 1))
sleep "$VIDEO_FRAME_INTERVAL"
done
) &
local frames_pid=$!
# Gallery screenshots loop (every N seconds for human review)
(
local idx=0
while true; do
local fname
fname=$(printf "%s/%04d.png" "$screenshot_dir" "$idx")
$AB screenshot "$fname" 2>/dev/null || true
idx=$((idx + 1))
sleep "$SCREENSHOT_INTERVAL"
done
) &
local screenshot_pid=$!
# Save state
echo "$frames_pid $screenshot_pid" > "$PID_FILE"
echo "$output_video $frames_dir $screenshot_dir" > "$STATE_FILE"
echo "[record] Started!"
echo " Video frames: every ${VIDEO_FRAME_INTERVAL}s (PID $frames_pid)"
echo " Screenshots: every ${SCREENSHOT_INTERVAL}s → $screenshot_dir/"
echo " Stop with: $0 stop"
}
cmd_stop() {
if [ ! -f "$PID_FILE" ] || [ ! -f "$STATE_FILE" ]; then
echo "[record] No active recording found."
return 0
fi
local frames_pid screenshot_pid
read -r frames_pid screenshot_pid < "$PID_FILE"
local output_video frames_dir screenshot_dir
read -r output_video frames_dir screenshot_dir < "$STATE_FILE"
# Stop both capture loops
kill "$frames_pid" 2>/dev/null || true
kill "$screenshot_pid" 2>/dev/null || true
wait "$frames_pid" 2>/dev/null || true
wait "$screenshot_pid" 2>/dev/null || true
# Assemble frames into video
local frame_count
frame_count=$(ls -1 "$frames_dir"/frame_*.png 2>/dev/null | wc -l | tr -d ' ')
if [ "$frame_count" -gt 0 ]; then
echo "[record] Assembling $frame_count frames into video..."
ffmpeg -y -framerate 2 -i "$frames_dir/frame_%06d.png" \
-c:v libx264 -crf 23 -pix_fmt yuv420p -an \
"$output_video" > /tmp/ffmpeg-assemble.log 2>&1
if [ ! -s "$output_video" ]; then
echo " [warn] Video assembly failed. Check /tmp/ffmpeg-assemble.log"
echo " Frames preserved in: $frames_dir/"
fi
else
echo " [warn] No frames captured."
fi
rm -rf "$frames_dir" 2>/dev/null
rm -f "$PID_FILE" "$STATE_FILE"
local video_size screenshot_count
video_size=$(ls -lh "$output_video" 2>/dev/null | awk '{print $5}' || echo "?")
screenshot_count=$(ls -1 "$screenshot_dir"/*.png 2>/dev/null | wc -l | tr -d ' ' || echo "0")
echo "[record] Stopped!"
echo " Video: $output_video ($video_size)"
echo " Screenshots: ${screenshot_count} files in $screenshot_dir/"
echo " Play: open $output_video"
}
cmd_status() {
if [ ! -f "$PID_FILE" ]; then
echo "[record] No active recording."
return 0
fi
local frames_pid screenshot_pid
read -r frames_pid screenshot_pid < "$PID_FILE"
local frames_ok="no" screenshot_ok="no"
kill -0 "$frames_pid" 2>/dev/null && frames_ok="yes"
kill -0 "$screenshot_pid" 2>/dev/null && screenshot_ok="yes"
if [ -f "$STATE_FILE" ]; then
local output_video frames_dir screenshot_dir
read -r output_video frames_dir screenshot_dir < "$STATE_FILE"
local frame_count ss_count
frame_count=$(ls -1 "$frames_dir"/frame_*.png 2>/dev/null | wc -l | tr -d ' ' || echo "0")
ss_count=$(ls -1 "$screenshot_dir"/*.png 2>/dev/null | wc -l | tr -d ' ' || echo "0")
echo "[record] Active recording"
echo " Frames: $frame_count captured (running: $frames_ok)"
echo " Screenshots: $ss_count captured (running: $screenshot_ok)"
echo " Output: $output_video"
fi
}
# ─── Main ───
case "${1:-}" in
start) shift; cmd_start "$@" ;;
stop) cmd_stop ;;
status) cmd_status ;;
*)
echo "Usage: $0 {start [name] | stop | status}"
echo ""
echo " start [name] Start recording (default: recording-YYYYMMDD-HHMMSS)"
echo " stop Stop recording and save outputs"
echo " status Check if recording is active"
exit 1
;;
esac
+36 -73
View File
@@ -1,76 +1,64 @@
---
name: modal
description: MUST use when creating, editing, or writing modal dialogs or imperative modals. Prefer createModal / useModalContext / confirmModal from @lobehub/ui/base-ui; root @lobehub/ui is legacy (antd Modal). Covers patterns, ModalHost, and migration notes.
description: Modal imperative API guide. Use when creating modal dialogs using createModal from @lobehub/ui. Triggers on modal component implementation or dialog creation tasks.
user-invocable: false
---
# Modal Imperative API Guide
## Recommended: `@lobehub/ui/base-ui`
Use `createModal` from `@lobehub/ui` for imperative modal dialogs.
New code should use the **base-ui** modal stack (headless primitives, not antd `Modal`):
## Why Imperative?
- `createModal`, `confirmModal`, `ModalHost` from `@lobehub/ui/base-ui`
- `useModalContext` from `@lobehub/ui/base-ui` inside modal **content**
| Mode | Characteristics | Recommended |
| ----------- | ------------------------------------- | ----------- |
| Declarative | Need `open` state, render `<Modal />` | ❌ |
| Imperative | Call function directly, no state | ✅ |
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
## File Structure
```
features/
└── MyFeatureModal/
├── index.tsx # export createXxxModal
└── MyFeatureContent.tsx # modal body
├── index.tsx # Export createXxxModal
└── MyFeatureContent.tsx # Modal content
```
### 1. Content (`MyFeatureContent.tsx`)
## Implementation
### 1. Content Component (`MyFeatureContent.tsx`)
```tsx
'use client';
import { useModalContext } from '@lobehub/ui/base-ui';
import { useModalContext } from '@lobehub/ui';
import { useTranslation } from 'react-i18next';
export const MyFeatureContent = () => {
const { t } = useTranslation('namespace');
const { close } = useModalContext();
const { close } = useModalContext(); // Optional: get close method
return <div>{/* ... */}</div>;
return <div>{/* Modal content */}</div>;
};
```
### 2. `createModal` (`index.tsx`)
### 2. Export createModal (`index.tsx`)
```tsx
'use client';
import { createModal } from '@lobehub/ui/base-ui';
import { t } from 'i18next';
import { createModal } from '@lobehub/ui';
import { t } from 'i18next'; // Note: use i18next, not react-i18next
import { MyFeatureContent } from './MyFeatureContent';
export const createMyFeatureModal = () =>
createModal({
content: <MyFeatureContent />,
allowFullscreen: true,
children: <MyFeatureContent />,
destroyOnHidden: false,
footer: null,
maskClosable: true,
styles: {
content: { overflow: 'hidden', padding: 0 },
},
styles: { body: { overflow: 'hidden', padding: 0 } },
title: t('myFeature.title', { ns: 'setting' }),
width: 'min(80%, 800px)',
});
@@ -88,52 +76,27 @@ const handleOpen = useCallback(() => {
return <Button onClick={handleOpen}>Open</Button>;
```
### i18n
## i18n Handling
- **Content**: `useTranslation` in components.
- **`createModal` options**: `import { t } from 'i18next'` where hooks are unavailable.
- **Content component**: `useTranslation` hook (React context)
- **createModal params**: `import { t } from 'i18next'` (non-hook, imperative)
### `useModalContext`
## useModalContext Hook
```tsx
const { close, setCanDismissByClickOutside } = useModalContext();
```
### Common options (base-ui)
## Common Config
`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`.
---
| Property | Type | Description |
| ----------------- | ------------------- | ------------------------ |
| `allowFullscreen` | `boolean` | Allow fullscreen mode |
| `destroyOnHidden` | `boolean` | Destroy content on close |
| `footer` | `ReactNode \| null` | Footer content |
| `width` | `string \| number` | Modal width |
## Examples
- Base-ui (preferred): follow sections above; ensure **base-ui `ModalHost`** is mounted.
- Legacy: `src/features/SkillStore/index.tsx`, `src/features/LibraryModal/CreateNew/index.tsx`
- `src/features/SkillStore/index.tsx`
- `src/features/LibraryModal/CreateNew/index.tsx`
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: pr
description: "Create a PR for the current branch. Use when the user asks to create a pull request, submit PR, or says 'pr'."
user-invocable: true
user_invocable: true
---
# Create Pull Request
+1 -4
View File
@@ -6,9 +6,6 @@ description: React component development guide. Use when working with React comp
# React Component Writing Guide
- Use antd-style for complex styles; for simple cases, use inline `style` attribute
- **Prefer `createStaticStyles` with `cssVar.*`** (zero-runtime) — module-level, no hook call required
- Only fall back to `createStyles` + `token` when styles genuinely need runtime computation (dynamic props, JS color fns like `readableColor`/`chroma`)
- See `.cursor/docs/createStaticStyles_migration_guide.md` for full pattern
- Use `Flexbox` and `Center` from `@lobehub/ui` for layouts (see `references/layout-kit.md`)
- Component priority: `src/components` > `@lobehub/ui/base-ui` > `@lobehub/ui` > custom implementation
- Always prefer `@lobehub/ui/base-ui` primitives (Select, Modal, DropdownMenu, Popover, Switch, ScrollArea…) over antd equivalents
@@ -67,7 +64,7 @@ import { dynamicElement, redirectElement, ErrorBoundary } from '@/utils/router';
element: dynamicElement(() => import('./chat'), 'Desktop > Chat');
element: redirectElement('/settings/profile');
errorElement: <ErrorBoundary />;
errorElement: <ErrorBoundary resetPath="/chat" />;
```
### Navigation
+114
View File
@@ -0,0 +1,114 @@
---
name: recent-data
description: Guide for using Recent Data (topics, resources, pages). Use when working with recently accessed items, implementing recent lists, or accessing session store recent data. Triggers on recent data usage or implementation tasks.
user-invocable: false
---
# Recent Data Usage Guide
Recent data (recentTopics, recentResources, recentPages) is stored in session store.
## Initialization
In app top-level (e.g., `RecentHydration.tsx`):
```tsx
import { useInitRecentTopic } from '@/hooks/useInitRecentTopic';
import { useInitRecentResource } from '@/hooks/useInitRecentResource';
import { useInitRecentPage } from '@/hooks/useInitRecentPage';
const App = () => {
useInitRecentTopic();
useInitRecentResource();
useInitRecentPage();
return <YourComponents />;
};
```
## Usage
### Method 1: Read from Store (Recommended)
```tsx
import { useSessionStore } from '@/store/session';
import { recentSelectors } from '@/store/session/selectors';
const Component = () => {
const recentTopics = useSessionStore(recentSelectors.recentTopics);
const isInit = useSessionStore(recentSelectors.isRecentTopicsInit);
if (!isInit) return <div>Loading...</div>;
return (
<div>
{recentTopics.map((topic) => (
<div key={topic.id}>{topic.title}</div>
))}
</div>
);
};
```
### Method 2: Use Hook Return (Single component)
```tsx
const { data: recentTopics, isLoading } = useInitRecentTopic();
```
## Available Selectors
### Recent Topics
```tsx
const recentTopics = useSessionStore(recentSelectors.recentTopics);
// Type: RecentTopic[]
const isInit = useSessionStore(recentSelectors.isRecentTopicsInit);
// Type: boolean
```
**RecentTopic type:**
```typescript
interface RecentTopic {
agent: {
avatar: string | null;
backgroundColor: string | null;
id: string;
title: string | null;
} | null;
id: string;
title: string | null;
updatedAt: Date;
}
```
### Recent Resources
```tsx
const recentResources = useSessionStore(recentSelectors.recentResources);
// Type: FileListItem[]
const isInit = useSessionStore(recentSelectors.isRecentResourcesInit);
```
### Recent Pages
```tsx
const recentPages = useSessionStore(recentSelectors.recentPages);
const isInit = useSessionStore(recentSelectors.isRecentPagesInit);
```
## Features
1. **Auto login detection**: Only loads when user is logged in
2. **Data caching**: Stored in store, no repeated loading
3. **Auto refresh**: SWR refreshes on focus (5-minute interval)
4. **Type safe**: Full TypeScript types
## Best Practices
1. Initialize all recent data at app top-level
2. Use selectors to read from store
3. For multi-component use, prefer Method 1
4. Use selectors for render optimization
-185
View File
@@ -5,14 +5,6 @@ description: "Version release workflow. Use when the user mentions 'release', 'h
# Version Release Workflow
## Mandatory Companion Skill
For every `/version-release` execution, you MUST load and apply:
- `../microcopy/SKILL.md`
Changelog style guidance is now fully embedded in this skill. Keep release facts unchanged, and only improve structure, readability, and tone.
## Overview
The primary development branch is **canary**. All day-to-day development happens on canary. When releasing, canary is merged into main. After merge, `auto-tag-release.yml` automatically handles tagging, version bumping, creating a GitHub Release, and syncing back to the canary branch.
@@ -158,166 +150,6 @@ All release PR bodies (both Minor and Patch) must include a user-facing changelo
- Weekly Release: See `reference/changelog-example/weekly-release.md`
- DB Migration: See `reference/changelog-example/db-migration.md`
### Mandatory Inputs Before Writing
1. Release diff context (`git log main..canary` and/or `git diff main...canary --stat`)
2. Existing release template constraints (title, credits, trigger rules)
3. `../microcopy/SKILL.md` terminology constraints
### Output Constraints (Hard Rules)
1. Keep all factual claims accurate to merged changes.
2. Do not invent numbers, scope, timelines, or availability tiers.
3. Keep release title and trigger-sensitive format unchanged.
4. Keep `Credits` section intact (format required by project conventions).
5. Prefer fewer headings and more natural narrative paragraphs.
6. EN/ZH versions must cover the same facts in the same order.
7. Prefer storytelling over feature enumeration.
8. Avoid `Key Updates` sections that are only bullet dumps unless explicitly requested.
### Editorial Voice (Notion/Linear-Inspired)
Target a changelog voice that is calm, confident, and human:
- Start from user reality, not internal implementation.
- Explain why this change matters before listing mechanics.
- Keep tone practical and grounded, but allow a little product warmth.
- Favor concrete workflow examples over abstract claims.
- Write like an update from a thoughtful product team, not a marketing launch page.
### Writing Model (3-Pass Rewrite)
#### Pass 1: Remove AI Vocabulary and Filler
- Replace inflated words with simple alternatives.
- Remove transition padding like "furthermore", "notably", "it is worth noting that".
- Cut generic importance inflation ("pivotal", "testament", "game-changer").
- Prefer direct verbs like `run`, `customize`, `manage`, `capture`, `improve`, `fix`.
#### Pass 2: Break AI Sentence Patterns
Avoid these structures:
- Parallel negation: "Not X, but Y"
- Tricolon overload: "A, B, and C" used repeatedly
- Rhetorical Q + answer: "What does this mean? It means..."
- Dramatic reveal openers: "Here's the thing", "The result?"
- Mirror symmetry in consecutive lines
- Overuse of em dashes
- Every paragraph ending in tidy "lesson learned" phrasing
#### Pass 3: Add Human Product Texture
- Lead with user-visible outcome, then explain mechanism.
- Mix sentence lengths naturally.
- Prefer straightforward phrasing over polished-but-empty language.
- Keep confidence, but avoid launch-ad hype.
- Write like a product team update, not a marketing page.
### Recommended Structure Blueprint
Use this shape unless the user asks otherwise:
1. `# 🚀 release: ...`
2. One opening paragraph (2-4 sentences) that explains overall user impact.
3. 2-4 narrative capability blocks (short headings optional):
- each block = user value + key capability
4. `Improvements and fixes` / `体验优化与修复` with concise bullets
5. `Credits` with required mention format
### Length and Reading Density (Important)
Avoid overly short release notes when the diff is substantial.
- Weekly release PR body:
- Usually target 350-700 English words (or equivalent Chinese length)
- Keep 2-4 narrative sections, each with at least one real paragraph
- Minor release PR body:
- Usually target 500-1000 English words (or equivalent Chinese length)
- Allow richer context and more concrete usage scenarios
- DB migration release PR body:
- Keep concise, but still include context + impact + operator notes
- If there are many commits, increase narrative depth before adding more bullets.
- If there are few commits, stay concise and do not pad content.
### Storytelling Contract (Major Capabilities)
For each major capability, write in this order:
1. Prior context/problem (briefly)
2. What changed in this release
3. Practical impact on user workflow
Do not collapse major capability sections into one-line bullets.
### Section Anatomy (Preferred)
Each major section should follow this internal rhythm:
1. Lead sentence: what changed and who benefits.
2. Context sentence: what was painful, slow, or fragmented before.
3. Mechanism paragraph: how the new behavior works in practice.
4. Optional utility list (`Use X to:`) for actionable workflows.
5. Optional availability closer when plan/platform constraints matter.
This pattern increases readability and makes changelogs more enjoyable to read without sacrificing precision.
### Section and Heading Heuristics
- Keep heading count low (typically 3-5).
- Weekly release PR body target:
- 1 opening paragraph
- 2-4 major narrative sections
- 1 improvements/fixes section
- 1 credits section
- Never produce heading-per-bullet layout.
- If a section has 4+ bullets, convert into 2-3 short narrative paragraphs when possible.
### Linear-Style Block Pattern
Use this pattern when writing major sections:
```md
## <Capability name>
<One sentence: what users can do now and why it matters.>
<One short paragraph: how this works in practice, in plain language.>
<Optional list for workflows>
Use <feature> to:
- <practical action 1>
- <practical action 2>
- <practical action 3>
<Optional availability sentence>
```
### Notion-Style Readability Moves
Apply these moves when appropriate:
- Use one clear "scene" sentence to ground context (for example, what a team is doing when the feature helps).
- Alternate paragraph lengths: one compact paragraph followed by a denser explanatory one.
- Prefer specific nouns (`triage inbox`, `topic switch`, `mobile session`) over broad terms like "experience" or "workflow improvements".
- Keep transitions natural (`Previously`, `Now`, `In practice`, `This means`) and avoid ornate writing.
- End key sections with a practical takeaway sentence, not a slogan.
### Anti-Pattern Red Flags (Rewrite Required)
- "Key Updates" followed by only bullets and no narrative context
- One bullet per feature with no prior context or user impact
- Repeated template like "Feature X: did Y"
- Heading-per-feature with no explanatory paragraph
- Mechanical transitions with no causal flow
### EN/ZH Synchronization Rules
- Keep section order aligned.
- Keep facts and scope aligned.
- Localize naturally; avoid literal sentence mirroring.
- If one language uses bullets for a section, the other should match style intent.
### Writing Tips
- **User-facing**: Describe changes that users can perceive, not internal implementation details
@@ -325,20 +157,3 @@ Apply these moves when appropriate:
- **Highlight key items**: Use `**bold**` for important feature names
- **Credit contributors**: Collect all committers via `git log` and list alphabetically
- **Flexible categories**: Choose categories based on actual changes — no need to force-fit all categories
- **Terminology enforcement**: Ensure wording follows `microcopy` skill terminology and tone constraints
- **Linear narrative enforcement**: Follow capability -> explanation -> optional "Use X to" list
- **Storytelling enforcement**: For major updates, write in "before -> now -> impact" order
- **Depth enforcement**: If the diff is non-trivial, prefer complete paragraphs over compressed bullet-only summaries
- **Pleasure-to-read enforcement**: Include concrete examples and practical scenarios so readers can imagine using the capability
### Quick Checklist
- [ ] First paragraph explains user-visible release outcome
- [ ] Heading count is minimal and meaningful
- [ ] Major capabilities are short narrative paragraphs, not only bullets
- [ ] Includes "before -> now -> impact" for major sections
- [ ] No obvious AI patterns (parallel negation, rhetorical Q/A, dramatic reveal)
- [ ] Vocabulary is plain, direct, and product-credible
- [ ] Improvements/fixes remain concise and scannable
- [ ] Credits format is preserved exactly
- [ ] EN/ZH versions align in facts and order
@@ -4,27 +4,16 @@ A changelog reference for database migration release PR bodies.
---
This release includes a **database schema migration** for Agent Evaluation Benchmark. We are adding **5 new tables** so benchmark setup, runs, and run-topic records can be stored in a complete and queryable structure.
This release includes a **database schema migration** involving **5 new tables** for the Agent Evaluation Benchmark system.
## Migration overview
### Migration: Add Agent Evaluation Benchmark Tables
Previously, benchmark-related data lacked a full lifecycle model, which made it harder to track evaluation flow from dataset to run results. This migration introduces the missing relational layer so benchmark configuration, execution, and analysis records stay connected.
- Added 5 new tables: `agent_eval_benchmarks`, `agent_eval_datasets`, `agent_eval_records`, `agent_eval_runs`, `agent_eval_run_topics`
In practical terms, this reduces ambiguity for downstream features and gives operators a cleaner foundation for troubleshooting and reporting.
### Notes for Self-hosted Users
Added tables:
- `agent_eval_benchmarks`
- `agent_eval_datasets`
- `agent_eval_records`
- `agent_eval_runs`
- `agent_eval_run_topics`
## Notes for self-hosted users
- Migration runs automatically during app startup.
- No manual SQL action is required in standard deployments.
- As with any schema release, we still recommend database backup and rollout during a low-traffic window.
- The migration runs automatically on application startup
- No manual intervention required
The migration owner: @{pr-author} — responsible for this database schema change, reach out for any migration-related issues.
@@ -4,47 +4,42 @@ A real-world changelog reference for weekly patch release PR bodies.
---
This weekly release includes **82 commits**. The throughline is simple: less friction when moving from idea to execution. Across agent workflows, model coverage, and desktop polish, this release removes several small blockers that used to interrupt momentum.
This release includes **82 commits** , Key updates are below.
The result is not one headline feature, but a noticeably smoother week-to-week experience. Teams can evaluate agents with clearer structure, ship richer media flows, and spend less time debugging provider and platform edge cases.
### New Features and Enhancements
## Agent workflows and media generation
- Added **Agent Benchmark** support for more systematic agent performance evaluation.
- Introduced the **video generation** feature end-to-end, including entry points, sidebar "new" badge support, and skeleton loading for topic switching.
- Expanded memory capabilities: support for memory effort/tool permission configuration and improved timeout calculation for memory analysis tasks.
- Added desktop editor support for image upload via file picker.
Previously, some agent evaluation and media generation flows still felt fragmented: setup was manual, discoverability was uneven, and switching between topics could interrupt context. This release adds **Agent Benchmark** support and lands the **video generation** path end-to-end, from entry point to generation feedback.
### Models and Provider Expansion
In practice, this means users can discover and run these workflows with fewer detours. Sidebar "new" indicators improve visibility, skeleton loading makes topic switches feel less abrupt, and memory-related controls now behave more predictably under real workload pressure.
- Added a new provider: **Straico**.
- Added/updated support for:
- Claude Sonnet 4.6
- Gemini 3.1 Pro Preview
- Qwen3.5 series
- Grok Imagine (`grok-imagine-image`)
- MiniMax 2.5
- Added related i18n copy and model parameter adaptations.
We also expanded memory controls with effort and tool-permission configuration, and improved timeout calculation for memory analysis tasks so longer runs fail less often in production-like usage.
### Desktop Improvements
## Models and provider coverage
- Integrated `electron-liquid-glass` (macOS Tahoe).
- Improved DMG background assets and desktop release workflow.
Provider diversity matters most when teams can adopt new models without rewriting glue code every sprint. This release adds **Straico** and updates support for Claude Sonnet 4.6, Gemini 3.1 Pro Preview, Qwen3.5, Grok Imagine (`grok-imagine-image`), and MiniMax 2.5.
### Stability, Security, and UX Fixes
Use these updates to:
- route requests to newly available providers
- test newer model families without custom patching
- keep model parameters and related i18n copy aligned across providers
This keeps model exploration practical: faster evaluation loops, fewer adaptation surprises, and cleaner cross-provider behavior.
## Desktop and platform polish
Desktop receives a set of quality-of-life upgrades that reduce "death by a thousand cuts" moments. We integrated `electron-liquid-glass` for macOS Tahoe and improved DMG background assets and packaging flow for more consistent release output.
The desktop editor now supports image upload from the file picker, which shortens everyday authoring steps and removes one more reason to switch tools mid-task.
## Improvements and fixes
- Fixed multiple video pipeline issues across precharge refund handling, webhook token verification, pricing parameter usage, asset cleanup, and type safety.
- Fixed path traversal risk in `sanitizeFileName` and added corresponding unit tests.
- Fixed MCP media URL generation when `APP_URL` was duplicated in output paths.
- Fixed multiple video generation pipeline issues: precharge refund handling, webhook token verification, pricing parameter usage, asset cleanup, and type safety.
- Fixed `sanitizeFileName` path traversal risks and added unit tests.
- Fixed MCP media URL generation with duplicated `APP_URL` prefix.
- Fixed Qwen3 embedding failures caused by batch-size limits.
- Fixed several UI interaction issues, including mobile header agent selector/topic count, ChatInput scrolling behavior, and tooltip stacking context.
- Fixed multiple UI/interaction issues, including mobile header agent selector/topic count, ChatInput scrolling behavior, and tooltip stacking context.
- Fixed missing `@napi-rs/canvas` native bindings in Docker standalone builds.
- Improved GitHub Copilot authentication retry behavior and response error handling in edge cases.
## Credits
### Credits
Huge thanks to these contributors (alphabetical):
+3 -6
View File
@@ -71,18 +71,15 @@ internal_createTopic: async (params) => {
**Actions:**
- Public: `createTopic`, `sendMessage`
- Internal: `internal_createTopic`, `internal_updateMessageContent`
- Dispatch: `internal_dispatchTopic`
**State:**
- Toggle: `internal_toggleMessageLoading`
- ID arrays: `topicEditingIds`
**State:**
- ID arrays: `messageLoadingIds`, `topicEditingIds`
- Maps: `topicMaps`, `messagesMap`
- Active: `activeTopicId`
- Init flags: `topicsInit`
## Detailed Guides
@@ -30,13 +30,16 @@ internal_createMessage: async (message, context) => {
let tempId = context?.tempMessageId;
if (!tempId) {
tempId = internal_createTmpMessage(message);
internal_toggleMessageLoading(true, tempId);
}
try {
const id = await messageService.createMessage(message);
await refreshMessages();
internal_toggleMessageLoading(false, tempId);
return id;
} catch (e) {
internal_toggleMessageLoading(false, tempId);
internal_dispatchMessage({
id: tempId,
type: 'updateMessage',
-11
View File
@@ -408,14 +408,3 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# IMPORTANT: This key is stored server-side only and NEVER exposed to the client
# When this key is set, Klavis integration will be automatically enabled
# KLAVIS_API_KEY=your_klavis_api_key_here
# #######################################
# #### Message Gateway (IM Integration) ##
# #######################################
# External message-gateway for unified IM platform connection management.
# Set ENABLED=1 to activate. To migrate away, remove ENABLED first (keep URL/TOKEN)
# so LobeHub can automatically disconnect leftover gateway connections.
# MESSAGE_GATEWAY_ENABLED=1
# MESSAGE_GATEWAY_URL=https://message-gateway.lobehub.com
# MESSAGE_GATEWAY_SERVICE_TOKEN=your_service_token_here
+3 -3
View File
@@ -97,8 +97,8 @@ jobs:
if: needs.check-duplicate-run.outputs.should_skip != 'true'
strategy:
matrix:
shard: [1, 2, 3]
name: Test App (shard ${{ matrix.shard }}/3)
shard: [1, 2]
name: Test App (shard ${{ matrix.shard }}/2)
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 }}/3
run: bunx vitest --coverage --silent='passed-only' --reporter=default --reporter=blob --shard=${{ matrix.shard }}/2
- name: Upload blob report
if: ${{ !cancelled() }}
+1 -9
View File
@@ -25,9 +25,6 @@ Desktop.ini
*.code-workspace
.vscode/sessions.json
prd
# Recordings
.records/
# Temporary files
.temp/
temp/
@@ -140,10 +137,5 @@ pnpm-lock.yaml
.turbo
spaHtmlTemplates.ts
# Embedded CLI bundle (built at pack time)
apps/desktop/resources/bin/lobe-cli.js
apps/desktop/resources/cli-package.json
# Superpowers plugin brainstorm/spec outputs (local only; do not commit)
.superpowers/
.heerogeneous-tracing
docs/superpowers
+4 -4
View File
@@ -1,6 +1,6 @@
const { defineConfig } = require('@lobehub/i18n-cli');
const fs = require('node:fs');
const path = require('node:path');
const fs = require('fs');
const path = require('path');
module.exports = defineConfig({
entry: 'locales/en-US',
@@ -27,14 +27,14 @@ module.exports = defineConfig({
],
temperature: 0,
saveImmediately: true,
modelName: 'gpt-5.1-chat-latest',
modelName: 'chatgpt-4o-latest',
experimental: {
jsonMode: true,
},
markdown: {
reference:
'You need to maintain the component format of the mdx file; the output text does not need to be wrapped in any code block syntax on the outermost layer.\n' +
fs.readFileSync(path.join(__dirname, 'docs/glossary.md'), 'utf8'),
fs.readFileSync(path.join(__dirname, 'docs/glossary.md'), 'utf-8'),
entry: ['./README.md', './docs/**/*.md', './docs/**/*.mdx'],
entryLocale: 'en-US',
outputLocales: ['zh-CN'],
+4 -6
View File
@@ -6,11 +6,7 @@
},
"editor.formatOnSave": true,
// don't show errors, but fix when save and git pre commit
"eslint.rules.customizations": [
{ "rule": "simple-import-sort/exports", "severity": "off" },
{ "rule": "perfectionist/sort-interfaces", "severity": "off" },
{ "rule": "simple-import-sort/imports", "severity": "off" }
],
"eslint.rules.customizations": [],
"eslint.validate": [
"json",
"javascript",
@@ -20,7 +16,7 @@
// support mdx
"mdx"
],
"js/ts.tsdk.path": "node_modules/typescript/lib",
"mdx.server.enable": false,
"npm.packageManager": "pnpm",
"search.exclude": {
"**/node_modules": true,
@@ -48,7 +44,9 @@
// make stylelint work with tsx antd-style css template string
"typescriptreact"
],
"typescript.tsdk": "node_modules/typescript/lib",
"vitest.disableWorkspaceWarning": true,
"vitest.maximumConfigs": 10,
"workbench.editor.customLabels.patterns": {
"**/app/**/[[]*[]]/[[]*[]]/page.tsx": "${dirname(2)}/${dirname(1)}/${dirname} • page component",
"**/app/**/[[]*[]]/page.tsx": "${dirname(1)}/${dirname} • page component",
-119
View File
@@ -2,125 +2,6 @@
# Changelog
### [Version 2.1.52](https://github.com/lobehub/lobe-chat/compare/v2.1.51...v2.1.52)
<sup>Released on **2026-04-20**</sup>
#### 👷 Build System
- **database**: add topic status and tasks automation mode.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### Build System
- **database**: add topic status and tasks automation mode, closes [#13994](https://github.com/lobehub/lobe-chat/issues/13994) ([3bcd581](https://github.com/lobehub/lobe-chat/commit/3bcd581))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
## [Version 2.1.51](https://github.com/lobehub/lobe-chat/compare/v0.0.0-nightly.pr13850.8503...v2.1.51)
<sup>Released on **2026-04-16**</sup>
#### 👷 Build System
- **database**: add document history schema.
- **database**: add document history schema.
#### 🐛 Bug Fixes
- **misc**: fix minify cli.
- **misc**: recent delete.
- **deps**: pin @react-pdf/image to 3.0.4 to avoid privatized @react-pdf/svg.
- **database**: enforce document history ownership and pagination.
#### ✨ Features
- **database**: add document history table and update related models.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### Build System
- **database**: add document history schema, closes [#13789](https://github.com/lobehub/lobe-chat/issues/13789) ([c1174d3](https://github.com/lobehub/lobe-chat/commit/c1174d3))
- **database**: add document history schema ([e3eef04](https://github.com/lobehub/lobe-chat/commit/e3eef04))
#### What's fixed
- **misc**: fix minify cli, closes [#13888](https://github.com/lobehub/lobe-chat/issues/13888) ([cb4ad01](https://github.com/lobehub/lobe-chat/commit/cb4ad01))
- **misc**: recent delete, closes [#13878](https://github.com/lobehub/lobe-chat/issues/13878) ([85227cf](https://github.com/lobehub/lobe-chat/commit/85227cf))
- **deps**: pin @react-pdf/image to 3.0.4 to avoid privatized @react-pdf/svg ([d526b40](https://github.com/lobehub/lobe-chat/commit/d526b40))
- **database**: enforce document history ownership and pagination ([b9c4b87](https://github.com/lobehub/lobe-chat/commit/b9c4b87))
#### What's improved
- **database**: add document history table and update related models ([64fc6d4](https://github.com/lobehub/lobe-chat/commit/64fc6d4))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
## [Version 2.1.50](https://github.com/lobehub/lobe-chat/compare/v2.1.49...v2.1.50)
<sup>Released on **2026-04-16**</sup>
#### 👷 Build System
- **database**: add document history schema.
- **database**: add document history schema.
#### 🐛 Bug Fixes
- **deps**: pin @react-pdf/image to 3.0.4 to avoid privatized @react-pdf/svg.
- **database**: enforce document history ownership and pagination.
#### ✨ Features
- **database**: add document history table and update related models.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### Build System
- **database**: add document history schema, closes [#13789](https://github.com/lobehub/lobe-chat/issues/13789) ([c1174d3](https://github.com/lobehub/lobe-chat/commit/c1174d3))
- **database**: add document history schema ([e3eef04](https://github.com/lobehub/lobe-chat/commit/e3eef04))
#### What's fixed
- **deps**: pin @react-pdf/image to 3.0.4 to avoid privatized @react-pdf/svg ([d526b40](https://github.com/lobehub/lobe-chat/commit/d526b40))
- **database**: enforce document history ownership and pagination ([b9c4b87](https://github.com/lobehub/lobe-chat/commit/b9c4b87))
#### What's improved
- **database**: add document history table and update related models ([64fc6d4](https://github.com/lobehub/lobe-chat/commit/64fc6d4))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 2.1.45](https://github.com/lobehub/lobe-chat/compare/v2.1.44...v2.1.45)
<sup>Released on **2026-03-26**</sup>
+1 -1
View File
@@ -6,7 +6,7 @@ Guidelines for using Claude Code in this LobeHub repository.
- Next.js 16 + React 19 + TypeScript
- SPA inside Next.js with `react-router-dom`
- `@lobehub/ui`, antd for components; antd-style for CSS-in-JS**prefer `createStaticStyles` with `cssVar.*`** (zero-runtime); only fall back to `createStyles` + `token` when styles genuinely need runtime computation. See `.cursor/docs/createStaticStyles_migration_guide.md`.
- `@lobehub/ui`, antd for components; antd-style for CSS-in-JS
- react-i18next for i18n; zustand for state management
- SWR for data fetching; TRPC for type-safe backend
- Drizzle ORM with PostgreSQL; Vitest for testing
+1 -4
View File
@@ -1,6 +1,6 @@
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
.\" Manual command details come from the Commander command tree.
.TH LH 1 "" "@lobehub/cli 0.0.8" "User Commands"
.TH LH 1 "" "@lobehub/cli 0.0.3" "User Commands"
.SH NAME
lh \- LobeHub CLI \- manage and connect to LobeHub services
.SH SYNOPSIS
@@ -98,9 +98,6 @@ Manage messages
.B model
Manage AI models
.TP
.B notify
Send a callback message to a topic and trigger the agent to process it
.TP
.B provider
Manage AI providers
.TP
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@lobehub/cli",
"version": "0.0.8",
"version": "0.0.3",
"type": "module",
"bin": {
"lh": "./dist/index.js",
@@ -28,7 +28,6 @@
"type-check": "tsc --noEmit"
},
"devDependencies": {
"@lobechat/agent-gateway-client": "workspace:*",
"@lobechat/device-gateway-client": "workspace:*",
"@lobechat/local-file-shell": "workspace:*",
"@trpc/client": "^11.8.1",
-1
View File
@@ -1,5 +1,4 @@
packages:
- '../../packages/agent-gateway-client'
- '../../packages/device-gateway-client'
- '../../packages/local-file-shell'
- '../../packages/file-loaders'
+1 -27
View File
@@ -37,25 +37,7 @@ export async function getAuthInfo(): Promise<AuthInfo> {
};
}
export type AgentStreamTokenType = 'jwt' | 'apiKey';
export interface AgentStreamAuthInfo {
headers: Record<string, string>;
serverUrl: string;
/**
* Raw token value (without header prefix). Used for WebSocket auth messages
* where header-based auth is not available.
*/
token: string;
/**
* How the token should be verified by downstream services (agent gateway WS).
* jwt → validate with JWKS
* apiKey → validate by calling /api/v1/users/me
*/
tokenType: AgentStreamTokenType;
}
export async function getAgentStreamAuthInfo(): Promise<AgentStreamAuthInfo> {
export async function getAgentStreamAuthInfo(): Promise<Pick<AuthInfo, 'headers' | 'serverUrl'>> {
const serverUrl = resolveServerUrl();
const envJwt = process.env.LOBEHUB_JWT;
@@ -63,8 +45,6 @@ export async function getAgentStreamAuthInfo(): Promise<AgentStreamAuthInfo> {
return {
headers: { 'Oidc-Auth': envJwt },
serverUrl,
token: envJwt,
tokenType: 'jwt',
};
}
@@ -73,8 +53,6 @@ export async function getAgentStreamAuthInfo(): Promise<AgentStreamAuthInfo> {
return {
headers: { 'X-API-Key': envApiKey },
serverUrl,
token: envApiKey,
tokenType: 'apiKey',
};
}
@@ -86,15 +64,11 @@ export async function getAgentStreamAuthInfo(): Promise<AgentStreamAuthInfo> {
return {
headers: {},
serverUrl,
token: '',
tokenType: 'jwt',
};
}
return {
headers: { 'Oidc-Auth': result.credentials.accessToken },
serverUrl,
token: result.credentials.accessToken,
tokenType: 'jwt',
};
}
+2 -13
View File
@@ -258,10 +258,6 @@ export function registerAgentCommand(program: Command) {
'--device <target>',
'Target device ID, or use "local" for the current connected device',
)
.option(
'--no-headless',
"Disable headless mode and wait for human approval on tool calls (default: headless — tools auto-run, matching the CLI's non-interactive nature)",
)
.option('--json', 'Output full JSON event stream')
.option('-v, --verbose', 'Show detailed tool call info')
.option('--replay <file>', 'Replay events from a saved JSON file (offline)')
@@ -271,7 +267,6 @@ export function registerAgentCommand(program: Command) {
agentId?: string;
autoStart?: boolean;
device?: string;
headless?: boolean;
json?: boolean;
prompt?: string;
replay?: string;
@@ -345,11 +340,6 @@ export function registerAgentCommand(program: Command) {
if (options.slug) input.slug = options.slug;
if (options.topicId) input.appContext = { topicId: options.topicId };
if (options.autoStart === false) input.autoStart = false;
// commander's --no-headless sets `headless` to false. Anything else
// (undefined, true) → headless mode is on and tool calls auto-execute.
if (options.headless !== false) {
input.userInterventionConfig = { approvalMode: 'headless' };
}
const result = await client.aiAgent.execAgent.mutate(input as any);
const r = result as any;
@@ -365,17 +355,16 @@ export function registerAgentCommand(program: Command) {
}
// 2. Connect to stream (WebSocket via Gateway, or fallback to SSE)
const { serverUrl, headers, token, tokenType } = await getAgentStreamAuthInfo();
const { serverUrl, headers } = await getAgentStreamAuthInfo();
const agentGatewayUrl = options.sse ? undefined : resolveAgentGatewayUrl();
if (agentGatewayUrl) {
const token = headers['Oidc-Auth'] || headers['X-API-Key'] || '';
await streamAgentEventsViaWebSocket({
gatewayUrl: agentGatewayUrl,
json: options.json,
operationId,
serverUrl,
token,
tokenType,
verbose: options.verbose,
});
} else {
-42
View File
@@ -270,48 +270,6 @@ describe('generate command', () => {
);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Video generation started'));
});
it('should pass image-to-video params', async () => {
mockTrpcClient.generationTopic.createTopic.mutate.mockResolvedValue('topic-3');
mockTrpcClient.video.createVideo.mutate.mockResolvedValue({
data: { generationId: 'gen-v2' },
success: true,
});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'generate',
'video',
'a cat waving',
'--model',
'cogvideox',
'--provider',
'zhipu',
'--image',
'https://example.com/first.png',
'--end-image',
'https://example.com/last.png',
'--images',
'https://example.com/a.png',
'https://example.com/b.png',
]);
expect(mockTrpcClient.video.createVideo.mutate).toHaveBeenCalledWith(
expect.objectContaining({
generationTopicId: 'topic-3',
model: 'cogvideox',
params: {
endImageUrl: 'https://example.com/last.png',
imageUrl: 'https://example.com/first.png',
imageUrls: ['https://example.com/a.png', 'https://example.com/b.png'],
prompt: 'a cat waving',
},
provider: 'zhipu',
}),
);
});
});
describe('tts', () => {
+1 -10
View File
@@ -6,16 +6,13 @@ import { getTrpcClient } from '../../api/client';
export function registerVideoCommand(parent: Command) {
parent
.command('video <prompt>')
.description('Generate a video from text or image(s)')
.description('Generate a video from text')
.requiredOption('-m, --model <model>', 'Model ID')
.requiredOption('-p, --provider <provider>', 'Provider name')
.option('--aspect-ratio <ratio>', 'Aspect ratio (e.g. 16:9)')
.option('--duration <sec>', 'Duration in seconds')
.option('--resolution <res>', 'Resolution (e.g. 720p, 1080p)')
.option('--seed <n>', 'Random seed')
.option('--image <url>', 'First-frame image URL (image-to-video)')
.option('--images <urls...>', 'Multiple reference image URLs')
.option('--end-image <url>', 'Last-frame image URL')
.option('--json', 'Output raw JSON')
.action(
async (
@@ -23,9 +20,6 @@ export function registerVideoCommand(parent: Command) {
options: {
aspectRatio?: string;
duration?: string;
endImage?: string;
image?: string;
images?: string[];
json?: boolean;
model: string;
provider: string;
@@ -41,9 +35,6 @@ export function registerVideoCommand(parent: Command) {
if (options.duration) params.duration = Number.parseInt(options.duration, 10);
if (options.resolution) params.resolution = options.resolution;
if (options.seed) params.seed = Number.parseInt(options.seed, 10);
if (options.image) params.imageUrl = options.image;
if (options.images && options.images.length > 0) params.imageUrls = options.images;
if (options.endImage) params.endImageUrl = options.endImage;
const result = await client.video.createVideo.mutate({
generationTopicId: topicId as string,
-51
View File
@@ -79,57 +79,6 @@ describe('message command', () => {
);
expect(mockTrpcClient.message.listAll.query).not.toHaveBeenCalled();
});
it('should keep first page on the backend default offset for filtered queries', async () => {
mockTrpcClient.message.getMessages.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync([
'node',
'test',
'message',
'list',
'--topic-id',
't1',
'-L',
'200',
]);
expect(mockTrpcClient.message.getMessages.query).toHaveBeenCalledWith(
expect.objectContaining({ pageSize: 200, topicId: 't1' }),
);
});
it('should convert page 2 to current 1 for filtered queries', async () => {
mockTrpcClient.message.getMessages.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync([
'node',
'test',
'message',
'list',
'--topic-id',
't1',
'--page',
'2',
]);
expect(mockTrpcClient.message.getMessages.query).toHaveBeenCalledWith(
expect.objectContaining({ current: 1, topicId: 't1' }),
);
});
it('should support the short page flag for filtered queries', async () => {
mockTrpcClient.message.getMessages.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'message', 'list', '--topic-id', 't1', '-P', '2']);
expect(mockTrpcClient.message.getMessages.query).toHaveBeenCalledWith(
expect.objectContaining({ current: 1, topicId: 't1' }),
);
});
});
describe('search', () => {
+2 -4
View File
@@ -16,7 +16,7 @@ export function registerMessageCommand(program: Command) {
.option('--topic-id <id>', 'Filter by topic ID')
.option('--agent-id <id>', 'Filter by agent ID')
.option('-L, --limit <n>', 'Page size', '30')
.option('-P, --page <n>', 'Page number', '1')
.option('--page <n>', 'Page number', '1')
.option('--user', 'Only show user messages')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(
@@ -32,9 +32,7 @@ export function registerMessageCommand(program: Command) {
const hasFilter = options.topicId || options.agentId;
const pageSize = options.limit ? Number.parseInt(options.limit, 10) : undefined;
const current = options.page
? Math.max(Number.parseInt(options.page, 10) - 1, 0)
: undefined;
const current = options.page ? Number.parseInt(options.page, 10) : undefined;
let items: any[];
+1 -1
View File
@@ -208,7 +208,7 @@ function readAgentProfile(workspacePath: string): AgentProfile {
// Try to extract **Emoji:** value (single emoji)
const emojiMatch = content.match(/\*{0,2}Emoji:?\*{0,2}\s*(.+)/i);
const rawAvatar = emojiMatch ? emojiMatch[1].trim() : undefined;
// Filter out placeholder text like (待定)(Chinese TBD), _(待定)_, (TBD), N/A, etc.
// Filter out placeholder text like (待定), _(待定)_, (TBD), N/A, etc.
const isPlaceholder =
rawAvatar && /^[_*(].*[)_*]$|^(?:tbd|todo|n\/?a|none|待定|未定)$/i.test(rawAvatar);
const avatar = rawAvatar && !isPlaceholder ? rawAvatar : undefined;
-51
View File
@@ -1,51 +0,0 @@
import type { Command } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../api/client';
import { log } from '../utils/logger';
export function registerNotifyCommand(program: Command) {
program
.command('notify')
.description('Send a callback message to a topic and trigger the agent to process it')
.requiredOption('--topic <topicId>', 'Target topic ID')
.requiredOption('-c, --content <content>', 'Message content')
.option('--agent-id <agentId>', 'Agent ID (overrides topic default)')
.option('--thread-id <threadId>', 'Thread ID for threaded conversations')
.option('--json', 'Output JSON')
.action(
async (options: {
agentId?: string;
content: string;
json?: boolean;
threadId?: string;
topic: string;
}) => {
log.debug('notify: topic=%s, agentId=%s', options.topic, options.agentId);
const client = await getTrpcClient();
try {
const result = await client.agentNotify.notify.mutate({
agentId: options.agentId,
content: options.content,
threadId: options.threadId,
topicId: options.topic,
});
if (options.json) {
console.log(JSON.stringify(result, null, 2));
return;
}
console.log(`${pc.green('✓')} Message sent to topic ${pc.bold(result.topicId)}`);
if (result.operationId) {
console.log(` Operation ID: ${result.operationId}`);
}
} catch (error: any) {
console.error(`${pc.red('✗')} Failed to send notification: ${error.message}`);
process.exit(1);
}
},
);
}
+1 -6
View File
@@ -466,12 +466,7 @@ export function registerTaskCommand(program: Command) {
: act.priority === 'normal'
? pc.yellow(' [normal]')
: '';
const resolvedLabel = act.resolvedAction
? act.resolvedComment
? `${act.resolvedAction}: ${act.resolvedComment}`
: act.resolvedAction
: '';
const resolved = resolvedLabel ? pc.green(` ✏️ ${resolvedLabel}`) : '';
const resolved = act.resolvedAction ? pc.green(` ✏️ ${act.resolvedAction}`) : '';
const typeLabel = pc.dim(`[${act.briefType}]`);
console.log(
` ${icon} ${pc.dim(ago.padStart(7))} Brief ${typeLabel} ${act.title}${pri}${resolved}${idSuffix}`,
-42
View File
@@ -77,48 +77,6 @@ describe('topic command', () => {
expect.objectContaining({ agentId: 'a1' }),
);
});
it('should keep first page on the backend default offset', async () => {
mockTrpcClient.topic.getTopics.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'topic', 'list', '--agent-id', 'a1', '-L', '200']);
expect(mockTrpcClient.topic.getTopics.query).toHaveBeenCalledWith(
expect.objectContaining({ agentId: 'a1', pageSize: 200 }),
);
});
it('should convert page 2 to current 1', async () => {
mockTrpcClient.topic.getTopics.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync([
'node',
'test',
'topic',
'list',
'--agent-id',
'a1',
'--page',
'2',
]);
expect(mockTrpcClient.topic.getTopics.query).toHaveBeenCalledWith(
expect.objectContaining({ agentId: 'a1', current: 1 }),
);
});
it('should support the short page flag', async () => {
mockTrpcClient.topic.getTopics.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'topic', 'list', '--agent-id', 'a1', '-P', '2']);
expect(mockTrpcClient.topic.getTopics.query).toHaveBeenCalledWith(
expect.objectContaining({ agentId: 'a1', current: 1 }),
);
});
});
describe('search', () => {
+2 -3
View File
@@ -17,7 +17,7 @@ export function registerTopicCommand(program: Command) {
.description('List topics')
.option('--agent-id <id>', 'Filter by agent ID')
.option('-L, --limit <n>', 'Page size', '30')
.option('-P, --page <n>', 'Page number', '1')
.option('--page <n>', 'Page number', '1')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(
async (options: {
@@ -31,8 +31,7 @@ export function registerTopicCommand(program: Command) {
const input: Record<string, any> = {};
if (options.agentId) input.agentId = options.agentId;
if (options.limit) input.pageSize = Number.parseInt(options.limit, 10);
const page = options.page ? Number.parseInt(options.page, 10) : undefined;
if (page !== undefined && page > 1) input.current = page - 1;
if (options.page) input.current = Number.parseInt(options.page, 10);
const result = await client.topic.getTopics.query(input as any);
const items = Array.isArray(result) ? result : ((result as any).items ?? []);
+1 -1
View File
@@ -160,7 +160,7 @@ export function spawnDaemon(args: string[]): number {
// Re-run the same entry with --daemon-child (internal flag)
const child = spawn(process.execPath, [...process.execArgv, ...args, '--daemon-child'], {
detached: true,
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', LOBEHUB_DAEMON: '1' },
env: { ...process.env, LOBEHUB_DAEMON: '1' },
stdio: ['ignore', logFd, logFd],
});
+1 -1
View File
@@ -1,3 +1,3 @@
import { createProgram } from './program';
createProgram().parse(process.argv, { from: 'node' });
createProgram().parse();
-2
View File
@@ -22,7 +22,6 @@ import { registerMemoryCommand } from './commands/memory';
import { registerMessageCommand } from './commands/message';
import { registerMigrateCommand } from './commands/migrate';
import { registerModelCommand } from './commands/model';
import { registerNotifyCommand } from './commands/notify';
import { registerPluginCommand } from './commands/plugin';
import { registerProviderCommand } from './commands/provider';
import { registerSearchCommand } from './commands/search';
@@ -69,7 +68,6 @@ export function createProgram() {
registerTopicCommand(program);
registerMessageCommand(program);
registerModelCommand(program);
registerNotifyCommand(program);
registerProviderCommand(program);
registerPluginCommand(program);
registerUserCommand(program);
+1 -28
View File
@@ -279,10 +279,8 @@ describe('streamAgentEventsViaWebSocket', () => {
await flush();
const ws = capturedWs!;
// Note: serverUrl is not set here, and JSON.stringify drops undefined keys,
// so the parsed auth message will not contain a `serverUrl` field.
expect(ws.sent.map((s) => JSON.parse(s))).toEqual([
{ token: 'test-token', tokenType: 'jwt', type: 'auth' },
{ token: 'test-token', type: 'auth' },
{ lastEventId: '', type: 'resume' },
]);
@@ -290,31 +288,6 @@ describe('streamAgentEventsViaWebSocket', () => {
await promise;
});
it('should send tokenType=apiKey and serverUrl when the caller uses an API key', async () => {
const promise = streamAgentEventsViaWebSocket({
gatewayUrl: 'https://gw.test.com',
operationId: 'op-1',
serverUrl: 'https://app.lobehub.com',
token: 'lh_sk_abc',
tokenType: 'apiKey',
});
await flush();
const ws = capturedWs!;
// serverUrl is forwarded so the gateway can call back to /api/v1/users/me
// to verify the API key.
expect(ws.sent.map((s) => JSON.parse(s))[0]).toEqual({
serverUrl: 'https://app.lobehub.com',
token: 'lh_sk_abc',
tokenType: 'apiKey',
type: 'auth',
});
ws.simulateMessage({ id: '1', type: 'session_complete' });
await promise;
});
it('should render agent_event messages using existing renderEvent', async () => {
const promise = streamAgentEventsViaWebSocket({
gatewayUrl: 'https://gw.test.com',
+11 -19
View File
@@ -1,10 +1,16 @@
import type { AgentStreamEvent } from '@lobechat/agent-gateway-client';
import pc from 'picocolors';
import urlJoin from 'url-join';
import { log } from './logger';
export type { AgentStreamEvent } from '@lobechat/agent-gateway-client';
export interface AgentStreamEvent {
data: any;
id?: string;
operationId: string;
stepIndex: number;
timestamp: number;
type: string;
}
interface StreamOptions {
json?: boolean;
@@ -14,18 +20,7 @@ interface StreamOptions {
interface WebSocketStreamOptions extends StreamOptions {
gatewayUrl: string;
operationId: string;
/**
* LobeHub server URL the gateway should call back to when verifying
* an apiKey token (via `/api/v1/users/me`). Required when
* `tokenType === 'apiKey'`; ignored for JWT.
*/
serverUrl?: string;
token: string;
/**
* How the gateway should verify `token`. `jwt` is the default for
* backwards compatibility with existing callers.
*/
tokenType?: 'jwt' | 'apiKey';
}
/**
@@ -173,13 +168,13 @@ const HEARTBEAT_INTERVAL = 30_000;
export async function streamAgentEventsViaWebSocket(
options: WebSocketStreamOptions,
): Promise<void> {
const { gatewayUrl, operationId, serverUrl, token, tokenType = 'jwt', ...streamOpts } = options;
const { gatewayUrl, operationId, token, ...streamOpts } = options;
const wsUrl = urlJoin(
gatewayUrl.replace(/^http/, 'ws'),
`/ws?operationId=${encodeURIComponent(operationId)}`,
);
log.debug(`Connecting to gateway: ${wsUrl} (auth: ${tokenType})`);
log.debug(`Connecting to gateway: ${wsUrl}`);
return new Promise<void>((resolve, reject) => {
const ws = new WebSocket(wsUrl);
@@ -197,10 +192,7 @@ export async function streamAgentEventsViaWebSocket(
};
ws.onopen = () => {
// `serverUrl` is required so the gateway can call back to verify an
// apiKey token. Harmless (but unused) for JWT, so we always include it
// when available to match the device-gateway-client contract.
ws.send(JSON.stringify({ serverUrl, token, tokenType, type: 'auth' }));
ws.send(JSON.stringify({ token, type: 'auth' }));
};
ws.onmessage = (event) => {
-4
View File
@@ -9,10 +9,6 @@ export default defineConfig({
entry: ['src/index.ts'],
fixedExtension: false,
format: ['esm'],
minify: !!process.env.MINIFY,
outputOptions: {
codeSplitting: false,
},
platform: 'node',
target: 'node18',
});
+1 -24
View File
@@ -109,26 +109,6 @@ const config = {
console.info('📦 Downloading agent-browser binary...');
execSync('node scripts/download-agent-browser.mjs', { stdio: 'inherit', cwd: __dirname });
// Build and copy CLI bundle for embedding
console.info('📦 Building CLI for embedding...');
execSync('npm run build:cli', { stdio: 'inherit', cwd: __dirname });
const cliSrc = path.resolve(__dirname, '../cli/dist/index.js');
const cliDest = path.resolve(__dirname, 'resources/bin/lobe-cli.js');
await fs.copyFile(cliSrc, cliDest);
// Write a minimal package.json next to the CLI bundle so that
// createRequire('../package.json') resolves correctly in the packaged app.
// The CLI script lives at Resources/bin/lobe-cli.js, so '../package.json'
// resolves to Resources/package.json.
const cliPkg = JSON.parse(
await fs.readFile(path.resolve(__dirname, '../cli/package.json'), 'utf8'),
);
await fs.writeFile(
path.resolve(__dirname, 'resources/cli-package.json'),
JSON.stringify({ name: cliPkg.name, type: 'module', version: cliPkg.version }),
);
console.info('✅ CLI bundle copied to resources/bin/lobe-cli.js');
},
/**
* AfterPack hook for post-processing:
@@ -316,10 +296,7 @@ const config = {
releaseNotes: process.env.RELEASE_NOTES || undefined,
},
extraResources: [
{ from: 'resources/bin', to: 'bin' },
{ from: 'resources/cli-package.json', to: 'package.json' },
],
extraResources: [{ from: 'resources/bin', to: 'bin' }],
win: {
executableName: 'LobeHub',
+5 -57
View File
@@ -15,64 +15,15 @@ import {
import { getExternalDependencies } from './native-deps.config.mjs';
/**
* 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`
* Rewrite `/` to `/apps/desktop/index.html` so the electron-vite dev server
* serves the desktop HTML entry when root is the monorepo root.
*/
function electronDesktopHtmlPlugin(): PluginOption {
return {
configureServer(server: ViteDevServer) {
server.middlewares.use((req, _res, next) => {
const rawUrl = req.url ?? '';
const pathname = rawUrl.split('?')[0];
// Explicit document-entry requests — always rewrite.
if (pathname === '/' || pathname === '/index.html') {
if (req.url === '/' || req.url === '/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();
});
@@ -139,6 +90,7 @@ export default defineConfig({
outDir: 'dist/preload',
sourcemap: isDev ? 'inline' : false,
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src/main'),
@@ -151,10 +103,7 @@ export default defineConfig({
build: {
outDir: path.resolve(__dirname, 'dist/renderer'),
rollupOptions: {
input: {
main: path.resolve(__dirname, 'index.html'),
popup: path.resolve(__dirname, 'popup.html'),
},
input: path.resolve(__dirname, 'index.html'),
output: sharedRollupOutput,
},
},
@@ -164,7 +113,6 @@ export default defineConfig({
},
optimizeDeps: sharedOptimizeDeps,
plugins: [
forceAbsoluteBasePlugin(),
electronDesktopHtmlPlugin(),
...(sharedRendererPlugins({ platform: 'desktop' }) as PluginOption[]),
],
-1
View File
@@ -11,7 +11,6 @@
"author": "LobeHub",
"main": "./dist/main/index.js",
"scripts": {
"build:cli": "cd ../cli && cross-env MINIFY=1 bun run build",
"build:main": "cross-env NODE_OPTIONS=--max-old-space-size=8192 electron-vite build",
"build:run-unpack": "electron .",
"dev": "electron-vite dev",
-3
View File
@@ -1,11 +1,8 @@
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
@@ -1,114 +0,0 @@
<!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,20 +66,6 @@ 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_ELECTRON_DESKTOP_SHORTCUTS,
shortcuts: DEFAULT_SHORTCUTS_CONFIG,
storagePath: appStorageDir,
themeMode: 'system',
updateChannel: UPDATE_CHANNEL,
+3 -4
View File
@@ -12,7 +12,6 @@ import { BrowserWindow, shell } from 'electron';
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
import { appendVercelCookie } from '@/utils/http-headers';
import { createLogger } from '@/utils/logger';
import { netFetch } from '@/utils/net-fetch';
import { ControllerModule, IpcMethod } from './index';
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
@@ -361,10 +360,10 @@ export default class AuthCtr extends ControllerModule {
logger.debug(`Polling for credentials: ${url.toString()}`);
// Use Electron net.fetch to respect system CA store (self-signed/private CA certs)
// Send HTTP request directly
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
appendVercelCookie(headers);
const response = await netFetch(url.toString(), { headers, method: 'GET' });
const response = await fetch(url.toString(), { headers, method: 'GET' });
// Check response status
if (response.status === 404) {
@@ -482,7 +481,7 @@ export default class AuthCtr extends ControllerModule {
'Content-Type': 'application/x-www-form-urlencoded',
};
appendVercelCookie(tokenHeaders);
const response = await netFetch(tokenUrl.toString(), {
const response = await fetch(tokenUrl.toString(), {
body,
headers: tokenHeaders,
method: 'POST',
@@ -1,5 +1,4 @@
import type {
FocusTopicPopupParams,
InterceptRouteParams,
OpenSettingsWindowOptions,
WindowMinimumSizeParams,
@@ -81,30 +80,6 @@ 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) => {
@@ -1,58 +0,0 @@
import { exec } from 'node:child_process';
import path from 'node:path';
import process from 'node:process';
import { promisify } from 'node:util';
import { getCliWrapperDir } from '@/modules/cliEmbedding';
import { createLogger } from '@/utils/logger';
import { ControllerModule, IpcMethod } from './index';
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
const logger = createLogger('controllers:CliCtr');
function normalizeServerUrl(url: string): string {
return url.replace(/\/$/, '');
}
export default class CliCtr extends ControllerModule {
static override readonly groupName = 'cli';
@IpcMethod()
async runCliCommand(args: string): Promise<{ exitCode: number; stderr: string; stdout: string }> {
const execAsync = promisify(exec);
const wrapperDir = getCliWrapperDir();
const cmd = process.platform === 'win32' ? 'lobehub.cmd' : 'lobehub';
const wrapperPath = path.join(wrapperDir, cmd);
const env = { ...process.env };
const remoteCtr = this.app.getController(RemoteServerConfigCtr);
if (remoteCtr) {
const [token, serverUrl] = await Promise.all([
remoteCtr.getAccessToken(),
remoteCtr.getRemoteServerUrl(),
]);
if (token && serverUrl) {
env.LOBEHUB_JWT = token;
env.LOBEHUB_SERVER = normalizeServerUrl(serverUrl);
logger.debug('Injected LOBEHUB_JWT / LOBEHUB_SERVER for CLI command');
}
}
try {
const { stdout, stderr } = await execAsync(`"${wrapperPath}" ${args}`, {
env,
timeout: 15_000,
});
return { exitCode: 0, stderr, stdout };
} catch (error: any) {
return {
exitCode: error.code ?? 1,
stderr: error.stderr ?? '',
stdout: error.stdout ?? String(error.message),
};
}
}
}
@@ -1,436 +0,0 @@
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();
});
}
}
@@ -48,7 +48,6 @@ import { type FileResult, type SearchOptions } from '@/modules/fileSearch';
import ContentSearchService from '@/services/contentSearchSrv';
import FileSearchService from '@/services/fileSearchSrv';
import { createLogger } from '@/utils/logger';
import { netFetch } from '@/utils/net-fetch';
import { ControllerModule, IpcMethod } from './index';
@@ -342,7 +341,7 @@ export default class LocalFileCtr extends ControllerModule {
}
try {
const response = await netFetch(url);
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to download skill package: ${response.status} ${response.statusText}`,
@@ -3,7 +3,7 @@ import type {
ShowDesktopNotificationParams,
} from '@lobechat/electron-client-ipc';
import { app, Notification } from 'electron';
import { linux, macOS, windows } from 'electron-is';
import { macOS, windows } from 'electron-is';
import { getIpcContext } from '@/utils/ipc';
import { createLogger } from '@/utils/logger';
@@ -131,12 +131,7 @@ export default class NotificationCtr extends ControllerModule {
silent: params.silent || false,
timeoutType: 'default',
title: params.title,
// On Linux/GNOME Shell, urgency 'normal' causes notifications to appear as banners.
// Clicking the dismiss (X) button on such banners can freeze the system for 30-45 seconds
// due to heavy gnome-shell processing. Using 'low' urgency routes notifications to the
// message tray instead, preventing the banner's X button from being shown.
// The urgency option is ignored on macOS and Windows.
urgency: linux() ? 'low' : 'normal',
urgency: 'normal',
});
// Add more event listeners for debugging
@@ -178,28 +173,6 @@ 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
*/
@@ -9,7 +9,6 @@ import { OFFICIAL_CLOUD_SERVER } from '@/const/env';
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
import { appendVercelCookie } from '@/utils/http-headers';
import { createLogger } from '@/utils/logger';
import { netFetch } from '@/utils/net-fetch';
import { ControllerModule, IpcMethod } from './index';
@@ -486,7 +485,7 @@ export default class RemoteServerConfigCtr extends ControllerModule {
'Content-Type': 'application/x-www-form-urlencoded',
};
appendVercelCookie(headers);
const response = await netFetch(tokenUrl.toString(), { body, headers, method: 'POST' });
const response = await fetch(tokenUrl.toString(), { body, headers, method: 'POST' });
if (!response.ok) {
// Try to parse error response
@@ -10,38 +10,17 @@ import { runCommand, ShellProcessManager } from '@lobechat/local-file-shell';
import { createLogger } from '@/utils/logger';
import CliCtr from './CliCtr';
import { ControllerModule, IpcMethod } from './index';
const logger = createLogger('controllers:ShellCommandCtr');
const processManager = new ShellProcessManager();
/** Prefix for a simple `lh`/`lobe`/`lobehub` invocation (keyword + boundary, args via slice). */
const SIMPLE_LH_PREFIX = /^\s*(?:lh|lobe|lobehub)(?=\s|$)/;
export default class ShellCommandCtr extends ControllerModule {
static override readonly groupName = 'shellCommand';
@IpcMethod()
async handleRunCommand(params: RunCommandParams): Promise<RunCommandResult> {
const prefixMatch = SIMPLE_LH_PREFIX.exec(params.command);
if (prefixMatch) {
const cliCtr = this.app.getController(CliCtr);
if (cliCtr) {
const args = params.command.slice(prefixMatch[0].length).trim();
logger.debug('Routing lh command to CliCtr.runCliCommand:', args);
const result = await cliCtr.runCliCommand(args);
return {
exit_code: result.exitCode,
output: result.stdout + result.stderr,
stderr: result.stderr,
stdout: result.stdout,
success: result.exitCode === 0,
};
}
}
return runCommand(params, { logger, processManager });
}
+2 -220
View File
@@ -1,18 +1,8 @@
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,
GitBranchInfo,
GitBranchListItem,
GitCheckoutResult,
GitLinkedPullRequestResult,
GitWorkingTreeStatus,
ThemeMode,
} from '@lobechat/electron-client-ipc';
import type { ElectronAppState, ThemeMode } from '@lobechat/electron-client-ipc';
import { app, dialog, nativeTheme, shell } from 'electron';
import { macOS } from 'electron-is';
import { pathExists, readdir } from 'fs-extra';
@@ -245,8 +235,7 @@ export default class SystemController extends ControllerModule {
}
}
@IpcMethod()
async detectRepoType(dirPath: string): Promise<'git' | 'github' | undefined> {
private async detectRepoType(dirPath: string): Promise<'git' | 'github' | undefined> {
const gitConfigPath = path.join(dirPath, '.git', 'config');
try {
const config = await readFile(gitConfigPath, 'utf8');
@@ -257,213 +246,6 @@ 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,15 +1,8 @@
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');
/**
@@ -119,19 +112,4 @@ 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;
}
}
}
@@ -29,11 +29,6 @@ vi.mock('electron', () => ({
ipcMain: {
handle: ipcMainHandleMock,
},
net: {
fetch: vi.fn((input: RequestInfo | URL, init?: RequestInit) =>
global.fetch(input as any, init as any),
),
},
shell: {
openExternal: vi.fn().mockResolvedValue(undefined),
},
@@ -19,7 +19,7 @@ vi.mock('electron', () => ({
},
}));
// Mock App and its dependencies
// 模拟 App 及其依赖项
const mockToggleVisible = vi.fn();
const mockLoadUrl = vi.fn();
const mockShow = vi.fn();
@@ -14,29 +14,29 @@ vi.mock('electron', () => ({
},
}));
// Mock App and its dependencies
// 模拟 App 及其依赖项
const mockShow = vi.fn();
const mockRetrieveByIdentifier = vi.fn(() => ({
show: mockShow,
}));
// Create an object that sufficiently mocks App behavior to satisfy DevtoolsCtr's needs
// 创建一个足够模拟 App 行为的对象,以满足 DevtoolsCtr 的需求
const mockApp = {
browserManager: {
retrieveByIdentifier: mockRetrieveByIdentifier,
},
// If DevtoolsCtr or its base class uses other app properties/methods during construction or method calls,
// they also need to be added as mocks here
} as unknown as App; // Type assertion since we only mock a subset of the App structure
// 如果 DevtoolsCtr 或其基类在构造或方法调用中使用了 app 的其他属性/方法,
// 也需要在这里添加相应的模拟
} as unknown as App; // 使用类型断言,因为我们只模拟了部分 App 结构
describe('DevtoolsCtr', () => {
let devtoolsCtr: DevtoolsCtr;
beforeEach(() => {
vi.clearAllMocks(); // Only clears mock function records created by vi.fn(), does not affect IoCContainer state
vi.clearAllMocks(); // 只清除 vi.fn() 创建的模拟函数的记录,不影响 IoCContainer 状态
ipcMainHandleMock.mockClear();
// Instantiate DevtoolsCtr. Its @IpcMethod decorator will execute and interact with the real IoCContainer.
// 实例化 DevtoolsCtr。其 @IpcMethod 装饰器会执行并与真实的 IoCContainer 交互。
devtoolsCtr = new DevtoolsCtr(mockApp);
});
@@ -44,9 +44,9 @@ describe('DevtoolsCtr', () => {
it('should retrieve the devtools browser window using app.browserManager and show it', async () => {
await devtoolsCtr.openDevtools();
// Verify that browserManager.retrieveByIdentifier is called with the 'devtools' argument
// 验证 browserManager.retrieveByIdentifier 是否以 'devtools' 参数被调用
expect(mockRetrieveByIdentifier).toHaveBeenCalledWith('devtools');
// Verify that the show method of the returned object is called
// 验证返回对象的 show 方法是否被调用
expect(mockShow).toHaveBeenCalled();
});
});
@@ -1,216 +0,0 @@
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);
});
});
});
@@ -5,14 +5,11 @@ import { type App } from '@/core/App';
import LocalFileCtr from '../LocalFileCtr';
const { ipcMainHandleMock, fetchMock } = vi.hoisted(() => ({
const { ipcMainHandleMock } = vi.hoisted(() => ({
ipcMainHandleMock: vi.fn(),
fetchMock: vi.fn(),
}));
vi.mock('@/utils/net-fetch', () => ({
netFetch: fetchMock,
}));
const fetchMock = vi.fn();
// Mock logger
vi.mock('@/utils/logger', () => ({
@@ -40,6 +37,8 @@ vi.mock('electron', () => ({
},
}));
vi.stubGlobal('fetch', fetchMock);
// Mock node:fs/promises and node:fs
vi.mock('node:fs/promises', () => ({
access: vi.fn(),
@@ -14,7 +14,7 @@ vi.mock('electron', () => ({
},
}));
// Mock App and its dependencies
// 模拟 App 及其依赖项
const mockRefreshMenus = vi.fn();
const mockShowContextMenu = vi.fn();
const mockRebuildAppMenu = vi.fn();
@@ -37,7 +37,7 @@ describe('MenuController', () => {
describe('refreshAppMenu', () => {
it('should call menuManager.refreshMenus', () => {
// Mock return value
// 模拟返回值
mockRefreshMenus.mockReturnValueOnce(true);
const result = menuController.refreshAppMenu();
@@ -9,7 +9,7 @@ const { ipcMainHandleMock } = vi.hoisted(() => ({
ipcMainHandleMock: vi.fn(),
}));
// Mock logger
// 模拟 logger
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
debug: vi.fn(),
@@ -19,7 +19,7 @@ vi.mock('@/utils/logger', () => ({
}),
}));
// Mock undici - create mocks directly using vi.fn()
// 模拟 undici - 使用 vi.fn() 直接在 Mock 中创建
vi.mock('undici', () => ({
fetch: vi.fn(),
getGlobalDispatcher: vi.fn(),
@@ -28,7 +28,7 @@ vi.mock('undici', () => ({
ProxyAgent: vi.fn(),
}));
// Mock defaultProxySettings
// 模拟 defaultProxySettings
vi.mock('@/const/store', () => ({
defaultProxySettings: {
enableProxy: false,
@@ -40,7 +40,7 @@ vi.mock('@/const/store', () => ({
},
}));
// Mock App and its dependencies
// 模拟 App 及其依赖项
const mockStoreManager = {
get: vi.fn(),
set: vi.fn(),
@@ -53,19 +53,19 @@ const mockApp = {
describe('NetworkProxyCtr', () => {
let networkProxyCtr: NetworkProxyCtr;
// Dynamically import undici Mock
// 动态导入 undici Mock
let mockUndici: any;
beforeEach(async () => {
vi.clearAllMocks();
ipcMainHandleMock.mockClear();
// Dynamically import undici Mock
// 动态导入 undici Mock
mockUndici = await import('undici');
networkProxyCtr = new NetworkProxyCtr(mockApp);
// Set default return values for undici mocks
// 设置 undici mocks 的默认返回值
vi.mocked(mockUndici.Agent).mockReturnValue({});
vi.mocked(mockUndici.ProxyAgent).mockReturnValue({});
vi.mocked(mockUndici.getGlobalDispatcher).mockReturnValue({
@@ -73,7 +73,7 @@ describe('NetworkProxyCtr', () => {
});
vi.mocked(mockUndici.setGlobalDispatcher).mockReturnValue(undefined);
// Set default return value for fetch mock
// 设置 fetch mock 的默认返回值
vi.mocked(mockUndici.fetch).mockResolvedValue({
ok: true,
status: 200,
@@ -92,7 +92,7 @@ describe('NetworkProxyCtr', () => {
};
it('should validate enabled proxy config with all required fields', () => {
// Indirectly test validation logic by testing public methods
// 通过测试公共方法来间接测试验证逻辑
expect(() => networkProxyCtr.setProxySettings(validConfig)).not.toThrow();
});
@@ -350,7 +350,7 @@ describe('NetworkProxyCtr', () => {
const invalidConfig: NetworkProxySettings = {
enableProxy: true,
proxyType: 'http',
proxyServer: '', // invalid server
proxyServer: '', // 无效的服务器
proxyPort: '8080',
proxyRequireAuth: false,
proxyBypass: 'localhost,127.0.0.1,::1',
@@ -368,7 +368,7 @@ describe('NetworkProxyCtr', () => {
throw new Error('Store error');
});
// Should not throw an error
// 不应该抛出错误
await expect(networkProxyCtr.beforeAppReady()).resolves.not.toThrow();
mockStoreManager.get.mockReset();
@@ -386,7 +386,7 @@ describe('NetworkProxyCtr', () => {
proxyBypass: 'localhost,127.0.0.1,::1',
};
// Indirectly test URL building by testing proxy settings
// 通过测试代理设置来间接测试 URL 构建
expect(() => networkProxyCtr.setProxySettings(config)).not.toThrow();
});
@@ -402,7 +402,7 @@ describe('NetworkProxyCtr', () => {
proxyBypass: 'localhost,127.0.0.1,::1',
};
// Indirectly test URL building by testing proxy settings
// 通过测试代理设置来间接测试 URL 构建
expect(() => networkProxyCtr.setProxySettings(config)).not.toThrow();
});
@@ -418,7 +418,7 @@ describe('NetworkProxyCtr', () => {
proxyBypass: 'localhost,127.0.0.1,::1',
};
// Indirectly test URL building by testing proxy settings
// 通过测试代理设置来间接测试 URL 构建
expect(() => networkProxyCtr.setProxySettings(config)).not.toThrow();
});
});
@@ -41,7 +41,6 @@ vi.mock('electron', () => {
// Mock electron-is
vi.mock('electron-is', () => ({
linux: vi.fn(() => false),
macOS: vi.fn(() => false),
windows: vi.fn(() => false),
}));
@@ -181,26 +180,6 @@ describe('NotificationCtr', () => {
expect(result).toEqual({ success: true });
});
it('should use low urgency on Linux to prevent GNOME Shell freeze', async () => {
const { linux } = await import('electron-is');
const { Notification } = await import('electron');
vi.mocked(linux).mockReturnValue(true);
vi.mocked(Notification.isSupported).mockReturnValue(true);
mockBrowserWindow.isVisible.mockReturnValue(false);
const promise = controller.showDesktopNotification(params);
vi.advanceTimersByTime(100);
await promise;
expect(Notification).toHaveBeenCalledWith(
expect.objectContaining({
urgency: 'low',
}),
);
vi.mocked(linux).mockReturnValue(false);
});
it('should show notification when window is minimized', async () => {
const { Notification } = await import('electron');
vi.mocked(Notification.isSupported).mockReturnValue(true);
@@ -5,13 +5,8 @@ import type { App } from '@/core/App';
import RemoteServerConfigCtr from '../RemoteServerConfigCtr';
const { ipcMainHandleMock, mockFetch } = vi.hoisted(() => ({
const { ipcMainHandleMock } = vi.hoisted(() => ({
ipcMainHandleMock: vi.fn(),
mockFetch: vi.fn(),
}));
vi.mock('@/utils/net-fetch', () => ({
netFetch: mockFetch,
}));
// Mock logger
@@ -425,6 +420,13 @@ describe('RemoteServerConfigCtr', () => {
});
describe('refreshAccessToken', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch;
});
it('should return error when remote server is not active', async () => {
mockStoreManager.get.mockImplementation((key) => {
if (key === 'dataSyncConfig') {
@@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { App } from '@/core/App';
import CliCtr from '../CliCtr';
import ShellCommandCtr from '../ShellCommandCtr';
const { ipcMainHandleMock } = vi.hoisted(() => ({
@@ -33,17 +32,7 @@ vi.mock('node:crypto', () => ({
randomUUID: vi.fn(() => 'test-uuid-123'),
}));
vi.mock('../CliCtr', () => ({
default: class CliCtr {},
}));
const mockCliCtr = {
runCliCommand: vi.fn().mockResolvedValue({ exitCode: 0, stderr: '', stdout: 'cli output\n' }),
};
const mockApp = {
getController: vi.fn((c: unknown) => (c === CliCtr ? mockCliCtr : undefined)),
} as unknown as App;
const mockApp = {} as unknown as App;
describe('ShellCommandCtr (thin wrapper)', () => {
let ctr: ShellCommandCtr;
@@ -129,28 +118,6 @@ describe('ShellCommandCtr (thin wrapper)', () => {
expect(mockChildProcess.kill).toHaveBeenCalled();
});
it('should route lh commands to CliCtr.runCliCommand', async () => {
const result = await ctr.handleRunCommand({
command: 'lh status --json',
description: 'lh status',
});
expect(mockCliCtr.runCliCommand).toHaveBeenCalledWith('status --json');
expect(result.success).toBe(true);
expect(result.stdout).toContain('cli output');
expect(mockSpawn).not.toHaveBeenCalled();
});
it('should route lobehub commands to CliCtr.runCliCommand', async () => {
const result = await ctr.handleRunCommand({
command: 'lobehub search test',
description: 'lobehub search',
});
expect(mockCliCtr.runCliCommand).toHaveBeenCalledWith('search test');
expect(result.success).toBe(true);
});
it('should return error for non-existent shell_id', async () => {
const result = await ctr.handleGetCommandOutput({
shell_id: 'non-existent',
@@ -14,13 +14,13 @@ vi.mock('electron', () => ({
},
}));
// Mock App and its dependencies
// 模拟 App 及其依赖项
const mockGetShortcutsConfig = vi.fn().mockReturnValue({
toggleMainWindow: 'CommandOrControl+Shift+L',
openSettings: 'CommandOrControl+,',
});
const mockUpdateShortcutConfig = vi.fn().mockImplementation((id, accelerator) => {
// Simply mock a successful update
// 简单模拟更新成功
return true;
});
@@ -64,7 +64,7 @@ describe('ShortcutController', () => {
});
it('should return the result from shortcutManager.updateShortcutConfig', () => {
// Mock an update failure scenario
// 模拟更新失败的情况
mockUpdateShortcutConfig.mockReturnValueOnce(false);
const result = shortcutController.updateShortcutConfig({
@@ -19,7 +19,7 @@ vi.mock('electron', () => ({
},
}));
// Mock logger
// 模拟 logger
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
debug: vi.fn(),
@@ -27,10 +27,10 @@ vi.mock('@/utils/logger', () => ({
}),
}));
// Save the original platform to restore after all tests complete
// 保存原始平台,确保测试结束后能恢复
const originalPlatform = process.platform;
// Mock App and its dependencies
// 模拟 App 及其依赖项
const mockToggleVisible = vi.fn();
const mockGetMainWindow = vi.fn(() => ({
toggleVisible: mockToggleVisible,
@@ -56,14 +56,14 @@ describe('TrayMenuCtr', () => {
beforeEach(() => {
vi.clearAllMocks();
ipcMainHandleMock.mockClear();
// Reset mockedTray for each test
// 为每个测试重置 mockedTray
mockGetMainTray.mockReset();
trayMenuCtr = new TrayMenuCtr(mockApp);
});
// Restore platform settings after all tests complete
// 在所有测试完成后恢复平台设置
afterAll(() => {
// Restore the original platform
// 恢复原始平台
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
@@ -78,7 +78,7 @@ describe('TrayMenuCtr', () => {
describe('showNotification', () => {
it('should display balloon notification on Windows platform', async () => {
// Mock Windows platform
// 模拟 Windows 平台
Object.defineProperty(process, 'platform', { value: 'win32' });
const mockedTray = {
@@ -104,7 +104,7 @@ describe('TrayMenuCtr', () => {
});
it('should return error when not on Windows platform', async () => {
// Mock non-Windows platform
// 模拟非 Windows 平台
Object.defineProperty(process, 'platform', { value: 'darwin' });
const options: ShowTrayNotificationParams = {
@@ -123,7 +123,7 @@ describe('TrayMenuCtr', () => {
});
it('should return error when tray is not available on Windows', async () => {
// Mock Windows platform with no tray
// 模拟 Windows 平台但没有托盘
Object.defineProperty(process, 'platform', { value: 'win32' });
mockGetMainTray.mockReturnValue(null);
@@ -145,7 +145,7 @@ describe('TrayMenuCtr', () => {
describe('updateTrayIcon', () => {
it('should update tray icon on Windows platform', async () => {
// Mock Windows platform
// 模拟 Windows 平台
Object.defineProperty(process, 'platform', { value: 'win32' });
const mockedTray = {
@@ -165,7 +165,7 @@ describe('TrayMenuCtr', () => {
});
it('should handle errors when updating icon', async () => {
// Mock Windows platform
// 模拟 Windows 平台
Object.defineProperty(process, 'platform', { value: 'win32' });
const error = new Error('Failed to update icon');
@@ -189,7 +189,7 @@ describe('TrayMenuCtr', () => {
});
it('should return error when not on Windows platform', async () => {
// Mock non-Windows platform
// 模拟非 Windows 平台
Object.defineProperty(process, 'platform', { value: 'darwin' });
const options: UpdateTrayIconParams = {
@@ -207,7 +207,7 @@ describe('TrayMenuCtr', () => {
describe('updateTrayTooltip', () => {
it('should update tray tooltip on Windows platform', async () => {
// Mock Windows platform
// 模拟 Windows 平台
Object.defineProperty(process, 'platform', { value: 'win32' });
const mockedTray = {
@@ -227,7 +227,7 @@ describe('TrayMenuCtr', () => {
});
it('should return error when not on Windows platform', async () => {
// Mock non-Windows platform
// 模拟非 Windows 平台
Object.defineProperty(process, 'platform', { value: 'darwin' });
const options: UpdateTrayTooltipParams = {
@@ -243,7 +243,7 @@ describe('TrayMenuCtr', () => {
});
it('should return error when tooltip is not provided', async () => {
// Mock Windows platform
// 模拟 Windows 平台
Object.defineProperty(process, 'platform', { value: 'win32' });
const mockedTray = {
@@ -4,7 +4,7 @@ import type { App } from '@/core/App';
import UpdaterCtr from '../UpdaterCtr';
// Mock logger
// 模拟 logger
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
info: vi.fn(),
@@ -26,7 +26,7 @@ vi.mock('electron', () => ({
},
}));
// Mock App and its dependencies
// 模拟 App 及其依赖项
const mockCheckForUpdates = vi.fn();
const mockDownloadUpdate = vi.fn();
const mockInstallNow = vi.fn();
@@ -120,13 +120,13 @@ describe('UpdaterCtr', () => {
});
});
// Test error handling
// 测试错误处理
describe('error handling', () => {
it('should handle errors when checking for updates', async () => {
const error = new Error('Network error');
mockCheckForUpdates.mockRejectedValueOnce(error);
// Since the controller does not explicitly handle and return errors, we only verify that the call occurs and the error propagates correctly
// 由于控制器并未明确处理并返回错误,这里我们只验证调用发生且错误正确冒泡
await expect(updaterCtr.checkForUpdates()).rejects.toThrow(error);
});
+2 -3
View File
@@ -1,7 +1,6 @@
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) => {
@@ -16,7 +15,7 @@ const shortcutDecorator = (name: string) => (target: any, methodName: string, de
/**
* shortcut inject decorator
*/
export const shortcut = (method: DesktopHotkeyId) => shortcutDecorator(method);
export const shortcut = (method: ShortcutActionType) => shortcutDecorator(method);
const protocolDecorator =
(urlType: string, action: string) => (target: any, methodName: string, descriptor?: any) => {
@@ -2,10 +2,8 @@ import type { CreateServicesResult, IpcServiceConstructor, MergeIpcService } fro
import AuthCtr from './AuthCtr';
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';
@@ -23,10 +21,8 @@ import UpdaterCtr from './UpdaterCtr';
import UploadFileCtr from './UploadFileCtr';
export const controllerIpcConstructors = [
HeterogeneousAgentCtr,
AuthCtr,
BrowserWindowsCtr,
CliCtr,
DevtoolsCtr,
GatewayConnectionCtr,
LocalFileCtr,
+2 -10
View File
@@ -13,11 +13,9 @@ import { isDev } from '@/const/env';
import { ELECTRON_BE_PROTOCOL_SCHEME } from '@/const/protocol';
import type { IControlModule } from '@/controllers';
import AuthCtr from '@/controllers/AuthCtr';
import { generateCliWrapper, getCliWrapperDir } from '@/modules/cliEmbedding';
import {
astSearchDetectors,
browserAutomationDetectors,
cliAgentDetectors,
contentSearchDetectors,
fileSearchDetectors,
type IToolDetector,
@@ -91,9 +89,9 @@ export class App {
logger.info('----------------------------------------------');
logger.info('Starting LobeHub...');
// Append bundled binaries and CLI wrapper directories to PATH for tool resolution
// Append bundled binaries directory to PATH for fallback tool resolution
const pathSep = process.platform === 'win32' ? ';' : ':';
process.env.PATH = `${process.env.PATH}${pathSep}${binDir}${pathSep}${getCliWrapperDir()}`;
process.env.PATH = `${process.env.PATH}${pathSep}${binDir}`;
logger.debug('Initializing App');
// Initialize store manager
@@ -191,7 +189,6 @@ export class App {
const detectorCategories: Partial<Record<ToolCategory, IToolDetector[]>> = {
'runtime-environment': runtimeEnvironmentDetectors,
'cli-agents': cliAgentDetectors,
'ast-search': astSearchDetectors,
'browser-automation': browserAutomationDetectors,
'content-search': contentSearchDetectors,
@@ -229,11 +226,6 @@ export class App {
// Initialize app
await this.makeAppReady();
// Generate CLI wrapper for terminal usage
generateCliWrapper().catch((error) => {
logger.warn('Failed to generate CLI wrapper:', error);
});
// Initialize i18n. Note: app.getLocale() must be called after app.whenReady() to get the correct value
await this.i18n.init();
this.menuManager.initialize();
@@ -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 { app, BrowserWindow, ipcMain, screen, session as electronSession, shell } from 'electron';
import { BrowserWindow, ipcMain, screen, session as electronSession, shell } from 'electron';
import { preloadDir, resourcesDir } from '@/const/dir';
import { isMac } from '@/const/env';
@@ -259,13 +259,6 @@ 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,8 +1,4 @@
import type {
MainBroadcastEventKey,
MainBroadcastParams,
TopicPopupInfo,
} from '@lobechat/electron-client-ipc';
import type { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-client-ipc';
import type { WebContents } from 'electron';
import { isLinux } from '@/const/env';
@@ -15,9 +11,6 @@ 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');
@@ -152,62 +145,12 @@ 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
@@ -335,16 +278,6 @@ 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,89 +4,76 @@ import { type App as AppCore } from '../../App';
import Browser, { type BrowserWindowOpts } from '../Browser';
// Use vi.hoisted to define mocks before hoisting
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(),
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(),
},
},
on: vi.fn(),
setWindowOpenHandler: vi.fn(),
},
on: vi.fn(),
setWindowOpenHandler: vi.fn(),
},
};
};
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 },
}),
},
};
});
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 },
}),
},
};
});
// Mock electron
vi.mock('electron', () => ({
app: mockElectronApp,
BrowserWindow: MockBrowserWindow,
ipcMain: mockIpcMain,
nativeTheme: mockNativeTheme,
@@ -4,7 +4,6 @@ import { BrowserWindow, type Session } from 'electron';
import { isDev } from '@/const/env';
import { appendVercelCookie } from '@/utils/http-headers';
import { createLogger } from '@/utils/logger';
import { netFetch } from '@/utils/net-fetch';
interface BackendProxyProtocolManagerOptions {
getAccessToken: () => Promise<string | undefined | null>;
@@ -138,7 +137,7 @@ export class BackendProxyProtocolManager {
let upstreamResponse: Response;
try {
upstreamResponse = await netFetch(rewrittenUrl, requestInit);
upstreamResponse = await fetch(rewrittenUrl, requestInit);
} catch (error) {
this.logger.error(`${logPrefix} upstream fetch failed: ${rewrittenUrl}`, error);
@@ -12,9 +12,8 @@ import { RendererProtocolManager } from './RendererProtocolManager';
const logger = createLogger('core:RendererUrlManager');
// Vite build with root=monorepo preserves input path structure,
// so index.html / popup.html end up under apps/desktop/ in outDir.
// so index.html ends up at apps/desktop/index.html 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;
@@ -67,8 +66,7 @@ export class RendererUrlManager {
/**
* Resolve renderer file path in production.
* Static assets map directly; popup routes go to popup.html, all other
* routes fall back to index.html (SPA).
* Static assets map directly; all routes fall back to index.html (SPA).
*/
resolveRendererFilePath = async (url: URL): Promise<string | null> => {
const pathname = url.pathname;
@@ -79,12 +77,7 @@ export class RendererUrlManager {
return pathExistsSync(filePath) ? filePath : null;
}
// 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)
// All routes fallback to index.html (SPA)
return SPA_ENTRY_HTML;
};
@@ -41,7 +41,6 @@ export type ToolCategory =
| 'file-search'
| 'browser-automation'
| 'runtime-environment'
| 'cli-agents'
| 'system'
| 'custom';
@@ -43,11 +43,6 @@ vi.mock('electron', () => ({
BrowserWindow: {
getAllWindows: vi.fn(),
},
net: {
fetch: vi.fn((input: RequestInfo | URL, init?: RequestInit) =>
global.fetch(input as any, init as any),
),
},
}));
describe('BackendProxyProtocolManager', () => {
@@ -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 (value may be empty string when disabled by default)
if (!(id in DEFAULT_ELECTRON_DESKTOP_SHORTCUTS)) {
// 1. Check if ID is valid
if (!DEFAULT_SHORTCUTS_CONFIG[id]) {
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_ELECTRON_DESKTOP_SHORTCUTS };
this.shortcutsConfig = { ...DEFAULT_SHORTCUTS_CONFIG };
this.saveShortcutsConfig();
} else {
// Filter out invalid shortcuts that are not in DEFAULT_ELECTRON_DESKTOP_SHORTCUTS
// Filter out invalid shortcuts that are not in DEFAULT_SHORTCUTS_CONFIG
const filteredConfig: Record<string, string> = {};
let hasInvalidKeys = false;
Object.entries(config).forEach(([id, accelerator]) => {
if (id in DEFAULT_ELECTRON_DESKTOP_SHORTCUTS) {
if (DEFAULT_SHORTCUTS_CONFIG[id]) {
filteredConfig[id] = accelerator;
} else {
hasInvalidKeys = true;
@@ -248,7 +248,7 @@ export class ShortcutManager {
});
// Ensure all default shortcuts are present
Object.entries(DEFAULT_ELECTRON_DESKTOP_SHORTCUTS).forEach(([id, defaultAccelerator]) => {
Object.entries(DEFAULT_SHORTCUTS_CONFIG).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_ELECTRON_DESKTOP_SHORTCUTS };
this.shortcutsConfig = { ...DEFAULT_SHORTCUTS_CONFIG };
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_ELECTRON_DESKTOP_SHORTCUTS
if (!(id in DEFAULT_ELECTRON_DESKTOP_SHORTCUTS)) {
logger.debug(`Skipping shortcut '${id}' - not found in DEFAULT_ELECTRON_DESKTOP_SHORTCUTS`);
// 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`);
return;
}
@@ -1,7 +1,8 @@
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';
@@ -25,10 +26,10 @@ vi.mock('@/utils/logger', () => ({
}),
}));
// Mock desktop global shortcut defaults
vi.mock('@lobechat/const/desktopGlobalShortcuts', () => ({
DEFAULT_ELECTRON_DESKTOP_SHORTCUTS: {
showApp: '',
// Mock DEFAULT_SHORTCUTS_CONFIG
vi.mock('@/shortcuts', () => ({
DEFAULT_SHORTCUTS_CONFIG: {
showApp: 'Control+E',
openSettings: 'CommandOrControl+,',
},
}));
@@ -114,7 +115,7 @@ describe('ShortcutManager', () => {
expect(mockStoreManager.get).toHaveBeenCalledWith('shortcuts');
expect(globalShortcut.unregisterAll).toHaveBeenCalled();
expect(globalShortcut.register).not.toHaveBeenCalledWith('Control+E', expect.any(Function));
expect(globalShortcut.register).toHaveBeenCalledWith('Control+E', expect.any(Function));
expect(globalShortcut.register).toHaveBeenCalledWith(
'CommandOrControl+,',
expect.any(Function),
@@ -144,7 +145,7 @@ describe('ShortcutManager', () => {
shortcutManager.initialize();
const config = shortcutManager.getShortcutsConfig();
expect(config).toEqual(DEFAULT_ELECTRON_DESKTOP_SHORTCUTS);
expect(config).toEqual(DEFAULT_SHORTCUTS_CONFIG);
});
});
@@ -345,11 +346,8 @@ describe('ShortcutManager', () => {
shortcutManager['loadShortcutsConfig']();
expect(shortcutManager['shortcutsConfig']).toEqual(DEFAULT_ELECTRON_DESKTOP_SHORTCUTS);
expect(mockStoreManager.set).toHaveBeenCalledWith(
'shortcuts',
DEFAULT_ELECTRON_DESKTOP_SHORTCUTS,
);
expect(shortcutManager['shortcutsConfig']).toEqual(DEFAULT_SHORTCUTS_CONFIG);
expect(mockStoreManager.set).toHaveBeenCalledWith('shortcuts', DEFAULT_SHORTCUTS_CONFIG);
});
it('should use defaults when config is empty', () => {
@@ -357,7 +355,7 @@ describe('ShortcutManager', () => {
shortcutManager['loadShortcutsConfig']();
expect(shortcutManager['shortcutsConfig']).toEqual(DEFAULT_ELECTRON_DESKTOP_SHORTCUTS);
expect(shortcutManager['shortcutsConfig']).toEqual(DEFAULT_SHORTCUTS_CONFIG);
});
it('should filter invalid keys from stored config', () => {
@@ -415,11 +413,8 @@ describe('ShortcutManager', () => {
shortcutManager['loadShortcutsConfig']();
expect(shortcutManager['shortcutsConfig']).toEqual(DEFAULT_ELECTRON_DESKTOP_SHORTCUTS);
expect(mockStoreManager.set).toHaveBeenCalledWith(
'shortcuts',
DEFAULT_ELECTRON_DESKTOP_SHORTCUTS,
);
expect(shortcutManager['shortcutsConfig']).toEqual(DEFAULT_SHORTCUTS_CONFIG);
expect(mockStoreManager.set).toHaveBeenCalledWith('shortcuts', DEFAULT_SHORTCUTS_CONFIG);
});
});
@@ -463,7 +458,7 @@ describe('ShortcutManager', () => {
expect(globalShortcut.register).toHaveBeenCalledWith('Ctrl+P', expect.any(Function));
});
it('should skip shortcuts not defined in default electron desktop shortcuts', () => {
it('should skip shortcuts not in DEFAULT_SHORTCUTS_CONFIG', () => {
shortcutManager['shortcutsConfig'] = {
showApp: 'Alt+E',
invalidKey: 'Ctrl+I',
-435
View File
@@ -1,435 +0,0 @@
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
@@ -1,3 +0,0 @@
export type { ACPClientCallbacks, ACPClientParams } from './client';
export { ACPClient } from './client';
export type * from './types';
-326
View File
@@ -1,326 +0,0 @@
/**
* 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;
}
@@ -1,97 +0,0 @@
import { chmod, mkdir, rename, symlink, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { app } from 'electron';
import { createLogger } from '@/utils/logger';
const logger = createLogger('modules:cliEmbedding');
/**
* Resolve the correct Electron binary path per platform.
* - AppImage: use APPIMAGE env var (the actual .AppImage file)
* - Others: app.getPath('exe')
*/
function resolveElectronBinary(): string {
if (process.platform === 'linux' && process.env.APPIMAGE) {
return process.env.APPIMAGE;
}
return app.getPath('exe');
}
/**
* Resolve the CLI script path inside packaged resources.
*/
function resolveCliScript(): string {
if (app.isPackaged) {
return path.join(process.resourcesPath, 'bin', 'lobe-cli.js');
}
// Dev mode: app.getAppPath() points to apps/desktop/, go up to apps/cli/
return path.join(app.getAppPath(), '..', 'cli', 'dist', 'index.js');
}
/**
* Get the user-writable bin directory for CLI wrapper.
*/
export function getCliWrapperDir(): string {
return path.join(app.getPath('userData'), 'bin');
}
/**
* Generate shell wrapper scripts that invoke the embedded CLI
* using Electron's Node.js runtime via ELECTRON_RUN_AS_NODE=1.
*
* Called on every app launch to keep paths up-to-date after auto-updates.
*/
export async function generateCliWrapper(): Promise<void> {
const electronBin = resolveElectronBinary();
const cliScript = resolveCliScript();
const wrapperDir = getCliWrapperDir();
await mkdir(wrapperDir, { recursive: true });
if (process.platform === 'win32') {
const content = [
'@echo off',
'set ELECTRON_RUN_AS_NODE=1',
`"${electronBin}" "${cliScript}" %*`,
].join('\r\n');
const cmdPath = path.join(wrapperDir, 'lobehub.cmd');
await atomicWrite(cmdPath, content);
// Create short aliases: lh.cmd, lobe.cmd (copies on Windows, symlinks unreliable)
for (const alias of ['lh.cmd', 'lobe.cmd']) {
await atomicWrite(path.join(wrapperDir, alias), content);
}
logger.info(`CLI wrapper generated: ${cmdPath}`);
} else {
const content = [
'#!/bin/sh',
`ELECTRON_RUN_AS_NODE=1 exec "${electronBin}" "${cliScript}" "$@"`,
].join('\n');
const wrapperPath = path.join(wrapperDir, 'lobehub');
await atomicWrite(wrapperPath, content);
await chmod(wrapperPath, 0o755);
// Create short aliases: lh, lobe → lobehub
for (const alias of ['lh', 'lobe']) {
const linkPath = path.join(wrapperDir, alias);
await unlink(linkPath).catch(() => {});
await symlink('lobehub', linkPath);
}
logger.info(`CLI wrapper generated: ${wrapperPath}`);
}
}
/**
* Atomic write: write to temp file then rename to avoid partial reads.
*/
async function atomicWrite(filePath: string, content: string): Promise<void> {
const tmpPath = `${filePath}.tmp.${process.pid}`;
await writeFile(tmpPath, content, 'utf8');
await rename(tmpPath, filePath);
}
@@ -1 +0,0 @@
export { generateCliWrapper, getCliWrapperDir } from './generateCliWrapper';
@@ -1,145 +0,0 @@
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,7 +6,6 @@
*/
export { browserAutomationDetectors } from './agentBrowserDetectors';
export { cliAgentDetectors } from './cliAgentDetectors';
export { astSearchDetectors, contentSearchDetectors } from './contentSearchDetectors';
export { fileSearchDetectors } from './fileSearchDetectors';
export { runtimeEnvironmentDetectors } from './runtimeEnvironmentDetectors';
@@ -63,82 +63,11 @@ export const pythonDetector: IToolDetector = {
priority: 3,
};
/**
* Bun runtime detector
*/
export const bunDetector: IToolDetector = createCommandDetector('bun', {
description: 'Bun - fast JavaScript runtime and package manager',
priority: 4,
});
/**
* Bunx package runner detector
*/
export const bunxDetector: IToolDetector = createCommandDetector('bunx', {
description: 'bunx - Bun package runner for executing npm packages',
priority: 5,
});
/**
* pnpm package manager detector
*/
export const pnpmDetector: IToolDetector = createCommandDetector('pnpm', {
description: 'pnpm - fast, disk space efficient package manager',
priority: 6,
});
/**
* uv Python package manager detector
*/
export const uvDetector: IToolDetector = createCommandDetector('uv', {
description: 'uv - extremely fast Python package manager',
priority: 7,
});
/**
* LobeHub CLI detector
* Tries lobehub, lobe, lh in order; validates via --help output containing "LobeHub"
*/
export const lobehubDetector: IToolDetector = {
description: 'LobeHub CLI - manage and connect to LobeHub services',
async detect(): Promise<ToolStatus> {
const commands = ['lobehub', 'lobe', 'lh'];
const whichCmd = platform() === 'win32' ? 'where' : 'which';
for (const cmd of commands) {
try {
const { stdout: pathOut } = await execPromise(`${whichCmd} ${cmd}`, { timeout: 3000 });
const toolPath = pathOut.trim().split('\n')[0];
// Validate it's actually LobeHub CLI by checking help output
const { stdout: helpOut } = await execPromise(`${cmd} --help`, { timeout: 3000 });
if (!helpOut.includes('LobeHub')) continue;
const { stdout: versionOut } = await execPromise(`${cmd} --version`, { timeout: 3000 });
const version = versionOut.trim().split('\n')[0];
return { available: true, path: toolPath, version };
} catch {
continue;
}
}
return { available: false };
},
name: 'lobehub',
priority: 0,
};
/**
* All runtime environment detectors
*/
export const runtimeEnvironmentDetectors: IToolDetector[] = [
lobehubDetector,
nodeDetector,
npmDetector,
pythonDetector,
bunDetector,
bunxDetector,
pnpmDetector,
uvDetector,
];

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