mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
Compare commits
244 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 282415b886 | |||
| c1174d3eb8 | |||
| 7c8f721d6d | |||
| d526b40b78 | |||
| 64fc6d4bbd | |||
| b9c4b87a90 | |||
| e3eef044ca | |||
| d47f4fec76 | |||
| 9088a074e2 | |||
| b95720d210 | |||
| 560ec57f75 | |||
| dbca232e35 | |||
| c879629439 | |||
| 1ecf7d2be8 | |||
| 8b5aaeebdf | |||
| 4787bed380 | |||
| 5f25efd54c | |||
| c85be1265f | |||
| 4f1d2d494f | |||
| 3b81a94d76 | |||
| a4d9967e60 | |||
| 6a40eb8a3b | |||
| a23e159ef3 | |||
| 1eb1fca7f2 | |||
| 4100f2f700 | |||
| 23f91d044c | |||
| 06ac87dc45 | |||
| 6d731dd116 | |||
| f804d0fc7c | |||
| b268f44f06 | |||
| 475622a4b9 | |||
| 7b40538486 | |||
| 5531ff7907 | |||
| 4f56868545 | |||
| dc1b43d86c | |||
| 4d7cbfea8e | |||
| e65e2c3628 | |||
| eebf9cb056 | |||
| 3e7ee1fbfc | |||
| 84eff30be1 | |||
| 50a1cc1ec2 | |||
| d49aba748e | |||
| 8a0c3cb36a | |||
| 26d1d6bbfb | |||
| c5ec0ef2a1 | |||
| 6d0c8d710a | |||
| e10265fadd | |||
| c68dfa00df | |||
| b6a47debfd | |||
| 147ff3976f | |||
| 034c7c203b | |||
| b0b6684294 | |||
| 36d2427947 | |||
| 4d15979fab | |||
| 53786e30b6 | |||
| 7300b53e99 | |||
| 6f3897a6e8 | |||
| a6e330cfa9 | |||
| accc173068 | |||
| 81ab8aa07b | |||
| 12ee7c9e9a | |||
| 8d8b60e4f9 | |||
| 19aedcdf56 | |||
| 3bb09e0ef9 | |||
| 13fc65faa2 | |||
| de8761cf29 | |||
| 4f2f0055e1 | |||
| 2290929255 | |||
| a2eab24536 | |||
| b279c108b6 | |||
| 7a6fd8e865 | |||
| 7d599a980f | |||
| 1206db7c12 | |||
| bd61b61843 | |||
| 0c49b0a039 | |||
| 1beb9d4eb6 | |||
| 021fd07deb | |||
| 33f729cd1a | |||
| 8b3c871d08 | |||
| bd8143c464 | |||
| 68762fc4ae | |||
| 1a58d530fb | |||
| ca01385666 | |||
| 5231bbbcac | |||
| 496b10f5c0 | |||
| 1800110748 | |||
| b068c427d4 | |||
| d5eec83a72 | |||
| 6c9cbb07ee | |||
| b92ee0ade5 | |||
| 3327b293d6 | |||
| d7e5d4645d | |||
| 918e4a8fa1 | |||
| f58015bb23 | |||
| e6244aaea6 | |||
| e9d43cb43f | |||
| 5b03f009ee | |||
| 25cf3bfafd | |||
| 3cb7206d90 | |||
| e364b9a516 | |||
| a7e3d198df | |||
| 14cd81b624 | |||
| bd345d35a8 | |||
| 40d0825d79 | |||
| ea725aca9e | |||
| dbdbe16da9 | |||
| 5cd4e390e3 | |||
| 5c17a0d652 | |||
| ec3dd471b1 | |||
| 1d7a0d6bd8 | |||
| 71df4aa473 | |||
| 48d14bfb7e | |||
| 74bcf41fe8 | |||
| 210f020092 | |||
| 306691b4d7 | |||
| f531c65fbb | |||
| 6d742388fa | |||
| aec2d30506 | |||
| eb086b8456 | |||
| 3dd91a04fa | |||
| 9264a9c66d | |||
| f9f7283fec | |||
| 25e851b359 | |||
| f2a95f9ae6 | |||
| 4e0bcf1c4d | |||
| bbcb3304dc | |||
| 3b316e3a4e | |||
| 251e12c7d1 | |||
| 3b13a1b6d4 | |||
| 126db9612f | |||
| dd7819b1be | |||
| 3415df3715 | |||
| 0dc8930750 | |||
| 9f2d7daa17 | |||
| 249483c3e1 | |||
| eb2731183f | |||
| d9c50b97f8 | |||
| 8b445a1dc3 | |||
| be99aaebd0 | |||
| f96edd56fb | |||
| 074de037cd | |||
| 297c884b88 | |||
| 04b32e3152 | |||
| bbd09d6785 | |||
| 6a2ca59592 | |||
| 8aeb47eda3 | |||
| da1bccfd20 | |||
| 03c7a3fd42 | |||
| be8903e707 | |||
| d8534c2966 | |||
| d25db6e6f8 | |||
| df6d8f19f8 | |||
| 8af28a778b | |||
| 6ecae1bbd1 | |||
| 60a59e89f6 | |||
| 7fd6d67fe3 | |||
| 453db9f165 | |||
| 19f90e3d9a | |||
| fee0fe5699 | |||
| 88246e5719 | |||
| aaefe6c0d2 | |||
| cbc9bfccaa | |||
| 3e056ad37a | |||
| 46bac5b540 | |||
| 57ed8f8541 | |||
| 132893549a | |||
| d717d5da20 | |||
| 58fa4f869d | |||
| 32e36e330a | |||
| ee8cab8305 | |||
| 393653e20c | |||
| 560f598789 | |||
| 993dfe1bb0 | |||
| 967302269e | |||
| 674c849254 | |||
| f327e377a6 | |||
| e7be5b1928 | |||
| b54a41968d | |||
| f39f5e9fd6 | |||
| 7be18092d3 | |||
| c60c02bcfe | |||
| ec3443d1db | |||
| e76ab1f990 | |||
| c59c066330 | |||
| 7097167613 | |||
| 2c2795e73a | |||
| 965fc929e1 | |||
| 491aba4dbd | |||
| 6402656ec7 | |||
| f6314cc673 | |||
| cded932f1a | |||
| e7c496352f | |||
| 296c6f3cb3 | |||
| 53d0ee9ca5 | |||
| 689d5a51e8 | |||
| 23eab8769b | |||
| 0e57fd9955 | |||
| 2f5a31fc99 | |||
| 143a15fdb9 | |||
| 9c08fa5cdf | |||
| 59d8d878a2 | |||
| 0439a29189 | |||
| 4a63ea3dcc | |||
| 91b2653c71 | |||
| 8c8e7dd992 | |||
| a9cd2f7301 | |||
| b6c66dbdd7 | |||
| 5e1738ad4b | |||
| 4dc3c4ea1d | |||
| bc9ae6b4e5 | |||
| 70091935ba | |||
| 50e373ad1c | |||
| 966f943175 | |||
| c7c2b56f3b | |||
| 841c1d2ef2 | |||
| 26449e522a | |||
| f4c4ba7db5 | |||
| 83f8f0319c | |||
| 197a0cc8f1 | |||
| 6b4046eb17 | |||
| 9e27bef8fa | |||
| 11318f8ab9 | |||
| aaff9af3b7 | |||
| feb50e7007 | |||
| dc9adf8f10 | |||
| 3d592ca70d | |||
| 8d0ac45476 | |||
| 953033355b | |||
| 48b5927024 | |||
| 6e86912e7f | |||
| 4576059f4f | |||
| 9e9ba3e6c3 | |||
| 46602be0b3 | |||
| 14b278fba8 | |||
| 53c5708c9f | |||
| edc8920703 | |||
| 926de076d9 | |||
| 9b7beca85e | |||
| 0724d8ca60 | |||
| 9f36fe95ac | |||
| 3f148005e4 | |||
| 4e60d87514 | |||
| d2a16d0714 | |||
| ac8a9ec0f8 |
@@ -0,0 +1,298 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,218 @@
|
||||
---
|
||||
name: cli-backend-testing
|
||||
description: >
|
||||
CLI + Backend integration testing workflow. Use when verifying backend API changes
|
||||
(TRPC routers, services, models) via the LobeHub CLI against a local dev server.
|
||||
Triggers on 'cli test', 'test with cli', 'verify with cli', 'local cli test',
|
||||
'backend test with cli', or when needing to validate server-side changes end-to-end.
|
||||
---
|
||||
|
||||
# CLI + Backend Integration Testing
|
||||
|
||||
Standard workflow for verifying backend changes using the LobeHub CLI (`lh`) against a local dev server.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Verifying TRPC router / service / model changes end-to-end
|
||||
- Testing new API fields or response structure changes
|
||||
- Validating CLI command output after backend modifications
|
||||
- Debugging data flow issues between server and CLI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Requirement | Details |
|
||||
| ------------ | ------------------------------------------------------------- |
|
||||
| Dev server | `localhost:3011` (Next.js) |
|
||||
| CLI source | `lobehub/apps/cli/` |
|
||||
| CLI dev mode | Uses `LOBEHUB_CLI_HOME=.lobehub-dev` for isolated credentials |
|
||||
| Auth | Device Code Flow login to local server |
|
||||
|
||||
## Quick Reference
|
||||
|
||||
All CLI dev commands run from `lobehub/apps/cli/`:
|
||||
|
||||
```bash
|
||||
# Shorthand for all commands below
|
||||
CLI="LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts"
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Ensure Dev Server is Running
|
||||
|
||||
Check if the dev server is already running:
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}' http://localhost:3011/ 2> /dev/null
|
||||
```
|
||||
|
||||
- **If reachable** (returns any HTTP status): server is running. Skip to Step 2.
|
||||
- **If unreachable**: start the server:
|
||||
|
||||
```bash
|
||||
# From cloud repo root
|
||||
pnpm run dev:next
|
||||
```
|
||||
|
||||
To **restart** (pick up server-side code changes):
|
||||
|
||||
```bash
|
||||
lsof -ti:3011 | xargs kill
|
||||
pnpm run dev:next
|
||||
```
|
||||
|
||||
**Important:** Server-side code changes in the submodule (`lobehub/src/server/`, `lobehub/packages/`) require a server restart. Next.js hot-reload may not pick up changes in submodule packages.
|
||||
|
||||
### Step 2: Check CLI Authentication
|
||||
|
||||
Check if dev credentials already exist:
|
||||
|
||||
```bash
|
||||
cat lobehub/apps/cli/.lobehub-dev/settings.json 2> /dev/null
|
||||
```
|
||||
|
||||
- **If file exists and contains `"serverUrl": "http://localhost:3011"`**: already authenticated. Skip to Step 3.
|
||||
- **If file missing or points to wrong server**: login is needed. Ask the user to run:
|
||||
|
||||
```bash
|
||||
! cd lobehub/apps/cli && LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts login --server http://localhost:3011
|
||||
```
|
||||
|
||||
> Login requires interactive browser authorization (OIDC Device Code Flow), so the user must run it themselves via `!` prefix. After login, credentials are saved to `lobehub/apps/cli/.lobehub-dev/` and persist across sessions.
|
||||
|
||||
### Step 3: Test with CLI Commands
|
||||
|
||||
CLI runs from source (`bun src/index.ts`), so CLI-side code changes take effect immediately without rebuilding.
|
||||
|
||||
```bash
|
||||
cd lobehub/apps/cli
|
||||
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts <command>
|
||||
```
|
||||
|
||||
### Step 4: Clean Up Test Data
|
||||
|
||||
Delete any test data created during verification:
|
||||
|
||||
```bash
|
||||
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts task delete < id > -y
|
||||
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts agent delete < id > -y
|
||||
```
|
||||
|
||||
## Common Testing Patterns
|
||||
|
||||
### Task System
|
||||
|
||||
```bash
|
||||
# List tasks
|
||||
$CLI task list
|
||||
|
||||
# Create test data with nesting
|
||||
$CLI task create -n "Root Task" -i "Test instruction"
|
||||
$CLI task create -n "Child Task" -i "Sub instruction" --parent T-1
|
||||
|
||||
# View task detail (tests getTaskDetail service)
|
||||
$CLI task view T-1
|
||||
|
||||
# View task tree
|
||||
$CLI task tree T-1
|
||||
|
||||
# Test lifecycle
|
||||
$CLI task edit T-1 --status running
|
||||
$CLI task comment T-1 -m "Test comment"
|
||||
|
||||
# Clean up
|
||||
$CLI task delete T-1 -y
|
||||
```
|
||||
|
||||
### Agent System
|
||||
|
||||
```bash
|
||||
# List agents
|
||||
$CLI agent list
|
||||
|
||||
# View agent detail
|
||||
$CLI agent view <agent-id>
|
||||
|
||||
# Run agent (tests agent execution pipeline)
|
||||
$CLI agent run <agent-id> -m "Test prompt"
|
||||
```
|
||||
|
||||
### Document & Knowledge Base
|
||||
|
||||
```bash
|
||||
# List documents
|
||||
$CLI doc list
|
||||
|
||||
# Create and view
|
||||
$CLI doc create -t "Test Doc" -c "Content here"
|
||||
$CLI doc view <doc-id>
|
||||
|
||||
# Knowledge base
|
||||
$CLI kb list
|
||||
$CLI kb tree <kb-id>
|
||||
```
|
||||
|
||||
### Model & Provider
|
||||
|
||||
```bash
|
||||
# List models and providers
|
||||
$CLI model list
|
||||
$CLI provider list
|
||||
|
||||
# Test provider connectivity
|
||||
$CLI provider test <provider-id>
|
||||
```
|
||||
|
||||
## Dev-Test Cycle
|
||||
|
||||
The standard cycle for backend development:
|
||||
|
||||
```
|
||||
1. Make code changes (service/model/router/type)
|
||||
|
|
||||
2. Run unit tests (fast feedback)
|
||||
bunx vitest run --silent='passed-only' '<test-file>'
|
||||
|
|
||||
3. Restart dev server (if server-side changes)
|
||||
lsof -ti:3011 | xargs kill && pnpm run dev:next
|
||||
|
|
||||
4. CLI verification (end-to-end)
|
||||
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts <command>
|
||||
|
|
||||
5. Clean up test data
|
||||
```
|
||||
|
||||
### When Server Restart is Needed
|
||||
|
||||
| Change Location | Restart? |
|
||||
| ----------------------------------------- | -------- |
|
||||
| `lobehub/src/server/` (routers, services) | Yes |
|
||||
| `lobehub/packages/database/` (models) | Yes |
|
||||
| `lobehub/packages/types/` | Yes |
|
||||
| `lobehub/packages/prompts/` | Yes |
|
||||
| `lobehub/apps/cli/` (CLI code) | No |
|
||||
| `src/` (cloud overrides) | Yes |
|
||||
|
||||
### When Server Restart is NOT Needed
|
||||
|
||||
CLI runs from source via `bun src/index.ts`, so any changes to `lobehub/apps/cli/src/` take effect immediately on next command invocation.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Solution |
|
||||
| --------------------------- | --------------------------------------------------------------------- |
|
||||
| `No authentication found` | Run `login --server http://localhost:3011` |
|
||||
| `UNAUTHORIZED` on API calls | Token expired; re-run login |
|
||||
| `ECONNREFUSED` | Dev server not running; start with `pnpm run dev:next` |
|
||||
| CLI shows old data/behavior | Server needs restart to pick up code changes |
|
||||
| `EADDRINUSE` on port 3011 | Server already running; kill with `lsof -ti:3011 \| xargs kill` |
|
||||
| Login opens wrong server | Must use `--server http://localhost:3011` flag (env var doesn't work) |
|
||||
|
||||
## Credential Isolation
|
||||
|
||||
| Mode | Credential Dir | Server |
|
||||
| ---------- | -------------------------------- | ----------------- |
|
||||
| Dev | `lobehub/apps/cli/.lobehub-dev/` | `localhost:3011` |
|
||||
| Production | `~/.lobehub/` | `app.lobehub.com` |
|
||||
|
||||
The two environments are completely isolated. Dev mode credentials are gitignored.
|
||||
@@ -20,9 +20,11 @@ This is NON-NEGOTIABLE. Skipping Linear comments is a workflow violation.
|
||||
## Workflow
|
||||
|
||||
1. **Retrieve issue details** before starting: `mcp__linear-server__get_issue`
|
||||
2. **Check for sub-issues**: Use `mcp__linear-server__list_issues` with `parentId` filter
|
||||
3. **Update issue status** when completing: `mcp__linear-server__update_issue`
|
||||
4. **Add completion comment** (REQUIRED): `mcp__linear-server__create_comment`
|
||||
2. **Read images**: If the issue description contains images, MUST use `mcp__linear-server__extract_images` to read image content for full context
|
||||
3. **Check for sub-issues**: Use `mcp__linear-server__list_issues` with `parentId` filter
|
||||
4. **Mark as In Progress**: When starting to plan or implement an issue, immediately update status to **"In Progress"** via `mcp__linear-server__update_issue`
|
||||
5. **Update issue status** when completing: `mcp__linear-server__update_issue`
|
||||
6. **Add completion comment** (REQUIRED): `mcp__linear-server__create_comment`
|
||||
|
||||
## Creating Issues
|
||||
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
---
|
||||
name: local-testing
|
||||
description: >
|
||||
Local app and bot testing. Uses agent-browser CLI for Electron/web app UI testing,
|
||||
and osascript (AppleScript) for controlling native macOS apps (WeChat, Discord, Telegram, Slack, Lark/飞书, QQ)
|
||||
to test bots. Triggers on 'local test', 'test in electron', 'test desktop', 'test bot',
|
||||
'bot test', 'test in discord', 'test in telegram', 'test in slack', 'test in weixin',
|
||||
'test in wechat', 'test in lark', 'test in feishu', 'test in qq',
|
||||
'manual test', 'osascript', or UI/bot verification tasks.
|
||||
---
|
||||
|
||||
# Local App & Bot Testing
|
||||
|
||||
Two approaches for local testing on macOS:
|
||||
|
||||
| Approach | Tool | Best For |
|
||||
| --------------------------- | ------------------- | ---------------------------------------------------- |
|
||||
| **agent-browser + CDP** | `agent-browser` CLI | Electron apps, web apps (DOM access, JS eval) |
|
||||
| **osascript (AppleScript)** | `osascript -e` | Native macOS apps (WeChat, Discord, Telegram, Slack) |
|
||||
|
||||
---
|
||||
|
||||
# Part 1: agent-browser (Electron / Web Apps)
|
||||
|
||||
Use `agent-browser` to automate Chromium-based apps via Chrome DevTools Protocol.
|
||||
|
||||
Install via `npm i -g agent-browser`, `brew install agent-browser`, or `cargo install agent-browser`. Run `agent-browser install` to download Chrome. Run `agent-browser upgrade` to update.
|
||||
|
||||
## Core Workflow
|
||||
|
||||
Every browser automation follows this pattern:
|
||||
|
||||
1. **Navigate**: `agent-browser open <url>`
|
||||
2. **Snapshot**: `agent-browser snapshot -i` (get element refs like `@e1`, `@e2`)
|
||||
3. **Interact**: Use refs to click, fill, select
|
||||
4. **Re-snapshot**: After navigation or DOM changes, get fresh refs
|
||||
|
||||
```bash
|
||||
agent-browser open https://example.com/form
|
||||
agent-browser snapshot -i
|
||||
# Output: @e1 [input type="email"], @e2 [input type="password"], @e3 [button] "Submit"
|
||||
|
||||
agent-browser fill @e1 "user@example.com"
|
||||
agent-browser fill @e2 "password123"
|
||||
agent-browser click @e3
|
||||
agent-browser wait --load networkidle
|
||||
agent-browser snapshot -i # Check result
|
||||
```
|
||||
|
||||
## Command Chaining
|
||||
|
||||
```bash
|
||||
# Chain open + wait + snapshot in one call
|
||||
agent-browser open https://example.com && agent-browser wait --load networkidle && agent-browser snapshot -i
|
||||
```
|
||||
|
||||
Use `&&` when you don't need to read intermediate output. Run commands separately when you need to parse output first (e.g., snapshot to discover refs, then interact).
|
||||
|
||||
## Essential Commands
|
||||
|
||||
```bash
|
||||
# Navigation
|
||||
agent-browser open <url> # Navigate (aliases: goto, navigate)
|
||||
agent-browser close # Close browser
|
||||
agent-browser close --all # Close all active sessions
|
||||
|
||||
# Snapshot
|
||||
agent-browser snapshot -i # Interactive elements with refs (recommended)
|
||||
agent-browser snapshot -s "#selector" # Scope to CSS selector
|
||||
|
||||
# Interaction (use @refs from snapshot)
|
||||
agent-browser click @e1 # Click element
|
||||
agent-browser click @e1 --new-tab # Click and open in new tab
|
||||
agent-browser fill @e2 "text" # Clear and type text
|
||||
agent-browser type @e2 "text" # Type without clearing
|
||||
agent-browser select @e1 "option" # Select dropdown option
|
||||
agent-browser check @e1 # Check checkbox
|
||||
agent-browser press Enter # Press key
|
||||
agent-browser keyboard type "text" # Type at current focus (no selector)
|
||||
agent-browser keyboard inserttext "text" # Insert without key events
|
||||
agent-browser scroll down 500 # Scroll page
|
||||
agent-browser scroll down 500 --selector "div.content" # Scroll within container
|
||||
|
||||
# Get information
|
||||
agent-browser get text @e1 # Get element text
|
||||
agent-browser get url # Get current URL
|
||||
agent-browser get title # Get page title
|
||||
agent-browser get cdp-url # Get CDP WebSocket URL
|
||||
|
||||
# Wait
|
||||
agent-browser wait @e1 # Wait for element
|
||||
agent-browser wait --load networkidle # Wait for network idle
|
||||
agent-browser wait --url "**/page" # Wait for URL pattern
|
||||
agent-browser wait 2000 # Wait milliseconds
|
||||
agent-browser wait --text "Welcome" # Wait for text to appear
|
||||
agent-browser wait --fn "!document.body.innerText.includes('Loading...')" # Wait for text to disappear
|
||||
agent-browser wait "#spinner" --state hidden # Wait for element to disappear
|
||||
|
||||
# Downloads
|
||||
agent-browser download @e1 ./file.pdf # Click element to trigger download
|
||||
agent-browser wait --download ./output.zip # Wait for any download to complete
|
||||
|
||||
# Network
|
||||
agent-browser network requests # Inspect tracked requests
|
||||
agent-browser network requests --type xhr,fetch # Filter by resource type
|
||||
agent-browser network requests --method POST # Filter by HTTP method
|
||||
agent-browser network route "**/api/*" --abort # Block matching requests
|
||||
agent-browser network har start # Start HAR recording
|
||||
agent-browser network har stop ./capture.har # Stop and save HAR file
|
||||
|
||||
# Viewport & Device Emulation
|
||||
agent-browser set viewport 1920 1080 # Set viewport size (default: 1280x720)
|
||||
agent-browser set viewport 1920 1080 2 # 2x retina
|
||||
agent-browser set device "iPhone 14" # Emulate device (viewport + user agent)
|
||||
|
||||
# Capture
|
||||
agent-browser screenshot # Screenshot to temp dir
|
||||
agent-browser screenshot --full # Full page screenshot
|
||||
agent-browser screenshot --annotate # Annotated screenshot with numbered element labels
|
||||
agent-browser pdf output.pdf # Save as PDF
|
||||
|
||||
# Clipboard
|
||||
agent-browser clipboard read # Read text from clipboard
|
||||
agent-browser clipboard write "text" # Write text to clipboard
|
||||
agent-browser clipboard copy # Copy current selection
|
||||
agent-browser clipboard paste # Paste from clipboard
|
||||
|
||||
# Dialogs (alert, confirm, prompt, beforeunload)
|
||||
agent-browser dialog accept # Accept dialog
|
||||
agent-browser dialog accept "input" # Accept prompt dialog with text
|
||||
agent-browser dialog dismiss # Dismiss/cancel dialog
|
||||
agent-browser dialog status # Check if dialog is open
|
||||
|
||||
# Diff (compare page states)
|
||||
agent-browser diff snapshot # Compare current vs last snapshot
|
||||
agent-browser diff screenshot --baseline before.png # Visual pixel diff
|
||||
agent-browser diff url <url1> <url2> # Compare two pages
|
||||
|
||||
# Streaming
|
||||
agent-browser stream enable # Start WebSocket streaming
|
||||
agent-browser stream status # Inspect streaming state
|
||||
agent-browser stream disable # Stop streaming
|
||||
```
|
||||
|
||||
## Batch Execution
|
||||
|
||||
```bash
|
||||
echo '[
|
||||
["open", "https://example.com"],
|
||||
["snapshot", "-i"],
|
||||
["click", "@e1"],
|
||||
["screenshot", "result.png"]
|
||||
]' | agent-browser batch --json
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
```bash
|
||||
# Option 1: Auth vault (credentials stored encrypted)
|
||||
echo "$PASSWORD" | agent-browser auth save myapp --url https://app.example.com/login --username user --password-stdin
|
||||
agent-browser auth login myapp
|
||||
|
||||
# Option 2: Session name (auto-save/restore cookies + localStorage)
|
||||
agent-browser --session-name myapp open https://app.example.com/login
|
||||
agent-browser close # State auto-saved
|
||||
agent-browser --session-name myapp open https://app.example.com/dashboard # Auto-restored
|
||||
|
||||
# Option 3: Persistent profile
|
||||
agent-browser --profile ~/.myapp open https://app.example.com/login
|
||||
|
||||
# Option 4: State file
|
||||
agent-browser state save auth.json
|
||||
agent-browser state load auth.json
|
||||
```
|
||||
|
||||
## Semantic Locators (Alternative to Refs)
|
||||
|
||||
```bash
|
||||
agent-browser find text "Sign In" click
|
||||
agent-browser find label "Email" fill "user@test.com"
|
||||
agent-browser find role button click --name "Submit"
|
||||
agent-browser find placeholder "Search" type "query"
|
||||
agent-browser find testid "submit-btn" click
|
||||
```
|
||||
|
||||
## JavaScript Evaluation (eval)
|
||||
|
||||
```bash
|
||||
# Simple expressions
|
||||
agent-browser eval 'document.title'
|
||||
|
||||
# Complex JS: use --stdin with heredoc (RECOMMENDED)
|
||||
agent-browser eval --stdin << 'EVALEOF'
|
||||
JSON.stringify(
|
||||
Array.from(document.querySelectorAll("img"))
|
||||
.filter(i => !i.alt)
|
||||
.map(i => ({ src: i.src.split("/").pop(), width: i.width }))
|
||||
)
|
||||
EVALEOF
|
||||
|
||||
# Base64 encoding (avoids all shell escaping issues)
|
||||
agent-browser eval -b "$(echo -n 'document.title' | base64)"
|
||||
```
|
||||
|
||||
## Ref Lifecycle
|
||||
|
||||
Refs (`@e1`, `@e2`, etc.) are invalidated when the page changes. Always re-snapshot after clicking links/buttons that navigate, form submissions, or dynamic content loading.
|
||||
|
||||
## Annotated Screenshots (Vision Mode)
|
||||
|
||||
```bash
|
||||
agent-browser screenshot --annotate
|
||||
# Output includes the image path and a legend:
|
||||
# [1] @e1 button "Submit"
|
||||
# [2] @e2 link "Home"
|
||||
agent-browser click @e2 # Click using ref from annotated screenshot
|
||||
```
|
||||
|
||||
## Parallel Sessions
|
||||
|
||||
```bash
|
||||
agent-browser --session site1 open https://site-a.com
|
||||
agent-browser --session site2 open https://site-b.com
|
||||
agent-browser session list
|
||||
```
|
||||
|
||||
## Connect to Existing Chrome
|
||||
|
||||
```bash
|
||||
agent-browser --auto-connect snapshot # Auto-discover running Chrome
|
||||
agent-browser --cdp 9222 snapshot # Explicit CDP port
|
||||
```
|
||||
|
||||
## iOS Simulator (Mobile Safari)
|
||||
|
||||
```bash
|
||||
agent-browser device list
|
||||
agent-browser -p ios --device "iPhone 16 Pro" open https://example.com
|
||||
agent-browser -p ios snapshot -i
|
||||
agent-browser -p ios tap @e1
|
||||
agent-browser -p ios swipe up
|
||||
agent-browser -p ios screenshot mobile.png
|
||||
agent-browser -p ios close
|
||||
```
|
||||
|
||||
## Observability Dashboard
|
||||
|
||||
```bash
|
||||
agent-browser dashboard install
|
||||
agent-browser dashboard start # Background server on port 4848
|
||||
agent-browser dashboard stop
|
||||
```
|
||||
|
||||
## Cloud Providers
|
||||
|
||||
Use `-p <provider>` to run against cloud browsers: `agentcore`, `browserbase`, `browserless`, `browseruse`, `kernel`.
|
||||
|
||||
## Browser Engine Selection
|
||||
|
||||
```bash
|
||||
agent-browser --engine lightpanda open example.com # 10x faster, 10x less memory
|
||||
```
|
||||
|
||||
## Electron (LobeHub Desktop)
|
||||
|
||||
### Setup / Teardown
|
||||
|
||||
Use the `electron-dev.sh` script to manage the Electron dev environment. It handles process lifecycle, waits for SPA readiness, and reliably kills all child processes (main + helpers + vite).
|
||||
|
||||
```bash
|
||||
SCRIPT=".agents/skills/local-testing/scripts/electron-dev.sh"
|
||||
|
||||
# Start Electron dev with CDP (idempotent — skips if already running)
|
||||
$SCRIPT start
|
||||
|
||||
# Check if Electron is running and CDP is reachable
|
||||
$SCRIPT status
|
||||
|
||||
# Kill all Electron-related processes (main + helper + vite)
|
||||
$SCRIPT stop
|
||||
|
||||
# Force fresh restart
|
||||
$SCRIPT restart
|
||||
```
|
||||
|
||||
After `start` succeeds, connect with: `agent-browser --cdp 9222 snapshot -i`
|
||||
|
||||
**Always run `$SCRIPT stop` when done testing** — `pkill -f "Electron"` alone won't catch all helper processes.
|
||||
|
||||
#### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ----------------- | ----------------------- | ---------------------------------------- |
|
||||
| `CDP_PORT` | `9222` | Chrome DevTools Protocol port |
|
||||
| `ELECTRON_LOG` | `/tmp/electron-dev.log` | Electron process log |
|
||||
| `ELECTRON_WAIT_S` | `60` | Max seconds to wait for Electron process |
|
||||
| `RENDERER_WAIT_S` | `60` | Max seconds to wait for SPA to load |
|
||||
|
||||
### LobeHub-Specific Patterns
|
||||
|
||||
#### Access Zustand Store State
|
||||
|
||||
```bash
|
||||
agent-browser --cdp 9222 eval --stdin << 'EVALEOF'
|
||||
(function() {
|
||||
var chat = window.__LOBE_STORES.chat();
|
||||
var ops = Object.values(chat.operations);
|
||||
return JSON.stringify({
|
||||
ops: ops.map(function(o) { return { type: o.type, status: o.status }; }),
|
||||
activeAgent: chat.activeAgentId,
|
||||
activeTopic: chat.activeTopicId,
|
||||
});
|
||||
})()
|
||||
EVALEOF
|
||||
```
|
||||
|
||||
#### Find and Use the Chat Input
|
||||
|
||||
```bash
|
||||
# The chat input is contenteditable — must use -C flag
|
||||
agent-browser --cdp 9222 snapshot -i -C 2>&1 | grep "editable"
|
||||
|
||||
agent-browser --cdp 9222 click @e48
|
||||
agent-browser --cdp 9222 type @e48 "Hello world"
|
||||
agent-browser --cdp 9222 press Enter
|
||||
```
|
||||
|
||||
#### Wait for Agent to Complete
|
||||
|
||||
```bash
|
||||
agent-browser --cdp 9222 eval --stdin << 'EVALEOF'
|
||||
(function() {
|
||||
var chat = window.__LOBE_STORES.chat();
|
||||
var ops = Object.values(chat.operations);
|
||||
var running = ops.filter(function(o) { return o.status === 'running'; });
|
||||
return running.length === 0 ? 'done' : 'running: ' + running.length;
|
||||
})()
|
||||
EVALEOF
|
||||
```
|
||||
|
||||
#### Install Error Interceptor
|
||||
|
||||
```bash
|
||||
agent-browser --cdp 9222 eval --stdin << 'EVALEOF'
|
||||
(function() {
|
||||
window.__CAPTURED_ERRORS = [];
|
||||
var orig = console.error;
|
||||
console.error = function() {
|
||||
var msg = Array.from(arguments).map(function(a) {
|
||||
if (a instanceof Error) return a.message;
|
||||
return typeof a === 'object' ? JSON.stringify(a) : String(a);
|
||||
}).join(' ');
|
||||
window.__CAPTURED_ERRORS.push(msg);
|
||||
orig.apply(console, arguments);
|
||||
};
|
||||
return 'installed';
|
||||
})()
|
||||
EVALEOF
|
||||
|
||||
# Later, check captured errors:
|
||||
agent-browser --cdp 9222 eval "JSON.stringify(window.__CAPTURED_ERRORS)"
|
||||
```
|
||||
|
||||
## Chrome / Web Apps
|
||||
|
||||
```bash
|
||||
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
|
||||
--remote-debugging-port=9222 \
|
||||
--user-data-dir=/tmp/chrome-test-profile \
|
||||
"<URL>" &
|
||||
sleep 5
|
||||
agent-browser --cdp 9222 snapshot -i
|
||||
|
||||
# Or auto-discover running Chrome with remote debugging
|
||||
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.
|
||||
|
||||
The pattern is the same for every platform:
|
||||
|
||||
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)
|
||||
|
||||
## Per-Platform References
|
||||
|
||||
Pick the file for your target platform — each contains activation, navigation, send-message, and verification snippets specific to that app:
|
||||
|
||||
| Platform | Reference | Quick switcher |
|
||||
| ------------- | ------------------------------------------------ | -------------- |
|
||||
| Discord | [reference/discord.md](./reference/discord.md) | `Cmd+K` |
|
||||
| Slack | [reference/slack.md](./reference/slack.md) | `Cmd+K` |
|
||||
| Telegram | [reference/telegram.md](./reference/telegram.md) | `Cmd+F` |
|
||||
| WeChat / 微信 | [reference/wechat.md](./reference/wechat.md) | `Cmd+F` |
|
||||
| Lark / 飞书 | [reference/lark.md](./reference/lark.md) | `Cmd+K` |
|
||||
| QQ | [reference/qq.md](./reference/qq.md) | `Cmd+F` |
|
||||
|
||||
For **shared osascript patterns** (activate, type, paste, screenshot, read accessibility, common workflow template, gotchas), see [reference/osascript-common.md](./reference/osascript-common.md). Read this first if you're new to osascript automation.
|
||||
|
||||
---
|
||||
|
||||
# Scripts
|
||||
|
||||
Ready-to-use scripts in `.agents/skills/local-testing/scripts/`:
|
||||
|
||||
| Script | Usage |
|
||||
| ------------------------- | --------------------------------------------------- |
|
||||
| `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 |
|
||||
| `test-wechat-bot.sh` | Send message to WeChat bot via osascript |
|
||||
| `test-lark-bot.sh` | Send message to Lark / 飞书 bot via osascript |
|
||||
| `test-qq-bot.sh` | Send message to QQ bot via osascript |
|
||||
|
||||
### Window Screenshot Utility
|
||||
|
||||
`capture-app-window.sh` captures a screenshot of a specific app window using `screencapture -l <windowID>`. It uses Swift + CGWindowList to find the window by process name, so screenshots work correctly even when the window is on an external monitor or behind other windows.
|
||||
|
||||
```bash
|
||||
# Standalone usage
|
||||
./.agents/skills/local-testing/scripts/capture-app-window.sh "Discord" /tmp/discord.png
|
||||
./.agents/skills/local-testing/scripts/capture-app-window.sh "Slack" /tmp/slack.png
|
||||
./.agents/skills/local-testing/scripts/capture-app-window.sh "WeChat" /tmp/wechat.png
|
||||
```
|
||||
|
||||
All bot test scripts use this utility automatically for their screenshots.
|
||||
|
||||
### Bot Test Scripts
|
||||
|
||||
All bot test scripts share the same interface:
|
||||
|
||||
```bash
|
||||
./scripts/test-<platform>-bot.sh <channel_or_contact> <message> [wait_seconds] [screenshot_path]
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
# Discord — test a bot in #bot-testing channel
|
||||
./.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
|
||||
|
||||
# Slack — test a bot in #bot-testing channel
|
||||
./.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
|
||||
|
||||
# Telegram — test a bot by username
|
||||
./.agents/skills/local-testing/scripts/test-telegram-bot.sh "MyTestBot" "/start"
|
||||
./.agents/skills/local-testing/scripts/test-telegram-bot.sh "GPTBot" "Hello" 60
|
||||
|
||||
# WeChat — test a bot or send to a contact
|
||||
./.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
|
||||
|
||||
# Lark/飞书 — test a bot in a group chat
|
||||
./.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
|
||||
|
||||
# QQ — test a bot in a group or direct chat
|
||||
./.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
|
||||
```
|
||||
|
||||
Each script: activates the app, navigates to the channel/contact, pastes the message via clipboard, sends, waits, and takes a screenshot. Use the `Read` tool on the screenshot for visual verification.
|
||||
|
||||
---
|
||||
|
||||
# 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.
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
Outputs to `.records/` directory (gitignored): `<name>.mp4` (video) + `<name>/` (screenshots every 3s).
|
||||
|
||||
---
|
||||
|
||||
# Gotchas
|
||||
|
||||
### agent-browser
|
||||
|
||||
- **Daemon can get stuck** — if commands hang, `agent-browser close --all` or `pkill -f agent-browser` to reset
|
||||
- **HMR invalidates everything** — after code changes, refs break. Re-snapshot or restart
|
||||
- **`snapshot -i` doesn't find contenteditable** — use `snapshot -i -C` for rich text editors
|
||||
- **`fill` doesn't work on contenteditable** — use `type` for chat inputs
|
||||
- **Screenshots go to `~/.agent-browser/tmp/screenshots/`** — read them with the `Read` tool
|
||||
- **Dialogs block all commands** — if commands time out, check `agent-browser dialog status`
|
||||
- **Default timeout is 25s** — override with `AGENT_BROWSER_DEFAULT_TIMEOUT` (ms) or use explicit waits
|
||||
- **Shell quoting corrupts eval** — use `eval --stdin <<'EVALEOF'` for complex JS
|
||||
|
||||
### Electron-specific
|
||||
|
||||
- **Always use `electron-dev.sh stop` to clean up** — `pkill -f "Electron"` only kills the main process; helper processes (GPU, renderer, network) survive. The script finds and kills all of them via PID matching against the project's electron binary path.
|
||||
- **`npx electron-vite dev` must run from `apps/desktop/`** — running from project root fails silently. The `electron-dev.sh` script handles this automatically.
|
||||
- **Don't resize the Electron window after load** — resizing triggers full SPA reload
|
||||
- **Store is at `window.__LOBE_STORES`** not `window.__ZUSTAND_STORES__`
|
||||
|
||||
### osascript
|
||||
|
||||
See [reference/osascript-common.md](./reference/osascript-common.md#gotchas) for the full osascript gotchas list (accessibility permissions, `keystroke` non-ASCII issues, locale-specific app names, rate limiting, etc.).
|
||||
@@ -0,0 +1,97 @@
|
||||
# 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
|
||||
```
|
||||
@@ -0,0 +1,61 @@
|
||||
# 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
|
||||
```
|
||||
@@ -0,0 +1,217 @@
|
||||
# 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
|
||||
@@ -0,0 +1,62 @@
|
||||
# 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
|
||||
```
|
||||
@@ -0,0 +1,73 @@
|
||||
# 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
|
||||
```
|
||||
@@ -0,0 +1,80 @@
|
||||
# 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
|
||||
```
|
||||
@@ -0,0 +1,81 @@
|
||||
# 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
|
||||
```
|
||||
@@ -0,0 +1,142 @@
|
||||
# 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 |
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# capture-app-window.sh — Capture a screenshot of a specific app window
|
||||
#
|
||||
# Uses CGWindowList via Swift to find the window by process name, then
|
||||
# screencapture -l <windowID> to capture only that window.
|
||||
# Falls back to full-screen capture if the window is not found.
|
||||
#
|
||||
# Usage:
|
||||
# ./capture-app-window.sh <process_name> <output_path>
|
||||
#
|
||||
# Arguments:
|
||||
# process_name — The process/owner name as shown in Activity Monitor
|
||||
# (e.g., "Discord", "Slack", "Telegram", "WeChat", "QQ", "Lark")
|
||||
# output_path — Path to save the screenshot (e.g., /tmp/screenshot.png)
|
||||
#
|
||||
# Examples:
|
||||
# ./capture-app-window.sh "Discord" /tmp/discord.png
|
||||
# ./capture-app-window.sh "Slack" /tmp/slack.png
|
||||
# ./capture-app-window.sh "微信" /tmp/wechat.png
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
PROCESS="${1:?Usage: capture-app-window.sh <process_name> <output_path>}"
|
||||
OUTPUT="${2:?Usage: capture-app-window.sh <process_name> <output_path>}"
|
||||
|
||||
# Find the CGWindowID for the target process using Swift + CGWindowList
|
||||
# Pass process name via environment variable (swift -e doesn't support -- args)
|
||||
WINDOW_ID=$(TARGET_PROCESS="$PROCESS" swift -e '
|
||||
import Cocoa
|
||||
import Foundation
|
||||
let target = ProcessInfo.processInfo.environment["TARGET_PROCESS"] ?? ""
|
||||
let windowList = CGWindowListCopyWindowInfo([.optionAll], kCGNullWindowID) as! [[String: Any]]
|
||||
for w in windowList {
|
||||
let owner = w["kCGWindowOwnerName"] as? String ?? ""
|
||||
let layer = w["kCGWindowLayer"] as? Int ?? -1
|
||||
let bounds = w["kCGWindowBounds"] as? [String: Any] ?? [:]
|
||||
let ww = bounds["Width"] as? Double ?? 0
|
||||
let wh = bounds["Height"] as? Double ?? 0
|
||||
let wid = w["kCGWindowNumber"] as? Int ?? 0
|
||||
// Match process name, normal window layer (0), and reasonable size
|
||||
if owner == target && layer == 0 && ww > 200 && wh > 200 {
|
||||
print(wid)
|
||||
break
|
||||
}
|
||||
}
|
||||
' 2>/dev/null || true)
|
||||
|
||||
if [ -n "$WINDOW_ID" ]; then
|
||||
screencapture -l "$WINDOW_ID" -x "$OUTPUT"
|
||||
else
|
||||
echo "[capture] Warning: Could not find window for '$PROCESS', falling back to full screen"
|
||||
screencapture -x "$OUTPUT"
|
||||
fi
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# electron-dev.sh — Manage Electron dev environment for testing
|
||||
#
|
||||
# Usage:
|
||||
# ./electron-dev.sh start # Kill existing, start fresh, wait until ready
|
||||
# ./electron-dev.sh stop # Kill all Electron-related processes
|
||||
# ./electron-dev.sh status # Check if Electron is running and CDP is reachable
|
||||
# ./electron-dev.sh restart # Stop then start
|
||||
#
|
||||
# Environment variables:
|
||||
# CDP_PORT — Chrome DevTools Protocol port (default: 9222)
|
||||
# ELECTRON_LOG — Log file path (default: /tmp/electron-dev.log)
|
||||
# ELECTRON_WAIT_S — Max seconds to wait for Electron process (default: 60)
|
||||
# RENDERER_WAIT_S — Max seconds to wait for renderer/SPA (default: 60)
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
CDP_PORT="${CDP_PORT:-9222}"
|
||||
ELECTRON_LOG="${ELECTRON_LOG:-/tmp/electron-dev.log}"
|
||||
ELECTRON_WAIT_S="${ELECTRON_WAIT_S:-60}"
|
||||
RENDERER_WAIT_S="${RENDERER_WAIT_S:-60}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
|
||||
PIDFILE="/tmp/electron-dev-cdp-${CDP_PORT}.pid"
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
# Get the Electron binary path used by this project
|
||||
electron_bin_pattern() {
|
||||
echo "${PROJECT_ROOT}/apps/desktop/node_modules/.pnpm/electron@*/node_modules/electron/dist/Electron.app"
|
||||
}
|
||||
|
||||
# Find all PIDs related to the project's Electron dev session
|
||||
find_electron_pids() {
|
||||
local pids=""
|
||||
|
||||
# 1. Main Electron process (launched with --remote-debugging-port)
|
||||
local main_pids
|
||||
main_pids=$(pgrep -f "Electron\.app.*--remote-debugging-port=${CDP_PORT}" 2>/dev/null || true)
|
||||
[ -n "$main_pids" ] && pids="$pids $main_pids"
|
||||
|
||||
# 2. Electron Helper processes (gpu, renderer, utility) spawned from the project's electron binary
|
||||
local helper_pids
|
||||
helper_pids=$(pgrep -f "${PROJECT_ROOT}/apps/desktop/node_modules/.*Electron Helper" 2>/dev/null || true)
|
||||
[ -n "$helper_pids" ] && pids="$pids $helper_pids"
|
||||
|
||||
# 3. electron-vite dev server
|
||||
local vite_pids
|
||||
vite_pids=$(pgrep -f "electron-vite.*dev" 2>/dev/null || true)
|
||||
[ -n "$vite_pids" ] && pids="$pids $vite_pids"
|
||||
|
||||
# 4. PID from pidfile (fallback)
|
||||
if [ -f "$PIDFILE" ]; then
|
||||
local saved_pid
|
||||
saved_pid=$(cat "$PIDFILE")
|
||||
if kill -0 "$saved_pid" 2>/dev/null; then
|
||||
pids="$pids $saved_pid"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Deduplicate
|
||||
echo "$pids" | tr ' ' '\n' | sort -u | grep -v '^$' | tr '\n' ' ' || true
|
||||
}
|
||||
|
||||
do_stop() {
|
||||
echo "[electron-dev] Stopping Electron dev environment..."
|
||||
|
||||
local pids
|
||||
pids=$(find_electron_pids)
|
||||
|
||||
if [ -z "$pids" ]; then
|
||||
echo "[electron-dev] No Electron processes found."
|
||||
else
|
||||
echo "[electron-dev] Killing PIDs: $pids"
|
||||
for pid in $pids; do
|
||||
kill "$pid" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# Wait up to 5s for graceful exit, then force-kill survivors
|
||||
local waited=0
|
||||
while [ $waited -lt 5 ]; do
|
||||
local alive=""
|
||||
for pid in $pids; do
|
||||
kill -0 "$pid" 2>/dev/null && alive="$alive $pid"
|
||||
done
|
||||
[ -z "$alive" ] && break
|
||||
sleep 1
|
||||
waited=$((waited + 1))
|
||||
done
|
||||
|
||||
# Force-kill any remaining
|
||||
for pid in $pids; do
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
echo "[electron-dev] Force-killing PID $pid"
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Also close any agent-browser sessions connected to this port
|
||||
agent-browser --cdp "$CDP_PORT" close --all 2>/dev/null || true
|
||||
|
||||
rm -f "$PIDFILE"
|
||||
echo "[electron-dev] Stopped."
|
||||
}
|
||||
|
||||
do_status() {
|
||||
local pids
|
||||
pids=$(find_electron_pids)
|
||||
|
||||
if [ -z "$pids" ]; then
|
||||
echo "[electron-dev] Electron is NOT running."
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "[electron-dev] Electron is running (PIDs: $pids)"
|
||||
|
||||
# Check CDP connectivity
|
||||
if agent-browser --cdp "$CDP_PORT" get url >/dev/null 2>&1; then
|
||||
local url
|
||||
url=$(agent-browser --cdp "$CDP_PORT" get url 2>&1 | tail -1)
|
||||
echo "[electron-dev] CDP port ${CDP_PORT} is reachable. URL: $url"
|
||||
return 0
|
||||
else
|
||||
echo "[electron-dev] CDP port ${CDP_PORT} is NOT reachable (Electron may still be loading)."
|
||||
return 2
|
||||
fi
|
||||
}
|
||||
|
||||
wait_for_electron() {
|
||||
echo "[electron-dev] Waiting for Electron process (up to ${ELECTRON_WAIT_S}s)..."
|
||||
local elapsed=0
|
||||
local interval=3
|
||||
while [ $elapsed -lt "$ELECTRON_WAIT_S" ]; do
|
||||
if strings "$ELECTRON_LOG" 2>/dev/null | grep -q "starting electron"; then
|
||||
echo "[electron-dev] Electron process started."
|
||||
return 0
|
||||
fi
|
||||
sleep "$interval"
|
||||
elapsed=$((elapsed + interval))
|
||||
echo "[electron-dev] Still waiting... (${elapsed}/${ELECTRON_WAIT_S}s)"
|
||||
done
|
||||
echo "[electron-dev] ERROR: Electron did not start within ${ELECTRON_WAIT_S}s"
|
||||
echo "[electron-dev] Last 20 lines of log:"
|
||||
tail -20 "$ELECTRON_LOG" 2>/dev/null || true
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_renderer() {
|
||||
echo "[electron-dev] Waiting for renderer/SPA to load (up to ${RENDERER_WAIT_S}s)..."
|
||||
|
||||
# Initial delay — renderer needs time to bootstrap
|
||||
sleep 10
|
||||
|
||||
local elapsed=10
|
||||
local interval=5
|
||||
while [ $elapsed -lt "$RENDERER_WAIT_S" ]; do
|
||||
if agent-browser --cdp "$CDP_PORT" wait 2000 >/dev/null 2>&1; then
|
||||
# Check if interactive elements are present (SPA loaded)
|
||||
local snap
|
||||
snap=$(agent-browser --cdp "$CDP_PORT" snapshot -i 2>&1 || true)
|
||||
if echo "$snap" | grep -qE 'link |button '; then
|
||||
echo "[electron-dev] Renderer ready (interactive elements found)."
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
sleep "$interval"
|
||||
elapsed=$((elapsed + interval))
|
||||
echo "[electron-dev] SPA still loading... (${elapsed}/${RENDERER_WAIT_S}s)"
|
||||
done
|
||||
|
||||
echo "[electron-dev] WARNING: Timed out waiting for renderer, proceeding anyway."
|
||||
return 0
|
||||
}
|
||||
|
||||
do_start() {
|
||||
# If already running and healthy, skip
|
||||
local status_ok=0
|
||||
do_status >/dev/null 2>&1 || status_ok=$?
|
||||
if [ "$status_ok" -eq 0 ]; then
|
||||
echo "[electron-dev] Electron is already running and CDP is reachable. Skipping start."
|
||||
echo "[electron-dev] Use 'restart' to force a fresh session, or 'stop' to tear down."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Clean up any stale processes
|
||||
do_stop
|
||||
|
||||
# Start fresh
|
||||
echo "[electron-dev] Starting Electron dev server..."
|
||||
echo "[electron-dev] Project: $PROJECT_ROOT"
|
||||
echo "[electron-dev] CDP port: $CDP_PORT"
|
||||
echo "[electron-dev] Log: $ELECTRON_LOG"
|
||||
|
||||
: > "$ELECTRON_LOG" # Truncate log
|
||||
|
||||
(
|
||||
cd "$PROJECT_ROOT/apps/desktop" && \
|
||||
ELECTRON_ENABLE_LOGGING=1 npx electron-vite dev -- --remote-debugging-port="$CDP_PORT" \
|
||||
>> "$ELECTRON_LOG" 2>&1
|
||||
) &
|
||||
local bg_pid=$!
|
||||
echo "$bg_pid" > "$PIDFILE"
|
||||
echo "[electron-dev] Background PID: $bg_pid"
|
||||
|
||||
# Wait for Electron process to start
|
||||
if ! wait_for_electron; then
|
||||
echo "[electron-dev] Failed to start. Cleaning up..."
|
||||
do_stop
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Wait for renderer to be interactive
|
||||
if ! wait_for_renderer; then
|
||||
echo "[electron-dev] Renderer not ready, but Electron is running. You may need to wait more."
|
||||
fi
|
||||
|
||||
echo "[electron-dev] Ready! Use: agent-browser --cdp $CDP_PORT snapshot -i"
|
||||
}
|
||||
|
||||
do_restart() {
|
||||
do_stop
|
||||
sleep 2
|
||||
do_start
|
||||
}
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────
|
||||
|
||||
case "${1:-help}" in
|
||||
start) do_start ;;
|
||||
stop) do_stop ;;
|
||||
status) do_status ;;
|
||||
restart) do_restart ;;
|
||||
*)
|
||||
echo "Usage: $0 {start|stop|status|restart}"
|
||||
echo ""
|
||||
echo " start — Start Electron dev with CDP (idempotent, skips if already running)"
|
||||
echo " stop — Kill all Electron dev processes (main + helpers + vite)"
|
||||
echo " status — Check if Electron is running and CDP is reachable"
|
||||
echo " restart — Stop then start"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,353 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# record-electron-demo.sh — Record an automated demo of the Electron app
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/record-electron-demo.sh [script.sh] [output.mp4]
|
||||
#
|
||||
# script.sh — A shell script containing agent-browser commands to automate.
|
||||
# It receives the CDP port as $1. Defaults to a built-in queue-edit demo.
|
||||
# output.mp4 — Output file path. Defaults to /tmp/electron-demo.mp4
|
||||
#
|
||||
# Prerequisites:
|
||||
# - agent-browser CLI installed globally
|
||||
# - ffmpeg installed (brew install ffmpeg)
|
||||
# - Electron app NOT already running (script manages lifecycle)
|
||||
#
|
||||
# Examples:
|
||||
# # Run built-in demo
|
||||
# ./scripts/record-electron-demo.sh
|
||||
#
|
||||
# # Run custom automation script
|
||||
# ./scripts/record-electron-demo.sh ./my-demo.sh /tmp/my-demo.mp4
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
CDP_PORT=9222
|
||||
DEMO_SCRIPT="${1:-}"
|
||||
OUTPUT="${2:-/tmp/electron-demo.mp4}"
|
||||
ELECTRON_LOG="/tmp/electron-dev.log"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
RECORD_PID=""
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
cleanup() {
|
||||
echo "[cleanup] Stopping all processes..."
|
||||
[ -n "$RECORD_PID" ] && kill -INT "$RECORD_PID" 2>/dev/null && sleep 2
|
||||
pkill -f "electron-vite" 2>/dev/null || true
|
||||
pkill -f "Electron" 2>/dev/null || true
|
||||
pkill -f "agent-browser" 2>/dev/null || true
|
||||
echo "[cleanup] Done."
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
wait_for_electron() {
|
||||
echo "[wait] Waiting for Electron to start..."
|
||||
for i in $(seq 1 24); do
|
||||
sleep 5
|
||||
if strings "$ELECTRON_LOG" 2>/dev/null | grep -q "starting electron"; then
|
||||
echo "[wait] Electron process ready."
|
||||
return 0
|
||||
fi
|
||||
echo "[wait] Still waiting... (${i}/24)"
|
||||
done
|
||||
echo "[error] Electron failed to start within 120s"
|
||||
exit 1
|
||||
}
|
||||
|
||||
wait_for_renderer() {
|
||||
echo "[wait] Waiting for renderer to load..."
|
||||
sleep 15
|
||||
agent-browser --cdp "$CDP_PORT" wait 3000
|
||||
|
||||
# Poll until interactive elements appear (SPA may take extra time)
|
||||
for i in $(seq 1 12); do
|
||||
local snap
|
||||
snap=$(agent-browser --cdp "$CDP_PORT" snapshot -i 2>&1)
|
||||
if echo "$snap" | grep -q 'link "'; then
|
||||
echo "[wait] Renderer ready (interactive elements found)."
|
||||
return 0
|
||||
fi
|
||||
echo "[wait] SPA still loading... (${i}/12)"
|
||||
sleep 5
|
||||
done
|
||||
echo "[warn] Timed out waiting for interactive elements, proceeding anyway."
|
||||
}
|
||||
|
||||
get_window_and_screen_info() {
|
||||
# Returns: window_x window_y window_w window_h screen_index
|
||||
# Uses Swift to find the Electron window bounds and which screen it's on
|
||||
swift -e '
|
||||
import Cocoa
|
||||
let windowList = CGWindowListCopyWindowInfo([.optionAll], kCGNullWindowID) as! [[String: Any]]
|
||||
for w in windowList {
|
||||
let owner = w["kCGWindowOwnerName"] as? String ?? ""
|
||||
let name = w["kCGWindowName"] as? String ?? ""
|
||||
let layer = w["kCGWindowLayer"] as? Int ?? -1
|
||||
let bounds = w["kCGWindowBounds"] as? [String: Any] ?? [:]
|
||||
let wx = bounds["X"] as? Double ?? 0
|
||||
let wy = bounds["Y"] as? Double ?? 0
|
||||
let ww = bounds["Width"] as? Double ?? 0
|
||||
let wh = bounds["Height"] as? Double ?? 0
|
||||
if (owner == "Electron" || owner == "LobeHub") && layer == 0 && name == "LobeHub" && ww > 200 && wh > 200 {
|
||||
// Find which screen this window is on
|
||||
let screens = NSScreen.screens
|
||||
var screenIdx = 0
|
||||
let windowCenter = NSPoint(x: wx + ww / 2, y: wy + wh / 2)
|
||||
for (i, screen) in screens.enumerated() {
|
||||
let frame = screen.frame
|
||||
// Convert CG coords (top-left origin) to NSScreen coords (bottom-left origin)
|
||||
let mainHeight = screens[0].frame.height
|
||||
let screenTop = mainHeight - frame.origin.y - frame.height
|
||||
let screenBottom = screenTop + frame.height
|
||||
let screenLeft = frame.origin.x
|
||||
let screenRight = screenLeft + frame.width
|
||||
if windowCenter.x >= screenLeft && windowCenter.x <= screenRight &&
|
||||
windowCenter.y >= screenTop && windowCenter.y <= screenBottom {
|
||||
screenIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
// Compute window position relative to the screen it is on
|
||||
let screen = screens[screenIdx]
|
||||
let mainHeight = screens[0].frame.height
|
||||
let screenTop = mainHeight - screen.frame.origin.y - screen.frame.height
|
||||
let relX = wx - screen.frame.origin.x
|
||||
let relY = wy - screenTop
|
||||
let scale = Int(screen.backingScaleFactor)
|
||||
print("\(Int(relX)) \(Int(relY)) \(Int(ww)) \(Int(wh)) \(screenIdx) \(scale)")
|
||||
break
|
||||
}
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
start_recording() {
|
||||
local rel_x=$1 rel_y=$2 w=$3 h=$4 screen_idx=$5 scale=$6
|
||||
|
||||
# ffmpeg avfoundation device index for screens
|
||||
# List devices and find the one matching our screen index
|
||||
local device_idx
|
||||
device_idx=$(ffmpeg -f avfoundation -list_devices true -i "" 2>&1 \
|
||||
| grep "Capture screen ${screen_idx}" \
|
||||
| grep -oE '\[[0-9]+\]' | tr -d '[]' || true)
|
||||
|
||||
if [ -z "$device_idx" ]; then
|
||||
echo "[warn] Could not find capture device for screen $screen_idx, trying default (3)"
|
||||
device_idx=3
|
||||
fi
|
||||
|
||||
# Scale coordinates to native resolution
|
||||
local cx=$((rel_x * scale))
|
||||
local cy=$((rel_y * scale))
|
||||
local cw=$((w * scale))
|
||||
local ch=$((h * scale))
|
||||
|
||||
echo "[record] Window: ${rel_x},${rel_y} ${w}x${h} on screen ${screen_idx} (scale=${scale})"
|
||||
echo "[record] Crop: ${cx},${cy} ${cw}x${ch}, device: ${device_idx}"
|
||||
echo "[record] Output: $OUTPUT"
|
||||
|
||||
ffmpeg -y \
|
||||
-f avfoundation -framerate 30 -capture_cursor 1 -i "${device_idx}:" \
|
||||
-vf "crop=${cw}:${ch}:${cx}:${cy},scale=${w}:${h}" \
|
||||
-c:v libx264 -crf 23 -preset fast -an \
|
||||
"$OUTPUT" \
|
||||
> /tmp/ffmpeg-record.log 2>&1 &
|
||||
RECORD_PID=$!
|
||||
sleep 2
|
||||
|
||||
if ! kill -0 "$RECORD_PID" 2>/dev/null; then
|
||||
echo "[error] ffmpeg failed to start. Log:"
|
||||
cat /tmp/ffmpeg-record.log
|
||||
RECORD_PID=""
|
||||
return 1
|
||||
fi
|
||||
echo "[record] Recording started (PID=$RECORD_PID)"
|
||||
}
|
||||
|
||||
stop_recording() {
|
||||
if [ -n "$RECORD_PID" ]; then
|
||||
echo "[record] Stopping recording..."
|
||||
kill -INT "$RECORD_PID" 2>/dev/null || true
|
||||
wait "$RECORD_PID" 2>/dev/null || true
|
||||
RECORD_PID=""
|
||||
echo "[record] Saved to $OUTPUT"
|
||||
ls -lh "$OUTPUT"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Built-in demo: Queue Edit ────────────────────────────────────────
|
||||
|
||||
find_input_ref() {
|
||||
local port=$1
|
||||
agent-browser --cdp "$port" snapshot -i -C 2>&1 \
|
||||
| grep "editable" \
|
||||
| grep -oE 'ref=e[0-9]+' \
|
||||
| head -1 \
|
||||
| sed 's/ref=//'
|
||||
}
|
||||
|
||||
builtin_demo() {
|
||||
local port=$1
|
||||
|
||||
echo "[demo] Step 1: Navigate to first available agent"
|
||||
local snapshot agent_ref
|
||||
snapshot=$(agent-browser --cdp "$port" snapshot -i 2>&1)
|
||||
# Try Lobe AI first, then fall back to any agent link in the sidebar
|
||||
agent_ref=$(echo "$snapshot" | grep -oE 'link "Lobe AI" \[ref=e[0-9]+\]' | grep -oE 'e[0-9]+' || true)
|
||||
if [ -z "$agent_ref" ]; then
|
||||
# Pick the first agent-like link (skip nav links)
|
||||
agent_ref=$(echo "$snapshot" | grep 'link "' | grep -vE '"Home"|"Pages"|"Settings"|"Search"|"Resources"|"Marketplace"' | head -1 | grep -oE 'ref=e[0-9]+' | sed 's/ref=//' || true)
|
||||
fi
|
||||
if [ -z "$agent_ref" ]; then
|
||||
echo "[error] No agent link found in snapshot"
|
||||
echo "$snapshot" | head -30
|
||||
return 1
|
||||
fi
|
||||
echo "[demo] Clicking agent ref: @$agent_ref"
|
||||
agent-browser --cdp "$port" click "@$agent_ref"
|
||||
sleep 3
|
||||
|
||||
echo "[demo] Step 2: Send first message (triggers AI generation)"
|
||||
local input_ref
|
||||
input_ref=$(find_input_ref "$port")
|
||||
agent-browser --cdp "$port" click "@$input_ref"
|
||||
agent-browser --cdp "$port" type "@$input_ref" "Write a 3000 word essay about the complete history of space exploration from Sputnik to the James Webb Space Telescope"
|
||||
sleep 1
|
||||
agent-browser --cdp "$port" press Enter
|
||||
sleep 3
|
||||
|
||||
echo "[demo] Step 3: Queue message 1"
|
||||
input_ref=$(find_input_ref "$port")
|
||||
agent-browser --cdp "$port" click "@$input_ref"
|
||||
agent-browser --cdp "$port" type "@$input_ref" "This message should be edited"
|
||||
sleep 1
|
||||
agent-browser --cdp "$port" press Enter
|
||||
sleep 1
|
||||
|
||||
echo "[demo] Step 4: Queue message 2"
|
||||
input_ref=$(find_input_ref "$port")
|
||||
agent-browser --cdp "$port" click "@$input_ref"
|
||||
agent-browser --cdp "$port" type "@$input_ref" "Another queued message"
|
||||
sleep 1
|
||||
agent-browser --cdp "$port" press Enter
|
||||
sleep 1
|
||||
|
||||
echo "[demo] Step 5: Verify queue has messages"
|
||||
local queue_count
|
||||
queue_count=$(agent-browser --cdp "$port" eval --stdin << 'EVALEOF'
|
||||
(function() {
|
||||
var chat = window.__LOBE_STORES.chat();
|
||||
var total = 0;
|
||||
Object.keys(chat.queuedMessages).forEach(function(k) {
|
||||
total += chat.queuedMessages[k].length;
|
||||
});
|
||||
return String(total);
|
||||
})()
|
||||
EVALEOF
|
||||
)
|
||||
echo "[demo] Queue count: $queue_count"
|
||||
|
||||
if [ "$queue_count" = "0" ] || [ "$queue_count" = '"0"' ]; then
|
||||
echo "[demo] Queue was already drained. Retrying..."
|
||||
input_ref=$(find_input_ref "$port")
|
||||
agent-browser --cdp "$port" click "@$input_ref"
|
||||
agent-browser --cdp "$port" type "@$input_ref" "Now write another 3000 word essay about artificial intelligence from Turing to transformers covering every major breakthrough"
|
||||
sleep 1
|
||||
agent-browser --cdp "$port" press Enter
|
||||
sleep 2
|
||||
input_ref=$(find_input_ref "$port")
|
||||
agent-browser --cdp "$port" click "@$input_ref"
|
||||
agent-browser --cdp "$port" type "@$input_ref" "This message should be edited"
|
||||
sleep 1
|
||||
agent-browser --cdp "$port" press Enter
|
||||
sleep 1
|
||||
input_ref=$(find_input_ref "$port")
|
||||
agent-browser --cdp "$port" click "@$input_ref"
|
||||
agent-browser --cdp "$port" type "@$input_ref" "Another queued message"
|
||||
sleep 1
|
||||
agent-browser --cdp "$port" press Enter
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
echo "[demo] Step 6: Scroll to show queue tray"
|
||||
agent-browser --cdp "$port" scroll down 5000
|
||||
sleep 2
|
||||
|
||||
echo "[demo] Step 7: Click edit button on first queued message"
|
||||
agent-browser --cdp "$port" eval --stdin << 'EVALEOF'
|
||||
(function() {
|
||||
var chat = window.__LOBE_STORES.chat();
|
||||
var keys = Object.keys(chat.queuedMessages);
|
||||
for (var k = 0; k < keys.length; k++) {
|
||||
var queue = chat.queuedMessages[keys[k]];
|
||||
if (queue.length > 0) {
|
||||
var targetText = queue[0].content;
|
||||
var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null);
|
||||
while (walker.nextNode()) {
|
||||
var node = walker.currentNode;
|
||||
if (node.textContent.trim() === targetText) {
|
||||
var row = node.parentElement.parentElement;
|
||||
var buttons = row.querySelectorAll('[role="button"]');
|
||||
if (buttons.length >= 1) {
|
||||
buttons[0].click();
|
||||
return 'clicked edit on: ' + targetText;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 'edit button not found';
|
||||
})()
|
||||
EVALEOF
|
||||
sleep 3
|
||||
|
||||
echo "[demo] Step 8: Show result — content restored to input"
|
||||
sleep 3
|
||||
|
||||
echo "[demo] Complete!"
|
||||
}
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────
|
||||
|
||||
echo "=== Electron Demo Recorder ==="
|
||||
|
||||
# 1. Kill existing instances
|
||||
echo "[setup] Cleaning up existing processes..."
|
||||
pkill -f "Electron" 2>/dev/null || true
|
||||
pkill -f "electron-vite" 2>/dev/null || true
|
||||
pkill -f "agent-browser" 2>/dev/null || true
|
||||
sleep 3
|
||||
|
||||
# 2. Start Electron
|
||||
echo "[setup] Starting Electron..."
|
||||
cd "$PROJECT_ROOT/apps/desktop"
|
||||
ELECTRON_ENABLE_LOGGING=1 npx electron-vite dev -- --remote-debugging-port="$CDP_PORT" > "$ELECTRON_LOG" 2>&1 &
|
||||
|
||||
wait_for_electron
|
||||
wait_for_renderer
|
||||
|
||||
# 3. Get window position and start recording
|
||||
WIN_INFO=$(get_window_and_screen_info)
|
||||
if [ -z "$WIN_INFO" ]; then
|
||||
echo "[error] Could not find Electron window"
|
||||
exit 1
|
||||
fi
|
||||
read -r WIN_X WIN_Y WIN_W WIN_H SCREEN_IDX SCALE <<< "$WIN_INFO"
|
||||
start_recording "$WIN_X" "$WIN_Y" "$WIN_W" "$WIN_H" "$SCREEN_IDX" "$SCALE"
|
||||
|
||||
# 4. Run demo script
|
||||
if [ -n "$DEMO_SCRIPT" ] && [ -f "$DEMO_SCRIPT" ]; then
|
||||
echo "[demo] Running custom script: $DEMO_SCRIPT"
|
||||
bash "$DEMO_SCRIPT" "$CDP_PORT"
|
||||
else
|
||||
echo "[demo] Running built-in queue-edit demo"
|
||||
builtin_demo "$CDP_PORT"
|
||||
fi
|
||||
|
||||
# 5. Stop recording
|
||||
stop_recording
|
||||
|
||||
echo "=== Done! Output: $OUTPUT ==="
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# test-discord-bot.sh — Send a message to a Discord bot and capture the response
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/test-discord-bot.sh <channel> <message> [wait_seconds] [screenshot_path]
|
||||
#
|
||||
# channel — Channel name to navigate to via Quick Switcher (Cmd+K)
|
||||
# message — Message to send to the bot
|
||||
# wait_seconds — Seconds to wait for bot response (default: 10)
|
||||
# screenshot_path — Output screenshot path (default: /tmp/discord-bot-test.png)
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Discord desktop app installed and logged in
|
||||
# - Accessibility permission granted (System Preferences > Privacy > Accessibility)
|
||||
#
|
||||
# Examples:
|
||||
# ./scripts/test-discord-bot.sh "bot-testing" "!ping"
|
||||
# ./scripts/test-discord-bot.sh "bot-testing" "/ask Tell me a joke" 30
|
||||
# ./scripts/test-discord-bot.sh "general" "Hello bot" 15 /tmp/my-test.png
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
CHANNEL="${1:?Usage: test-discord-bot.sh <channel> <message> [wait_seconds] [screenshot_path]}"
|
||||
MESSAGE="${2:?Usage: test-discord-bot.sh <channel> <message> [wait_seconds] [screenshot_path]}"
|
||||
WAIT="${3:-10}"
|
||||
SCREENSHOT="${4:-/tmp/discord-bot-test.png}"
|
||||
|
||||
APP="Discord"
|
||||
|
||||
echo "[$APP] Activating..."
|
||||
osascript -e "tell application \"$APP\" to activate"
|
||||
sleep 1
|
||||
|
||||
echo "[$APP] Navigating to channel: $CHANNEL"
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
-- Quick Switcher
|
||||
keystroke "k" using command down
|
||||
delay 0.8
|
||||
keystroke "'"$CHANNEL"'"
|
||||
delay 1.5
|
||||
key code 36 -- Enter
|
||||
end tell
|
||||
'
|
||||
sleep 2
|
||||
|
||||
echo "[$APP] Sending message: $MESSAGE"
|
||||
osascript -e '
|
||||
set the clipboard to "'"$MESSAGE"'"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36 -- Enter
|
||||
end tell
|
||||
'
|
||||
|
||||
echo "[$APP] Waiting ${WAIT}s for bot response..."
|
||||
sleep "$WAIT"
|
||||
|
||||
echo "[$APP] Capturing screenshot..."
|
||||
"$SCRIPT_DIR/capture-app-window.sh" "$APP" "$SCREENSHOT"
|
||||
echo "[$APP] Done! Screenshot saved to $SCREENSHOT"
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# test-lark-bot.sh — Send a message to a Lark/Feishu bot and capture the response
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/test-lark-bot.sh <chat> <message> [wait_seconds] [screenshot_path]
|
||||
#
|
||||
# chat — Chat or contact name to search for
|
||||
# message — Message to send to the bot
|
||||
# wait_seconds — Seconds to wait for bot response (default: 10)
|
||||
# screenshot_path — Output screenshot path (default: /tmp/lark-bot-test.png)
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Lark (飞书) desktop app installed and logged in
|
||||
# - Accessibility permission granted (System Preferences > Privacy > Accessibility)
|
||||
#
|
||||
# Notes:
|
||||
# - The app name may be "Lark" or "飞书" depending on version/locale
|
||||
# - Uses Cmd+K to open search/quick switcher
|
||||
# - Enter sends message by default
|
||||
#
|
||||
# Examples:
|
||||
# ./scripts/test-lark-bot.sh "TestBot" "Hello"
|
||||
# ./scripts/test-lark-bot.sh "bot-testing" "/ask Tell me a joke" 30
|
||||
# ./scripts/test-lark-bot.sh "MyBot" "Help me summarize this" 60 /tmp/my-test.png
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
CHAT="${1:?Usage: test-lark-bot.sh <chat> <message> [wait_seconds] [screenshot_path]}"
|
||||
MESSAGE="${2:?Usage: test-lark-bot.sh <chat> <message> [wait_seconds] [screenshot_path]}"
|
||||
WAIT="${3:-10}"
|
||||
SCREENSHOT="${4:-/tmp/lark-bot-test.png}"
|
||||
|
||||
# Detect app name — "Lark" or "飞书"
|
||||
APP=""
|
||||
if osascript -e 'tell application "Lark" to name' &>/dev/null; then
|
||||
APP="Lark"
|
||||
elif osascript -e 'tell application "飞书" to name' &>/dev/null; then
|
||||
APP="飞书"
|
||||
else
|
||||
echo "[error] Lark/飞书 app not found. Install Lark or 飞书."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[$APP] Activating..."
|
||||
osascript -e "tell application \"$APP\" to activate"
|
||||
sleep 1
|
||||
|
||||
echo "[$APP] Searching for chat: $CHAT"
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
-- Quick Switcher / Search (Cmd+K)
|
||||
keystroke "k" using command down
|
||||
delay 0.8
|
||||
end tell
|
||||
'
|
||||
# Use clipboard for chat name (supports CJK characters)
|
||||
osascript -e '
|
||||
set the clipboard to "'"$CHAT"'"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 1.5
|
||||
key code 36 -- Enter to select first result
|
||||
end tell
|
||||
'
|
||||
sleep 2
|
||||
|
||||
echo "[$APP] Sending message: $MESSAGE"
|
||||
osascript -e '
|
||||
set the clipboard to "'"$MESSAGE"'"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36 -- Enter to send
|
||||
end tell
|
||||
'
|
||||
|
||||
echo "[$APP] Waiting ${WAIT}s for bot response..."
|
||||
sleep "$WAIT"
|
||||
|
||||
echo "[$APP] Capturing screenshot..."
|
||||
"$SCRIPT_DIR/capture-app-window.sh" "$APP" "$SCREENSHOT"
|
||||
echo "[$APP] Done! Screenshot saved to $SCREENSHOT"
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# test-qq-bot.sh — Send a message to a QQ bot and capture the response
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/test-qq-bot.sh <contact> <message> [wait_seconds] [screenshot_path]
|
||||
#
|
||||
# contact — Contact, group, or bot name to search for
|
||||
# message — Message to send
|
||||
# wait_seconds — Seconds to wait for bot response (default: 10)
|
||||
# screenshot_path — Output screenshot path (default: /tmp/qq-bot-test.png)
|
||||
#
|
||||
# Prerequisites:
|
||||
# - QQ desktop app installed and logged in
|
||||
# - Accessibility permission granted (System Preferences > Privacy > Accessibility)
|
||||
#
|
||||
# Notes:
|
||||
# - The app name is "QQ"
|
||||
# - Uses Cmd+F to open search
|
||||
# - Enter sends message by default; Shift+Enter for newlines
|
||||
# - Uses clipboard paste for CJK character support
|
||||
#
|
||||
# Examples:
|
||||
# ./scripts/test-qq-bot.sh "TestBot" "Hello"
|
||||
# ./scripts/test-qq-bot.sh "bot-testing" "Hello bot" 30
|
||||
# ./scripts/test-qq-bot.sh "MyBot" "/help" 15 /tmp/my-test.png
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
CONTACT="${1:?Usage: test-qq-bot.sh <contact> <message> [wait_seconds] [screenshot_path]}"
|
||||
MESSAGE="${2:?Usage: test-qq-bot.sh <contact> <message> [wait_seconds] [screenshot_path]}"
|
||||
WAIT="${3:-10}"
|
||||
SCREENSHOT="${4:-/tmp/qq-bot-test.png}"
|
||||
|
||||
APP="QQ"
|
||||
|
||||
echo "[$APP] Activating..."
|
||||
osascript -e "tell application \"$APP\" to activate"
|
||||
sleep 1
|
||||
|
||||
echo "[$APP] Searching for contact: $CONTACT"
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
-- Search (Cmd+F)
|
||||
keystroke "f" using command down
|
||||
delay 0.8
|
||||
end tell
|
||||
'
|
||||
# Use clipboard for contact name (supports CJK characters)
|
||||
osascript -e '
|
||||
set the clipboard to "'"$CONTACT"'"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 1.5
|
||||
key code 36 -- Enter to select first result
|
||||
end tell
|
||||
'
|
||||
sleep 2
|
||||
|
||||
echo "[$APP] Sending message: $MESSAGE"
|
||||
osascript -e '
|
||||
set the clipboard to "'"$MESSAGE"'"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36 -- Enter to send
|
||||
end tell
|
||||
'
|
||||
|
||||
echo "[$APP] Waiting ${WAIT}s for bot response..."
|
||||
sleep "$WAIT"
|
||||
|
||||
echo "[$APP] Capturing screenshot..."
|
||||
"$SCRIPT_DIR/capture-app-window.sh" "$APP" "$SCREENSHOT"
|
||||
echo "[$APP] Done! Screenshot saved to $SCREENSHOT"
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# test-slack-bot.sh — Send a message to a Slack bot and capture the response
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/test-slack-bot.sh <channel> <message> [wait_seconds] [screenshot_path]
|
||||
#
|
||||
# channel — Channel name to navigate to via Quick Switcher (Cmd+K)
|
||||
# message — Message to send (e.g., "@mybot hello" or "/ask question")
|
||||
# wait_seconds — Seconds to wait for bot response (default: 10)
|
||||
# screenshot_path — Output screenshot path (default: /tmp/slack-bot-test.png)
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Slack desktop app installed and logged in
|
||||
# - Accessibility permission granted (System Preferences > Privacy > Accessibility)
|
||||
#
|
||||
# Examples:
|
||||
# ./scripts/test-slack-bot.sh "bot-testing" "@mybot hello"
|
||||
# ./scripts/test-slack-bot.sh "bot-testing" "/ask What is 2+2?" 20
|
||||
# ./scripts/test-slack-bot.sh "general" "Hey bot" 15 /tmp/my-test.png
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
CHANNEL="${1:?Usage: test-slack-bot.sh <channel> <message> [wait_seconds] [screenshot_path]}"
|
||||
MESSAGE="${2:?Usage: test-slack-bot.sh <channel> <message> [wait_seconds] [screenshot_path]}"
|
||||
WAIT="${3:-10}"
|
||||
SCREENSHOT="${4:-/tmp/slack-bot-test.png}"
|
||||
|
||||
APP="Slack"
|
||||
|
||||
echo "[$APP] Activating..."
|
||||
osascript -e "tell application \"$APP\" to activate"
|
||||
sleep 1
|
||||
|
||||
echo "[$APP] Navigating to channel: $CHANNEL"
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
-- Quick Switcher
|
||||
keystroke "k" using command down
|
||||
delay 0.8
|
||||
keystroke "'"$CHANNEL"'"
|
||||
delay 1.5
|
||||
key code 36 -- Enter
|
||||
end tell
|
||||
'
|
||||
sleep 2
|
||||
|
||||
echo "[$APP] Sending message: $MESSAGE"
|
||||
osascript -e '
|
||||
set the clipboard to "'"$MESSAGE"'"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36 -- Enter
|
||||
end tell
|
||||
'
|
||||
|
||||
echo "[$APP] Waiting ${WAIT}s for bot response..."
|
||||
sleep "$WAIT"
|
||||
|
||||
echo "[$APP] Capturing screenshot..."
|
||||
"$SCRIPT_DIR/capture-app-window.sh" "$APP" "$SCREENSHOT"
|
||||
echo "[$APP] Done! Screenshot saved to $SCREENSHOT"
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# test-telegram-bot.sh — Send a message to a Telegram bot and capture the response
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/test-telegram-bot.sh <bot_or_chat> <message> [wait_seconds] [screenshot_path]
|
||||
#
|
||||
# bot_or_chat — Bot username or chat name to search for
|
||||
# message — Message to send to the bot
|
||||
# wait_seconds — Seconds to wait for bot response (default: 10)
|
||||
# screenshot_path — Output screenshot path (default: /tmp/telegram-bot-test.png)
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Telegram desktop app installed and logged in
|
||||
# - Accessibility permission granted (System Preferences > Privacy > Accessibility)
|
||||
#
|
||||
# Notes:
|
||||
# - The app name may be "Telegram" or "Telegram Desktop" depending on installation
|
||||
# - Uses Cmd+F to search for the bot, then Enter to open the chat
|
||||
#
|
||||
# Examples:
|
||||
# ./scripts/test-telegram-bot.sh "MyTestBot" "/start"
|
||||
# ./scripts/test-telegram-bot.sh "MyTestBot" "Hello bot" 30
|
||||
# ./scripts/test-telegram-bot.sh "GPTBot" "/ask What is AI?" 60 /tmp/my-test.png
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
BOT="${1:?Usage: test-telegram-bot.sh <bot_or_chat> <message> [wait_seconds] [screenshot_path]}"
|
||||
MESSAGE="${2:?Usage: test-telegram-bot.sh <bot_or_chat> <message> [wait_seconds] [screenshot_path]}"
|
||||
WAIT="${3:-10}"
|
||||
SCREENSHOT="${4:-/tmp/telegram-bot-test.png}"
|
||||
|
||||
# Detect app name — "Telegram" or "Telegram Desktop"
|
||||
APP=""
|
||||
if osascript -e 'tell application "Telegram" to name' &>/dev/null; then
|
||||
APP="Telegram"
|
||||
elif osascript -e 'tell application "Telegram Desktop" to name' &>/dev/null; then
|
||||
APP="Telegram Desktop"
|
||||
else
|
||||
echo "[error] Telegram app not found. Install Telegram or Telegram Desktop."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[$APP] Activating..."
|
||||
osascript -e "tell application \"$APP\" to activate"
|
||||
sleep 1
|
||||
|
||||
echo "[$APP] Searching for: $BOT"
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
-- Search (Escape first to clear any existing state)
|
||||
key code 53 -- Escape
|
||||
delay 0.3
|
||||
keystroke "f" using command down
|
||||
delay 0.8
|
||||
keystroke "'"$BOT"'"
|
||||
delay 2
|
||||
key code 36 -- Enter to select first result
|
||||
end tell
|
||||
'
|
||||
sleep 2
|
||||
|
||||
echo "[$APP] Sending message: $MESSAGE"
|
||||
osascript -e '
|
||||
set the clipboard to "'"$MESSAGE"'"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36 -- Enter
|
||||
end tell
|
||||
'
|
||||
|
||||
echo "[$APP] Waiting ${WAIT}s for bot response..."
|
||||
sleep "$WAIT"
|
||||
|
||||
echo "[$APP] Capturing screenshot..."
|
||||
"$SCRIPT_DIR/capture-app-window.sh" "$APP" "$SCREENSHOT"
|
||||
echo "[$APP] Done! Screenshot saved to $SCREENSHOT"
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# test-wechat-bot.sh — Send a message to a WeChat bot and capture the response
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/test-wechat-bot.sh <contact> <message> [wait_seconds] [screenshot_path]
|
||||
#
|
||||
# contact — Contact or bot name to search for
|
||||
# message — Message to send
|
||||
# wait_seconds — Seconds to wait for bot response (default: 10)
|
||||
# screenshot_path — Output screenshot path (default: /tmp/wechat-bot-test.png)
|
||||
#
|
||||
# Prerequisites:
|
||||
# - WeChat (微信) desktop app installed and logged in
|
||||
# - Accessibility permission granted (System Preferences > Privacy > Accessibility)
|
||||
#
|
||||
# Notes:
|
||||
# - The app name may be "微信" or "WeChat" depending on system language
|
||||
# - WeChat sends on Enter by default; use Shift+Enter for newlines
|
||||
# - For Chinese text, always uses clipboard paste (keystroke can't handle CJK)
|
||||
#
|
||||
# Examples:
|
||||
# ./scripts/test-wechat-bot.sh "TestBot" "Hello"
|
||||
# ./scripts/test-wechat-bot.sh "文件传输助手" "test message" 5
|
||||
# ./scripts/test-wechat-bot.sh "MyBot" "Tell me a joke" 30 /tmp/my-test.png
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
CONTACT="${1:?Usage: test-wechat-bot.sh <contact> <message> [wait_seconds] [screenshot_path]}"
|
||||
MESSAGE="${2:?Usage: test-wechat-bot.sh <contact> <message> [wait_seconds] [screenshot_path]}"
|
||||
WAIT="${3:-10}"
|
||||
SCREENSHOT="${4:-/tmp/wechat-bot-test.png}"
|
||||
|
||||
# Detect app name — "微信" or "WeChat"
|
||||
APP=""
|
||||
if osascript -e 'tell application "微信" to name' &>/dev/null; then
|
||||
APP="微信"
|
||||
elif osascript -e 'tell application "WeChat" to name' &>/dev/null; then
|
||||
APP="WeChat"
|
||||
else
|
||||
echo "[error] WeChat app not found. Install 微信 (WeChat)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[$APP] Activating..."
|
||||
osascript -e "tell application \"$APP\" to activate"
|
||||
sleep 1
|
||||
|
||||
echo "[$APP] Searching for contact: $CONTACT"
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
-- Search (Cmd+F)
|
||||
keystroke "f" using command down
|
||||
delay 0.8
|
||||
end tell
|
||||
'
|
||||
# Use clipboard for contact name (supports CJK characters)
|
||||
osascript -e '
|
||||
set the clipboard to "'"$CONTACT"'"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 1.5
|
||||
key code 36 -- Enter to select first result
|
||||
end tell
|
||||
'
|
||||
sleep 2
|
||||
|
||||
echo "[$APP] Sending message: $MESSAGE"
|
||||
# Always use clipboard paste — keystroke can't handle CJK or special characters
|
||||
osascript -e '
|
||||
set the clipboard to "'"$MESSAGE"'"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36 -- Enter to send
|
||||
end tell
|
||||
'
|
||||
|
||||
echo "[$APP] Waiting ${WAIT}s for bot response..."
|
||||
sleep "$WAIT"
|
||||
|
||||
echo "[$APP] Capturing screenshot..."
|
||||
"$SCRIPT_DIR/capture-app-window.sh" "$APP" "$SCREENSHOT"
|
||||
echo "[$APP] Done! Screenshot saved to $SCREENSHOT"
|
||||
@@ -7,7 +7,10 @@ description: React component development guide. Use when working with React comp
|
||||
|
||||
- Use antd-style for complex styles; for simple cases, use inline `style` attribute
|
||||
- Use `Flexbox` and `Center` from `@lobehub/ui` for layouts (see `references/layout-kit.md`)
|
||||
- Component priority: `src/components` > installed packages > `@lobehub/ui` > antd
|
||||
- Component priority: `src/components` > `@lobehub/ui/base-ui` > `@lobehub/ui` > custom implementation
|
||||
- Always prefer `@lobehub/ui/base-ui` primitives (Select, Modal, DropdownMenu, Popover, Switch, ScrollArea…) over antd equivalents
|
||||
- Fall back to `@lobehub/ui` higher-level components when base-ui has no match
|
||||
- Only implement a custom component as a last resort — never reach for antd directly
|
||||
- Use selectors to access zustand store data
|
||||
|
||||
## @lobehub/ui Components
|
||||
@@ -29,9 +32,9 @@ Reference: `node_modules/@lobehub/ui/es/index.mjs` for all available components.
|
||||
|
||||
Hybrid routing: Next.js App Router (static pages) + React Router DOM (main SPA).
|
||||
|
||||
| Route Type | Use Case | Implementation |
|
||||
| ------------------ | --------------------------------- | ---------------------------- |
|
||||
| Next.js App Router | Auth pages (login, signup, oauth) | `src/app/[variants]/(auth)/` |
|
||||
| Route Type | Use Case | Implementation |
|
||||
| ------------------ | --------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| Next.js App Router | Auth pages (login, signup, oauth) | `src/app/[variants]/(auth)/` |
|
||||
| React Router DOM | Main SPA (chat, settings) | `desktopRouter.config.tsx` + `desktopRouter.config.desktop.tsx` (must match) |
|
||||
|
||||
### Key Files
|
||||
@@ -47,9 +50,9 @@ Hybrid routing: Next.js App Router (static pages) + React Router DOM (main SPA).
|
||||
|
||||
Known pairs that must stay in sync:
|
||||
|
||||
| Base file (web, dynamic imports) | Desktop file (Electron, sync imports) |
|
||||
| --- | --- |
|
||||
| `src/spa/router/desktopRouter.config.tsx` | `src/spa/router/desktopRouter.config.desktop.tsx` |
|
||||
| Base file (web, dynamic imports) | Desktop file (Electron, sync imports) |
|
||||
| ----------------------------------------------------- | ------------------------------------------------------------- |
|
||||
| `src/spa/router/desktopRouter.config.tsx` | `src/spa/router/desktopRouter.config.desktop.tsx` |
|
||||
| `src/routes/(main)/settings/features/componentMap.ts` | `src/routes/(main)/settings/features/componentMap.desktop.ts` |
|
||||
|
||||
**How to check**: After editing any `.ts` / `.tsx` file, run `Glob` for `<filename>.desktop.{ts,tsx}` in the same directory. If a match exists, update it with the equivalent sync-import change.
|
||||
|
||||
@@ -71,15 +71,18 @@ internal_createTopic: async (params) => {
|
||||
**Actions:**
|
||||
|
||||
- Public: `createTopic`, `sendMessage`
|
||||
|
||||
- Internal: `internal_createTopic`, `internal_updateMessageContent`
|
||||
|
||||
- Dispatch: `internal_dispatchTopic`
|
||||
- Toggle: `internal_toggleMessageLoading`
|
||||
**State:**
|
||||
|
||||
**State:**
|
||||
- ID arrays: `topicEditingIds`
|
||||
|
||||
- ID arrays: `messageLoadingIds`, `topicEditingIds`
|
||||
- Maps: `topicMaps`, `messagesMap`
|
||||
|
||||
- Active: `activeTopicId`
|
||||
|
||||
- Init flags: `topicsInit`
|
||||
|
||||
## Detailed Guides
|
||||
|
||||
@@ -30,16 +30,13 @@ 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',
|
||||
|
||||
@@ -162,11 +162,15 @@ describe('ModuleName', () => {
|
||||
### 5. Create Pull Request
|
||||
|
||||
- Create a new branch: `automatic/add-tests-[module-name]-[date]`
|
||||
|
||||
- Commit changes with message format:
|
||||
|
||||
```
|
||||
✅ test: add unit tests for [module-name]
|
||||
```
|
||||
|
||||
- Push the branch
|
||||
|
||||
- Create a PR with:
|
||||
|
||||
- Title: `✅ test: add unit tests for [module-name]`
|
||||
@@ -198,6 +202,7 @@ describe('ModuleName', () => {
|
||||
- Test approach: [brief description]
|
||||
|
||||
---
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
```
|
||||
|
||||
|
||||
@@ -77,20 +77,24 @@ Create `e2e/src/features/{module-name}/README.md` with:
|
||||
# {Module} 模块 E2E 测试覆盖
|
||||
|
||||
## 模块概述
|
||||
|
||||
**路由**: `/module`, `/module/[id]`
|
||||
|
||||
## 功能清单与测试覆盖
|
||||
|
||||
### 1. 功能分组名称
|
||||
|
||||
| 功能点 | 描述 | 优先级 | 状态 | 测试文件 |
|
||||
| ------ | ---- | ------ | ---- | -------- |
|
||||
| 功能点 | 描述 | 优先级 | 状态 | 测试文件 |
|
||||
| ------ | ---- | ------ | ---- | ------------- |
|
||||
| 功能A | xxx | P0 | ✅ | `xxx.feature` |
|
||||
| 功能B | xxx | P1 | ⏳ | |
|
||||
| 功能B | xxx | P1 | ⏳ | |
|
||||
|
||||
## 测试文件结构
|
||||
|
||||
## 测试执行
|
||||
|
||||
## 已知问题
|
||||
|
||||
## 更新记录
|
||||
```
|
||||
|
||||
@@ -228,7 +232,7 @@ const testId = pickle.tags.find(
|
||||
tag.name.startsWith('@COMMUNITY-') ||
|
||||
tag.name.startsWith('@AGENT-') ||
|
||||
tag.name.startsWith('@HOME-') ||
|
||||
tag.name.startsWith('@PAGE-') || // Add new prefix
|
||||
tag.name.startsWith('@PAGE-') || // Add new prefix
|
||||
tag.name.startsWith('@ROUTES-'),
|
||||
);
|
||||
```
|
||||
@@ -300,11 +304,15 @@ HEADLESS=true BASE_URL=http://localhost:3006 \
|
||||
### 10. Create Pull Request
|
||||
|
||||
- Branch name: `test/e2e-{module-name}`
|
||||
|
||||
- Commit message format:
|
||||
|
||||
```
|
||||
✅ test: add E2E tests for {module-name}
|
||||
```
|
||||
|
||||
- PR title: `✅ test: add E2E tests for {module-name}`
|
||||
|
||||
- PR body template:
|
||||
|
||||
````markdown
|
||||
|
||||
@@ -36,6 +36,7 @@ If you detect any leaked secrets, respond IMMEDIATELY with:
|
||||
⚠️ **Security Warning**: Your comment appears to contain sensitive information (API keys, secrets, or credentials).
|
||||
|
||||
**Please delete your comment immediately** to protect your account security, then:
|
||||
|
||||
1. Rotate/regenerate any exposed credentials
|
||||
2. Re-post your question with secrets redacted (e.g., `AUTH_SECRET=***`)
|
||||
|
||||
@@ -73,12 +74,17 @@ Look for the "Troubleshooting" or "FAQ" section in the migration docs and match
|
||||
## Response Guidelines
|
||||
|
||||
1. **Be helpful and friendly** - Users are often frustrated when migration doesn't work
|
||||
|
||||
2. **Be specific** - Provide exact commands or configuration examples
|
||||
|
||||
3. **Reference documentation** - Point users to relevant docs sections
|
||||
|
||||
4. **Ask for logs** - If the issue is unclear, ask for Docker logs:
|
||||
|
||||
```bash
|
||||
docker logs <container_name> 2>&1 | tail -100
|
||||
```
|
||||
|
||||
5. **One issue at a time** - Focus on solving one problem before moving to the next
|
||||
|
||||
## Response Format
|
||||
@@ -90,6 +96,7 @@ Use this format for your responses:
|
||||
|
||||
[If missing information]
|
||||
To help you effectively, please provide:
|
||||
|
||||
- [List missing items]
|
||||
|
||||
[If you can help]
|
||||
@@ -102,6 +109,7 @@ Based on your description, here's what I suggest:
|
||||
|
||||
[If the issue is complex or unknown]
|
||||
This issue needs further investigation. I've notified the team. In the meantime, please:
|
||||
|
||||
1. [Any immediate steps they can try]
|
||||
2. Share your Docker logs if you haven't already
|
||||
```
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
|
||||
## Quick Reference by Name
|
||||
|
||||
- **@arvinxx**: Last resort only, mention for priority:high issues, tool calling , mcp
|
||||
- **@arvinxx**: Last resort only, mention for priority:high issues, tool calling, mcp, database
|
||||
- **@canisminor1990**: Design, UI components, editor, markdown rendering
|
||||
- **@tjx666**: Image/video generation, vision, cloud version, documentation, TTS, auth, login/register
|
||||
- **@ONLY-yours**: Performance, streaming, settings, general bugs, web platform, marketplace
|
||||
- **@RiverTwilight**: Knowledge base, files (KB-related), group chat
|
||||
- **@nekomeowww**: Memory, backend, deployment, DevOps
|
||||
- **@tjx666**: Image/video generation, vision, cloud version, documentation, TTS, auth, login/register, database
|
||||
- **@ONLY-yours**: Performance, streaming, settings, general bugs, web platform, marketplace, agent builder, schedule task
|
||||
- **@Innei**: Knowledge base, files (KB-related), group chat, Electron, desktop client, build system
|
||||
- **@nekomeowww**: Memory, backend, deployment, DevOps, database
|
||||
- **@sudongyuer**: Mobile app (React Native)
|
||||
- **@sxjeru**: Model providers and configuration
|
||||
- **@rdmclin2**: Team workspace
|
||||
- **@rdmclin2**: Team workspace, IM and bot integration
|
||||
- **@tcmonster**: Subscription, refund, recharge, business cooperation
|
||||
|
||||
Quick reference for assigning issues based on labels.
|
||||
@@ -28,7 +28,7 @@ Quick reference for assigning issues based on labels.
|
||||
| Label | Owner | Notes |
|
||||
| ------------------ | ----------- | -------------------------------------- |
|
||||
| `platform:mobile` | @sudongyuer | React Native mobile app |
|
||||
| `platform:desktop` | @ONLY-yours | Electron desktop client (general) |
|
||||
| `platform:desktop` | @Innei | Electron desktop client, build system |
|
||||
| `platform:web` | @ONLY-yours | Web platform (unless specific feature) |
|
||||
|
||||
### Feature Labels (feature:\*)
|
||||
@@ -38,8 +38,8 @@ Quick reference for assigning issues based on labels.
|
||||
| `feature:image` | @tjx666 | AI image generation |
|
||||
| `feature:dalle` | @tjx666 | DALL-E related |
|
||||
| `feature:vision` | @tjx666 | Vision/multimodal generation |
|
||||
| `feature:knowledge-base` | @RiverTwilight | Knowledge base and RAG |
|
||||
| `feature:files` | @RiverTwilight | File upload/management (when KB-related)<br>@ONLY-yours (general files) |
|
||||
| `feature:knowledge-base` | @Innei | Knowledge base and RAG |
|
||||
| `feature:files` | @Innei | File upload/management (when KB-related)<br>@ONLY-yours (general files) |
|
||||
| `feature:editor` | @canisminor1990 | Lobe Editor |
|
||||
| `feature:markdown` | @canisminor1990 | Markdown rendering |
|
||||
| `feature:auth` | @tjx666 | Authentication/authorization |
|
||||
@@ -57,9 +57,12 @@ Quick reference for assigning issues based on labels.
|
||||
| `feature:search` | @ONLY-yours | Search functionality |
|
||||
| `feature:tts` | @tjx666 | Text-to-speech |
|
||||
| `feature:export` | @ONLY-yours | Export functionality |
|
||||
| `feature:group-chat` | @RiverTwilight | Group chat functionality |
|
||||
| `feature:group-chat` | @arvinxx | Group chat functionality |
|
||||
| `feature:memory` | @nekomeowww | Memory feature |
|
||||
| `feature:team-workspace` | @rdmclin2 | Team workspace application |
|
||||
| `feature:im-integration` | @rdmclin2 | IM and bot integration (Slack, Discord, etc.) |
|
||||
| `feature:agent-builder` | @ONLY-yours | Agent builder |
|
||||
| `feature:schedule-task` | @ONLY-yours | Schedule task |
|
||||
| `feature:subscription` | @tcmonster | Subscription and billing |
|
||||
| `feature:refund` | @tcmonster | Refund requests |
|
||||
| `feature:recharge` | @tcmonster | Recharge and payment |
|
||||
@@ -125,18 +128,18 @@ Quick reference for assigning issues based on labels.
|
||||
|
||||
**Single owner:**
|
||||
|
||||
```
|
||||
```plaintext
|
||||
@username - This is a [feature/component] issue. Please take a look.
|
||||
```
|
||||
|
||||
**Multiple owners:**
|
||||
|
||||
```
|
||||
```plaintext
|
||||
@primary @secondary - This involves [features]. Please coordinate.
|
||||
```
|
||||
|
||||
**High priority:**
|
||||
|
||||
```
|
||||
```plaintext
|
||||
@owner @arvinxx - High priority [feature] issue.
|
||||
```
|
||||
|
||||
@@ -72,11 +72,15 @@ Module granularity examples:
|
||||
### 5. Create Pull Request
|
||||
|
||||
- Create a new branch: `automatic/translate-comments-[module-name]-[date]`
|
||||
|
||||
- Commit changes with message format:
|
||||
|
||||
```
|
||||
🌐 chore: translate non-English comments to English in [module-name]
|
||||
```
|
||||
|
||||
- Push the branch
|
||||
|
||||
- Create a PR with:
|
||||
|
||||
- Title: `🌐 chore: translate non-English comments to English in [module-name]`
|
||||
@@ -100,6 +104,7 @@ Module granularity examples:
|
||||
`[module-path]`
|
||||
|
||||
---
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
```
|
||||
|
||||
|
||||
@@ -9,16 +9,10 @@ inputs:
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
name: Setup Environment
|
||||
description: Setup Node.js, pnpm (install) and Bun (script runner) for workflows
|
||||
|
||||
inputs:
|
||||
node-version:
|
||||
description: Node.js version
|
||||
required: false
|
||||
default: '24.11.1'
|
||||
package-manager-cache:
|
||||
description: Pass-through to actions/setup-node package-manager-cache
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
package-manager-cache: ${{ inputs.package-manager-cache }}
|
||||
@@ -0,0 +1,13 @@
|
||||
AmAzing129
|
||||
arvinxx
|
||||
canisminor1990
|
||||
ilimei
|
||||
Innei
|
||||
lobehubbot
|
||||
nekomeowww
|
||||
ONLY-yours
|
||||
rdmclin2
|
||||
rivertwilight
|
||||
sudongyuer
|
||||
tcmonster
|
||||
tjx666
|
||||
@@ -3,7 +3,7 @@ name: Daily i18n Update
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
workflow_dispatch:
|
||||
workflow_dispatch: {}
|
||||
|
||||
# Add permissions configuration
|
||||
permissions:
|
||||
@@ -25,13 +25,11 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ secrets.BUN_VERSION }}
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
run: pnpm install
|
||||
|
||||
- name: Update i18n
|
||||
run: bun run i18n
|
||||
|
||||
@@ -26,8 +26,9 @@ jobs:
|
||||
|
||||
- name: Detect release PR (version from title)
|
||||
id: release
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
run: |
|
||||
PR_TITLE="${{ github.event.pull_request.title }}"
|
||||
echo "PR Title: $PR_TITLE"
|
||||
|
||||
# Match "🚀 release: v{x.x.x}" format (strict semver: x.y.z with optional -prerelease or +build)
|
||||
@@ -44,9 +45,10 @@ jobs:
|
||||
- name: Detect patch PR (branch first, title fallback)
|
||||
id: patch
|
||||
if: steps.release.outputs.should_tag != 'true'
|
||||
env:
|
||||
HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
run: |
|
||||
HEAD_REF="${{ github.event.pull_request.head.ref }}"
|
||||
PR_TITLE="${{ github.event.pull_request.title }}"
|
||||
echo "Head ref: $HEAD_REF"
|
||||
echo "PR Title: $PR_TITLE"
|
||||
|
||||
@@ -72,22 +74,13 @@ jobs:
|
||||
git checkout main
|
||||
git pull --rebase origin main
|
||||
|
||||
- name: Setup Node.js
|
||||
- name: Setup environment
|
||||
if: steps.release.outputs.should_tag == 'true' || steps.patch.outputs.should_tag == 'true'
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
if: steps.release.outputs.should_tag == 'true' || steps.patch.outputs.should_tag == 'true'
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install deps
|
||||
if: steps.release.outputs.should_tag == 'true' || steps.patch.outputs.should_tag == 'true'
|
||||
run: bun i
|
||||
run: pnpm install
|
||||
|
||||
- name: Resolve patch version (patch bump)
|
||||
id: patch-version
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: Bundle Analyzer
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -9,7 +9,6 @@ permissions:
|
||||
|
||||
env:
|
||||
NODE_VERSION: 24.11.1
|
||||
BUN_VERSION: 1.2.23
|
||||
|
||||
jobs:
|
||||
bundle-analyzer:
|
||||
@@ -20,19 +19,11 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
|
||||
|
||||
@@ -51,11 +51,11 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright browsers (with system deps)
|
||||
run: bunx playwright install --with-deps chromium
|
||||
|
||||
@@ -29,11 +29,11 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
|
||||
@@ -18,6 +18,16 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Check if author is a team member
|
||||
id: check-team
|
||||
run: |
|
||||
ISSUE_AUTHOR="${{ github.event.issue.user.login }}"
|
||||
if grep -iq "^${ISSUE_AUTHOR}$" .github/maintainers.txt; then
|
||||
echo "is_team=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_team=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Copy triage prompts
|
||||
run: |
|
||||
mkdir -p /tmp/claude-prompts
|
||||
@@ -62,7 +72,7 @@ jobs:
|
||||
**IMPORTANT**:
|
||||
- Follow ALL steps in the issue-triage.md guide
|
||||
- Apply labels according to the guide's rules
|
||||
- Post a mention comment to the appropriate team member(s) based on team-assignment.md
|
||||
- ${{ steps.check-team.outputs.is_team == 'true' && 'The issue author is a team member. Do NOT post any @mention comment.' || 'Post a mention comment to the appropriate team member(s) based on team-assignment.md' }}
|
||||
- Replace [ISSUE_NUMBER] with: ${{ github.event.issue.number }}
|
||||
|
||||
**Start the triage process now.**
|
||||
|
||||
@@ -21,7 +21,18 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Check if author is a team member
|
||||
id: check-team
|
||||
run: |
|
||||
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
|
||||
if grep -iq "^${PR_AUTHOR}$" .github/maintainers.txt; then
|
||||
echo "is_team=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_team=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Copy prompts
|
||||
if: steps.check-team.outputs.is_team == 'false'
|
||||
run: |
|
||||
mkdir -p /tmp/claude-prompts
|
||||
cp .claude/prompts/pr-assign.md /tmp/claude-prompts/
|
||||
@@ -29,6 +40,7 @@ jobs:
|
||||
cp .claude/prompts/security-rules.md /tmp/claude-prompts/
|
||||
|
||||
- name: Run Claude Code for PR Reviewer Assignment
|
||||
if: steps.check-team.outputs.is_team == 'false'
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
github_token: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
@@ -55,5 +55,5 @@ jobs:
|
||||
# Security: Allow only specific safe commands - no gh commands to prevent token exfiltration
|
||||
# These tools are restricted to code analysis and build operations only
|
||||
claude_args: |
|
||||
--allowedTools "Bash(git:*),Bash(gh:*),Bash(bun run:*),Bash(pnpm run:*),Bash(npm run:*),Bash(npx:*),Bash(bunx:*),Bash(vitest:*),Bash(rg:*),Bash(find:*),Bash(sed:*),Bash(grep:*),Bash(awk:*),Bash(wc:*),Bash(xargs:*)"
|
||||
--allowedTools "Bash(git:*),Bash(gh:*),Bash(bun run:*),Bash(bunx:*),Bash(pnpm:*),Bash(npm run:*),Bash(npx:*),Bash(vitest:*),Bash(rg:*),Bash(find:*),Bash(sed:*),Bash(grep:*),Bash(awk:*),Bash(wc:*),Bash(xargs:*)"
|
||||
--append-system-prompt "$(cat /tmp/claude-prompts/security-rules.md)"
|
||||
|
||||
@@ -61,13 +61,11 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install dependencies (bun)
|
||||
run: bun install
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Install Playwright browsers (with system deps)
|
||||
run: bunx playwright install --with-deps chromium
|
||||
|
||||
@@ -3,7 +3,7 @@ description: Auto-closes issues that are duplicates of existing issues
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * *'
|
||||
workflow_dispatch:
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
auto-close-duplicates:
|
||||
@@ -17,10 +17,11 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Auto-close duplicate issues
|
||||
run: bun run .github/scripts/auto-close-duplicates.ts
|
||||
|
||||
@@ -28,9 +28,21 @@ jobs:
|
||||
✅ @{{ author }}
|
||||
|
||||
This issue is closed, If you have any questions, you can comment and reply.
|
||||
- name: Checkout repository
|
||||
if: github.event_name == 'pull_request_target' && github.event.pull_request.merged == true
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check if PR author is maintainer
|
||||
if: github.event.pull_request.merged == true
|
||||
id: maintainer-check
|
||||
run: |
|
||||
if [ -f .github/maintainers.txt ] && grep -qx "${{ github.event.pull_request.user.login }}" .github/maintainers.txt; then
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Auto Comment on Pull Request Merged
|
||||
uses: actions-cool/pr-welcome@main
|
||||
if: github.event.pull_request.merged == true
|
||||
if: github.event.pull_request.merged == true && steps.maintainer-check.outputs.skip != 'true'
|
||||
with:
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
comment: |
|
||||
|
||||
@@ -6,10 +6,10 @@ on:
|
||||
channel:
|
||||
description: 'Release channel for desktop build (affects version suffix and workflow:set-desktop-version)'
|
||||
required: true
|
||||
default: nightly
|
||||
default: canary
|
||||
type: choice
|
||||
options:
|
||||
- nightly
|
||||
- canary
|
||||
- beta
|
||||
- stable
|
||||
build_macos:
|
||||
@@ -41,7 +41,6 @@ permissions:
|
||||
|
||||
env:
|
||||
NODE_VERSION: 24.11.1
|
||||
BUN_VERSION: 1.2.23
|
||||
|
||||
jobs:
|
||||
version:
|
||||
@@ -102,18 +101,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node & pnpm
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
- name: Setup build environment
|
||||
uses: ./.github/actions/desktop-build-setup
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
package-manager-cache: 'false'
|
||||
|
||||
# node-linker=hoisted 模式将可以确保 asar 压缩可用
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pnpm install --node-linker=hoisted &
|
||||
npm run install-isolated --prefix=./apps/desktop &
|
||||
wait
|
||||
|
||||
- name: Set package version
|
||||
run: npm run workflow:set-desktop-version ${{ needs.version.outputs.version }} ${{ inputs.channel }}
|
||||
@@ -127,8 +118,8 @@ jobs:
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_PROJECT_ID || secrets.UMAMI_NIGHTLY_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_BASE_URL || secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL }}
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ inputs.channel == 'stable' && secrets.UMAMI_STABLE_DESKTOP_PROJECT_ID || secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'stable' && secrets.UMAMI_STABLE_DESKTOP_BASE_URL || secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
|
||||
CSC_FOR_PULL_REQUEST: true
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
@@ -193,8 +184,8 @@ jobs:
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_PROJECT_ID || secrets.UMAMI_NIGHTLY_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_BASE_URL || secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL }}
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ inputs.channel == 'stable' && secrets.UMAMI_STABLE_DESKTOP_PROJECT_ID || secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'stable' && secrets.UMAMI_STABLE_DESKTOP_BASE_URL || secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
|
||||
TEMP: C:\temp
|
||||
TMP: C:\temp
|
||||
|
||||
@@ -222,17 +213,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node & pnpm
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
- name: Setup build environment
|
||||
uses: ./.github/actions/desktop-build-setup
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
package-manager-cache: 'false'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pnpm install --node-linker=hoisted &
|
||||
npm run install-isolated --prefix=./apps/desktop &
|
||||
wait
|
||||
|
||||
- name: Set package version
|
||||
run: npm run workflow:set-desktop-version ${{ needs.version.outputs.version }} ${{ inputs.channel }}
|
||||
@@ -244,8 +228,8 @@ jobs:
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_PROJECT_ID || secrets.UMAMI_NIGHTLY_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_BASE_URL || secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL }}
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ inputs.channel == 'stable' && secrets.UMAMI_STABLE_DESKTOP_PROJECT_ID || secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'stable' && secrets.UMAMI_STABLE_DESKTOP_BASE_URL || secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
@@ -274,12 +258,10 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node & Bun
|
||||
uses: ./.github/actions/setup-node-bun
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
package-manager-cache: 'false'
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
|
||||
@@ -27,15 +27,11 @@ jobs:
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node & Bun
|
||||
uses: ./.github/actions/setup-node-bun
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
bun-version: latest
|
||||
package-manager-cache: 'false'
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
run: pnpm install
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
|
||||
@@ -93,29 +89,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node & pnpm
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
- name: Setup build environment
|
||||
uses: ./.github/actions/desktop-build-setup
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: 'false'
|
||||
|
||||
# node-linker=hoisted 模式将可以确保 asar 压缩可用
|
||||
- name: Install dependencies
|
||||
run: pnpm install --node-linker=hoisted
|
||||
|
||||
# 移除国内 electron 镜像配置,GitHub Actions 使用官方源更快
|
||||
- name: Remove China electron mirror from .npmrc
|
||||
shell: bash
|
||||
run: |
|
||||
NPMRC_FILE="./apps/desktop/.npmrc"
|
||||
if [ -f "$NPMRC_FILE" ]; then
|
||||
sed -i.bak '/^electron_mirror=/d; /^electron_builder_binaries_mirror=/d' "$NPMRC_FILE"
|
||||
rm -f "${NPMRC_FILE}.bak"
|
||||
echo "✅ Removed electron mirror config from .npmrc"
|
||||
fi
|
||||
|
||||
- name: Install deps on Desktop
|
||||
run: npm run install-isolated --prefix=./apps/desktop
|
||||
|
||||
# 设置 package.json 的版本号
|
||||
- name: Set package version
|
||||
@@ -228,12 +205,8 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node & Bun
|
||||
uses: ./.github/actions/setup-node-bun
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
bun-version: latest
|
||||
package-manager-cache: 'false'
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
# 下载所有平台的构建产物
|
||||
- name: Download artifacts
|
||||
@@ -251,13 +224,11 @@ jobs:
|
||||
- name: Install yaml only for merge step
|
||||
run: |
|
||||
cd scripts/electronWorkflow
|
||||
# 在脚本目录创建最小 package.json,防止 bun 向上寻找根 package.json
|
||||
if [ ! -f package.json ]; then
|
||||
echo '{"name":"merge-mac-release","private":true}' > package.json
|
||||
fi
|
||||
bun add --no-save yaml@2.8.1
|
||||
|
||||
# 合并 macOS YAML 文件 (使用 bun 运行 JavaScript)
|
||||
- name: Merge latest-mac.yml files
|
||||
run: bun run scripts/electronWorkflow/mergeMacReleaseFiles.js
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ name: Release Desktop Beta
|
||||
# 如: v2.0.0-beta.1, v2.0.0-alpha.1, v2.0.0-rc.1
|
||||
#
|
||||
# 注意: Stable 版本 (如 v2.0.0) 由 release-desktop-stable.yml 处理
|
||||
# 注意: Nightly 版本 (如 v2.1.0-nightly.xxx) 由 release-desktop-nightly.yml 处理
|
||||
# 注意: Nightly 版本已停用,不再参与 Desktop 发布流程
|
||||
# ============================================
|
||||
|
||||
on:
|
||||
@@ -41,10 +41,10 @@ jobs:
|
||||
version="${version#v}"
|
||||
echo "version=${version}" >> $GITHUB_OUTPUT
|
||||
|
||||
# Beta 版本包含 beta/alpha/rc (nightly 由 release-desktop-nightly.yml 处理)
|
||||
# Beta 版本包含 beta/alpha/rc;nightly 标签已停用
|
||||
if [[ "$version" == *"nightly"* ]]; then
|
||||
echo "is_beta=false" >> $GITHUB_OUTPUT
|
||||
echo "⏭️ Skipping: $version is a nightly release (handled by release-desktop-nightly.yml)"
|
||||
echo "⏭️ Skipping: $version is a disabled nightly release tag"
|
||||
elif [[ "$version" == *"beta"* ]] || [[ "$version" == *"alpha"* ]] || [[ "$version" == *"rc"* ]]; then
|
||||
echo "is_beta=true" >> $GITHUB_OUTPUT
|
||||
echo "✅ Beta release detected: $version"
|
||||
@@ -62,19 +62,13 @@ jobs:
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
run: pnpm install
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
@@ -168,16 +162,10 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
# 下载所有平台的构建产物
|
||||
- name: Download artifacts
|
||||
@@ -195,13 +183,11 @@ jobs:
|
||||
- name: Install yaml only for merge step
|
||||
run: |
|
||||
cd scripts/electronWorkflow
|
||||
# 在脚本目录创建最小 package.json,防止 bun 向上寻找根 package.json
|
||||
if [ ! -f package.json ]; then
|
||||
echo '{"name":"merge-mac-release","private":true}' > package.json
|
||||
fi
|
||||
bun add --no-save yaml@2.8.1
|
||||
|
||||
# 合并 macOS YAML 文件 (使用 bun 运行 JavaScript)
|
||||
- name: Merge latest-mac.yml files
|
||||
run: bun run scripts/electronWorkflow/mergeMacReleaseFiles.js
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ jobs:
|
||||
name: Calculate Canary Version
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_notes: ${{ steps.release-notes.outputs.release_notes }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
should_build: ${{ steps.check.outputs.should_build }}
|
||||
@@ -121,6 +122,66 @@ jobs:
|
||||
echo "✅ Canary version: ${version}"
|
||||
echo "🏷️ Tag: ${tag}"
|
||||
|
||||
- name: Generate canary release notes
|
||||
if: steps.check.outputs.should_build == 'true'
|
||||
id: release-notes
|
||||
env:
|
||||
TAG: ${{ steps.version.outputs.tag }}
|
||||
run: |
|
||||
previous_canary=$(git tag --sort=-creatordate | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+-canary\.[0-9]+$' | head -n 1)
|
||||
latest_stable=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1)
|
||||
|
||||
if [ -n "$previous_canary" ]; then
|
||||
compare_from="$previous_canary"
|
||||
compare_range="${previous_canary}..HEAD"
|
||||
elif [ -n "$latest_stable" ]; then
|
||||
compare_from="$latest_stable"
|
||||
compare_range="${latest_stable}..HEAD"
|
||||
else
|
||||
compare_from="initial commit"
|
||||
compare_range="HEAD"
|
||||
fi
|
||||
|
||||
commit_count=$(git rev-list --count "$compare_range")
|
||||
commits=$(git log --no-merges --pretty='- `%h` %s (%an)' "$compare_range")
|
||||
|
||||
if [ -z "$commits" ]; then
|
||||
commits='- No new commits recorded.'
|
||||
fi
|
||||
|
||||
{
|
||||
echo "release_notes<<EOF"
|
||||
echo "## 🐤 Canary Build — ${TAG}"
|
||||
echo
|
||||
echo "> Automated canary build from \`canary\` branch."
|
||||
echo
|
||||
echo "### Commit Information"
|
||||
echo
|
||||
echo "- Based on changes since \`${compare_from}\`"
|
||||
echo "- Commit count: ${commit_count}"
|
||||
echo
|
||||
printf '%s\n' "$commits"
|
||||
echo
|
||||
echo "### ⚠️ Important Notes"
|
||||
echo
|
||||
echo "- **This is an automated canary build and is NOT intended for production use.**"
|
||||
echo "- Canary builds are triggered by \`build\`/\`fix\`/\`style\` commits on the \`canary\` branch."
|
||||
echo "- May contain **unstable or incomplete changes**. **Use at your own risk.**"
|
||||
echo "- It is strongly recommended to **back up your data** before using a canary build."
|
||||
echo
|
||||
echo "### 📦 Installation"
|
||||
echo
|
||||
echo "Download the appropriate installer for your platform from the assets below."
|
||||
echo
|
||||
echo "| Platform | File |"
|
||||
echo "|----------|------|"
|
||||
echo "| macOS (Apple Silicon) | \`.dmg\` (arm64) |"
|
||||
echo "| macOS (Intel) | \`.dmg\` (x64) |"
|
||||
echo "| Windows | \`.exe\` |"
|
||||
echo "| Linux | \`.AppImage\` / \`.deb\` |"
|
||||
echo "EOF"
|
||||
} >> $GITHUB_OUTPUT
|
||||
|
||||
# ============================================
|
||||
# 代码质量检查
|
||||
# ============================================
|
||||
@@ -133,19 +194,13 @@ jobs:
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
run: pnpm install
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
@@ -188,6 +243,7 @@ jobs:
|
||||
env:
|
||||
UPDATE_CHANNEL: canary
|
||||
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
|
||||
RELEASE_NOTES: ${{ needs.calculate-version.outputs.release_notes }}
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
@@ -207,6 +263,7 @@ jobs:
|
||||
env:
|
||||
UPDATE_CHANNEL: canary
|
||||
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
|
||||
RELEASE_NOTES: ${{ needs.calculate-version.outputs.release_notes }}
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
@@ -222,6 +279,7 @@ jobs:
|
||||
env:
|
||||
UPDATE_CHANNEL: canary
|
||||
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
|
||||
RELEASE_NOTES: ${{ needs.calculate-version.outputs.release_notes }}
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
@@ -247,16 +305,10 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
@@ -311,28 +363,7 @@ jobs:
|
||||
tag_name: ${{ needs.calculate-version.outputs.tag }}
|
||||
name: 'Desktop Canary ${{ needs.calculate-version.outputs.tag }}'
|
||||
prerelease: true
|
||||
body: |
|
||||
## 🐤 Canary Build — ${{ needs.calculate-version.outputs.tag }}
|
||||
|
||||
> Automated canary build from `canary` branch.
|
||||
|
||||
### ⚠️ Important Notes
|
||||
|
||||
- **This is an automated canary build and is NOT intended for production use.**
|
||||
- Canary builds are triggered by `build`/`fix`/`style` commits on the `canary` branch.
|
||||
- May contain **unstable or incomplete changes**. **Use at your own risk.**
|
||||
- It is strongly recommended to **back up your data** before using a canary build.
|
||||
|
||||
### 📦 Installation
|
||||
|
||||
Download the appropriate installer for your platform from the assets below.
|
||||
|
||||
| Platform | File |
|
||||
|----------|------|
|
||||
| macOS (Apple Silicon) | `.dmg` (arm64) |
|
||||
| macOS (Intel) | `.dmg` (x64) |
|
||||
| Windows | `.exe` |
|
||||
| Linux | `.AppImage` / `.deb` |
|
||||
body: ${{ needs.calculate-version.outputs.release_notes }}
|
||||
files: |
|
||||
release/latest*
|
||||
release/*.dmg*
|
||||
|
||||
@@ -1,427 +0,0 @@
|
||||
name: Release Desktop Nightly
|
||||
|
||||
# ============================================
|
||||
# Nightly 自动发版工作流
|
||||
# ============================================
|
||||
# 触发条件:
|
||||
# 1. 定时: 每天 UTC+8 14:00 (UTC 06:00)
|
||||
# 2. 手动触发 (workflow_dispatch)
|
||||
#
|
||||
# 版本策略:
|
||||
# 基于最新 tag 的 minor+1, 格式: X.(Y+1).0-nightly.YYYYMMDDHHMM
|
||||
# 例: 当前 tag v2.0.12 → v2.1.0-nightly.202502091400
|
||||
# 使用精确到分钟的时间戳避免同一天多次触发时 tag 冲突
|
||||
# ============================================
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force:
|
||||
description: 'Force build (skip diff check)'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: read-all
|
||||
|
||||
env:
|
||||
NODE_VERSION: '24.11.1'
|
||||
|
||||
jobs:
|
||||
# ============================================
|
||||
# 计算 Nightly 版本号
|
||||
# ============================================
|
||||
calculate-version:
|
||||
name: Calculate Nightly Version
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
has_changes: ${{ steps.changes.outputs.has_changes }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check for code changes since last nightly
|
||||
id: changes
|
||||
run: |
|
||||
# 手动触发 + force 时跳过 diff 检查
|
||||
if [ "${{ inputs.force }}" == "true" ]; then
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
echo "🔧 Force build requested, skipping diff check"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 查找上一个 nightly tag
|
||||
last_nightly=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+-nightly\.' | head -n 1)
|
||||
|
||||
if [ -z "$last_nightly" ]; then
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
echo "📦 No previous nightly tag found, proceeding with first nightly build"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "📌 Last nightly tag: $last_nightly"
|
||||
|
||||
# 对比指定目录是否有变更
|
||||
changes=$(git diff --name-only "$last_nightly"..HEAD -- package.json src/ packages/ apps/desktop/)
|
||||
|
||||
if [ -z "$changes" ]; then
|
||||
echo "has_changes=false" >> $GITHUB_OUTPUT
|
||||
echo "⏭️ No code changes since $last_nightly, skipping nightly build"
|
||||
else
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
change_count=$(echo "$changes" | wc -l | tr -d ' ')
|
||||
echo "✅ ${change_count} file(s) changed since $last_nightly:"
|
||||
echo "$changes" | head -20
|
||||
[ "$change_count" -gt 20 ] && echo " ... and $((change_count - 20)) more"
|
||||
fi
|
||||
|
||||
- name: Calculate nightly version
|
||||
if: steps.changes.outputs.has_changes == 'true'
|
||||
id: version
|
||||
run: |
|
||||
# 获取最新的 tag (排除 nightly tag)
|
||||
latest_tag=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1)
|
||||
|
||||
if [ -z "$latest_tag" ]; then
|
||||
echo "❌ No stable tag found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "📌 Latest stable tag: $latest_tag"
|
||||
|
||||
# 去掉 v 前缀
|
||||
base_version="${latest_tag#v}"
|
||||
|
||||
# 解析 major.minor.patch
|
||||
IFS='.' read -r major minor patch <<< "$base_version"
|
||||
|
||||
# minor + 1, patch 归零
|
||||
new_minor=$((minor + 1))
|
||||
timestamp=$(date -u +"%Y%m%d%H%M")
|
||||
|
||||
version="${major}.${new_minor}.0-nightly.${timestamp}"
|
||||
tag="v${version}"
|
||||
|
||||
echo "version=${version}" >> $GITHUB_OUTPUT
|
||||
echo "tag=${tag}" >> $GITHUB_OUTPUT
|
||||
echo "✅ Nightly version: ${version}"
|
||||
echo "🏷️ Tag: ${tag}"
|
||||
|
||||
# ============================================
|
||||
# 代码质量检查
|
||||
# ============================================
|
||||
test:
|
||||
name: Code quality check
|
||||
needs: [calculate-version]
|
||||
if: needs.calculate-version.outputs.has_changes == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
|
||||
# ============================================
|
||||
# 多平台构建
|
||||
# ============================================
|
||||
build:
|
||||
needs: [calculate-version, test]
|
||||
if: needs.calculate-version.outputs.has_changes == 'true'
|
||||
name: Build Desktop App
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-15, macos-15-intel, windows-2025, ubuntu-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup build environment
|
||||
uses: ./.github/actions/desktop-build-setup
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Set package version
|
||||
run: npm run workflow:set-desktop-version ${{ needs.calculate-version.outputs.version }} nightly
|
||||
|
||||
# macOS 构建前清理 (修复 hdiutil 问题 https://github.com/electron-userland/electron-builder/issues/8415)
|
||||
- name: Clean previous build artifacts (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
sudo rm -rf apps/desktop/release || true
|
||||
sudo rm -rf apps/desktop/dist || true
|
||||
sudo rm -rf /tmp/electron-builder* || true
|
||||
|
||||
# macOS 构建
|
||||
- name: Build artifact on macOS
|
||||
if: runner.os == 'macOS'
|
||||
run: npm run desktop:package:app
|
||||
env:
|
||||
UPDATE_CHANNEL: nightly
|
||||
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
CSC_FOR_PULL_REQUEST: true
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
|
||||
|
||||
# Windows 构建
|
||||
- name: Build artifact on Windows
|
||||
if: runner.os == 'Windows'
|
||||
run: npm run desktop:package:app
|
||||
env:
|
||||
UPDATE_CHANNEL: nightly
|
||||
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
|
||||
TEMP: C:\temp
|
||||
TMP: C:\temp
|
||||
|
||||
# Linux 构建
|
||||
- name: Build artifact on Linux
|
||||
if: runner.os == 'Linux'
|
||||
run: npm run desktop:package:app
|
||||
env:
|
||||
UPDATE_CHANNEL: nightly
|
||||
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: ./.github/actions/desktop-upload-artifacts
|
||||
with:
|
||||
artifact-name: release-${{ matrix.os }}
|
||||
retention-days: 3
|
||||
|
||||
# ============================================
|
||||
# 合并 macOS 多架构 latest-mac.yml 文件
|
||||
# ============================================
|
||||
merge-mac-files:
|
||||
needs: [build]
|
||||
name: Merge macOS Release Files
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
path: release
|
||||
pattern: release-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: List downloaded artifacts
|
||||
run: ls -R release
|
||||
|
||||
- name: Install yaml only for merge step
|
||||
run: |
|
||||
cd scripts/electronWorkflow
|
||||
if [ ! -f package.json ]; then
|
||||
echo '{"name":"merge-mac-release","private":true}' > package.json
|
||||
fi
|
||||
bun add --no-save yaml@2.8.1
|
||||
|
||||
- name: Merge latest-mac.yml files
|
||||
run: bun run scripts/electronWorkflow/mergeMacReleaseFiles.js
|
||||
|
||||
- name: Upload artifacts with merged macOS files
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: merged-release
|
||||
path: release/
|
||||
retention-days: 1
|
||||
|
||||
# ============================================
|
||||
# 创建 Nightly Release
|
||||
# ============================================
|
||||
publish-release:
|
||||
needs: [merge-mac-files, calculate-version]
|
||||
name: Publish Nightly Release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download merged artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: merged-release
|
||||
path: release
|
||||
|
||||
- name: List final artifacts
|
||||
run: ls -R release
|
||||
|
||||
- name: Create Nightly Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ needs.calculate-version.outputs.tag }}
|
||||
name: 'Desktop Nightly ${{ needs.calculate-version.outputs.tag }}'
|
||||
prerelease: true
|
||||
body: |
|
||||
## 🌙 Nightly Build — ${{ needs.calculate-version.outputs.tag }}
|
||||
|
||||
> Automated nightly build from `main` branch.
|
||||
|
||||
### ⚠️ Important Notes
|
||||
|
||||
- **This is an automated nightly build and is NOT intended for production use.**
|
||||
- Nightly builds are generated from the latest `main` branch and may contain **unstable, untested, or incomplete features**.
|
||||
- **No guarantees** are made regarding stability, data integrity, or backward compatibility.
|
||||
- Bugs, crashes, and breaking changes are expected. **Use at your own risk.**
|
||||
- **Do NOT report bugs** from nightly builds unless you can reproduce them on the latest beta or stable release.
|
||||
- Nightly builds may have **different update channels** — they will not auto-update to/from stable or beta versions.
|
||||
- It is strongly recommended to **back up your data** before using a nightly build.
|
||||
|
||||
### 📦 Installation
|
||||
|
||||
Download the appropriate installer for your platform from the assets below.
|
||||
|
||||
| Platform | File |
|
||||
|----------|------|
|
||||
| macOS (Apple Silicon) | `.dmg` (arm64) |
|
||||
| macOS (Intel) | `.dmg` (x64) |
|
||||
| Windows | `.exe` |
|
||||
| Linux | `.AppImage` / `.deb` |
|
||||
files: |
|
||||
release/latest*
|
||||
release/*.dmg*
|
||||
release/*.zip*
|
||||
release/*.exe*
|
||||
release/*.AppImage
|
||||
release/*.deb*
|
||||
release/*.snap*
|
||||
release/*.rpm*
|
||||
release/*.tar.gz*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# ============================================
|
||||
# 发布到 S3 更新服务器
|
||||
# ============================================
|
||||
publish-s3:
|
||||
needs: [merge-mac-files, calculate-version]
|
||||
name: Publish to S3
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: ./.github/actions/desktop-publish-s3
|
||||
with:
|
||||
channel: nightly
|
||||
version: ${{ needs.calculate-version.outputs.version }}
|
||||
aws-access-key-id: ${{ secrets.UPDATE_AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.UPDATE_AWS_SECRET_ACCESS_KEY }}
|
||||
s3-bucket: ${{ secrets.UPDATE_S3_BUCKET }}
|
||||
s3-region: ${{ secrets.UPDATE_S3_REGION }}
|
||||
s3-endpoint: ${{ secrets.UPDATE_S3_ENDPOINT }}
|
||||
|
||||
# ============================================
|
||||
# 清理旧的 Nightly Releases (保留最近 7 个)
|
||||
# ============================================
|
||||
cleanup-old-nightlies:
|
||||
needs: [publish-release, publish-s3]
|
||||
name: Cleanup Old Nightly Releases
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Delete old nightly GitHub releases
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { data: releases } = await github.rest.repos.listReleases({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const nightlyReleases = releases
|
||||
.filter(r => r.tag_name.includes('-nightly.'))
|
||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
|
||||
const toDelete = nightlyReleases.slice(7);
|
||||
|
||||
for (const release of toDelete) {
|
||||
console.log(`🗑️ Deleting old nightly release: ${release.tag_name}`);
|
||||
|
||||
// Delete the release
|
||||
await github.rest.repos.deleteRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: release.id,
|
||||
});
|
||||
|
||||
// Delete the tag
|
||||
try {
|
||||
await github.rest.git.deleteRef({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
ref: `tags/${release.tag_name}`,
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(`⚠️ Could not delete tag ${release.tag_name}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ Cleanup complete. Kept ${Math.min(nightlyReleases.length, 7)} nightly releases, deleted ${toDelete.length}.`);
|
||||
|
||||
- name: Cleanup old S3 versions
|
||||
uses: ./.github/actions/desktop-cleanup-s3
|
||||
with:
|
||||
channel: nightly
|
||||
keep-count: '15'
|
||||
aws-access-key-id: ${{ secrets.UPDATE_AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.UPDATE_AWS_SECRET_ACCESS_KEY }}
|
||||
s3-bucket: ${{ secrets.UPDATE_S3_BUCKET }}
|
||||
s3-region: ${{ secrets.UPDATE_S3_REGION }}
|
||||
s3-endpoint: ${{ secrets.UPDATE_S3_ENDPOINT }}
|
||||
@@ -266,16 +266,10 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
name: Release ModelBank
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- canary
|
||||
paths:
|
||||
- packages/model-bank/**
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ModelBank
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build package
|
||||
run: pnpm --filter model-bank build
|
||||
|
||||
publish:
|
||||
name: Publish ModelBank
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Bump patch version
|
||||
id: version
|
||||
run: |
|
||||
npm version patch --no-git-tag-version --prefix packages/model-bank
|
||||
echo "version=$(node -p 'require(\"./packages/model-bank/package.json\").version')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build package
|
||||
run: pnpm --filter model-bank build
|
||||
|
||||
- name: Publish to npm
|
||||
run: npm publish --provenance
|
||||
working-directory: packages/model-bank
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Commit version bump
|
||||
env:
|
||||
MODEL_BANK_VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
git config user.name "lobehubbot"
|
||||
git config user.email "i@lobehub.com"
|
||||
git add packages/model-bank/package.json
|
||||
git commit -m "🔖 chore(model-bank): release v${MODEL_BANK_VERSION}"
|
||||
git push
|
||||
@@ -37,19 +37,11 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
run: pnpm install
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
|
||||
@@ -15,15 +15,13 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ secrets.BUN_VERSION }}
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
run: pnpm install
|
||||
|
||||
- name: sync database schema to dbdocs
|
||||
env:
|
||||
DBDOCS_TOKEN: ${{ secrets.DBDOCS_TOKEN }}
|
||||
run: npm run db:visualize
|
||||
run: bun run db:visualize
|
||||
|
||||
+14
-46
@@ -37,19 +37,11 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ secrets.BUN_VERSION }}
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
run: pnpm install
|
||||
|
||||
- name: Test packages with coverage
|
||||
run: |
|
||||
@@ -111,19 +103,11 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
run: pnpm install
|
||||
|
||||
- name: Run tests
|
||||
run: bunx vitest --coverage --silent='passed-only' --reporter=default --reporter=blob --shard=${{ matrix.shard }}/2
|
||||
@@ -146,13 +130,11 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
run: pnpm install
|
||||
|
||||
- name: Download blob reports
|
||||
uses: actions/download-artifact@v7
|
||||
@@ -181,16 +163,8 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 10
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install deps
|
||||
run: pnpm install
|
||||
@@ -235,20 +209,14 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install deps
|
||||
run: pnpm i
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
run: bun run lint
|
||||
|
||||
- name: Test Coverage
|
||||
run: pnpm --filter @lobechat/database test:coverage
|
||||
|
||||
+13
-1
@@ -25,6 +25,9 @@ Desktop.ini
|
||||
*.code-workspace
|
||||
.vscode/sessions.json
|
||||
prd
|
||||
# Recordings
|
||||
.records/
|
||||
|
||||
# Temporary files
|
||||
.temp/
|
||||
temp/
|
||||
@@ -52,6 +55,7 @@ bun.lockb
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
public/_spa/
|
||||
public/spa/
|
||||
es/
|
||||
lib/
|
||||
@@ -134,4 +138,12 @@ i18n-unused-keys-report.json
|
||||
|
||||
pnpm-lock.yaml
|
||||
.turbo
|
||||
spaHtmlTemplates.ts
|
||||
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/
|
||||
docs/superpowers/
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
const { defineConfig } = require('@lobehub/i18n-cli');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
module.exports = defineConfig({
|
||||
entry: 'locales/en-US',
|
||||
@@ -27,14 +27,14 @@ module.exports = defineConfig({
|
||||
],
|
||||
temperature: 0,
|
||||
saveImmediately: true,
|
||||
modelName: 'chatgpt-4o-latest',
|
||||
modelName: 'gpt-5.1-chat-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'), 'utf-8'),
|
||||
fs.readFileSync(path.join(__dirname, 'docs/glossary.md'), 'utf8'),
|
||||
entry: ['./README.md', './docs/**/*.md', './docs/**/*.mdx'],
|
||||
entryLocale: 'en-US',
|
||||
outputLocales: ['zh-CN'],
|
||||
|
||||
@@ -47,6 +47,7 @@ lobehub/
|
||||
- Git commit messages should prefix with gitmoji
|
||||
- Git branch name format: `feat/feature-name`
|
||||
- Use `.github/PULL_REQUEST_TEMPLATE.md` for PR descriptions
|
||||
- **Protection of local changes**: Never use `git restore`, `git checkout --`, `git reset --hard`, or any other command or workflow that can forcibly overwrite, discard, or silently replace user-owned uncommitted changes. Before any revert or restoration affecting existing files, inspect the working tree carefully and obtain explicit user confirmation.
|
||||
|
||||
### Package Management
|
||||
|
||||
@@ -89,7 +90,8 @@ cd packages/[package-name] && bunx vitest run --silent='passed-only' '[file-path
|
||||
|
||||
- **`src/routes/`** holds only page segments (`_layout/index.tsx`, `index.tsx`, `[id]/index.tsx`). Keep route files **thin** — import from `@/features/*` and compose, no business logic.
|
||||
- **`src/features/`** holds business components by **domain** (e.g. `Pages`, `PageEditor`, `Home`). Layout pieces, hooks, and domain UI go here.
|
||||
- See the **spa-routes** skill for the full convention and file-division rules.
|
||||
- **Desktop router parity:** When changing the main SPA route tree, update **both** `src/spa/router/desktopRouter.config.tsx` (dynamic imports) and `src/spa/router/desktopRouter.config.desktop.tsx` (sync imports) so paths and nesting match. Changing only one can leave routes unregistered and cause **blank screens**.
|
||||
- See the **spa-routes** skill (`.agents/skills/spa-routes/SKILL.md`) for the full convention and file-division rules.
|
||||
|
||||
## Skills (Auto-loaded)
|
||||
|
||||
|
||||
@@ -2,6 +2,51 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
## [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">
|
||||
|
||||
[](#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>
|
||||
|
||||
@@ -60,6 +60,7 @@ When adding or changing SPA routes:
|
||||
1. In `src/routes/`, add only the route segment files (layout + page) that delegate to features.
|
||||
2. Implement layout and page content under `src/features/<Domain>/` and export from there.
|
||||
3. In route files, use `import { X } from '@/features/<Domain>'` (or `import Y from '@/features/<Domain>/...'`). Do not add new `features/` folders inside `src/routes/`.
|
||||
4. **Register the desktop route tree in both configs:** `src/spa/router/desktopRouter.config.tsx` and `src/spa/router/desktopRouter.config.desktop.tsx` must stay in sync (same paths and nesting). Updating only one can cause **blank screens** if the other build path expects the route.
|
||||
|
||||
See the **spa-routes** skill (`.agents/skills/spa-routes/SKILL.md`) for the full convention and file-division rules.
|
||||
|
||||
|
||||
+7
-7
@@ -1,8 +1,8 @@
|
||||
# Lobe Chat - Contributing Guide 🌟
|
||||
# LobeHub - Contributing Guide 🌟
|
||||
|
||||
We're thrilled that you want to contribute to Lobe Chat, the future of communication! 😄
|
||||
We're thrilled that you want to contribute to LobeHub, the future of communication! 😄
|
||||
|
||||
Lobe Chat is an open-source project, and we welcome your collaboration. Before you jump in, let's make sure you're all set to contribute effectively and have loads of fun along the way!
|
||||
LobeHub is an open-source project, and we welcome your collaboration. Before you jump in, let's make sure you're all set to contribute effectively and have loads of fun along the way!
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -69,11 +69,11 @@ git fetch upstream
|
||||
git merge upstream/main
|
||||
```
|
||||
|
||||
This ensures you're working on the most current version of Lobe Chat. Stay fresh! 💨
|
||||
This ensures you're working on the most current version of LobeHub. Stay fresh! 💨
|
||||
|
||||
## Open a Pull Request
|
||||
|
||||
🚀 Time to share your contribution! Head over to the original Lobe Chat repository and open a Pull Request (PR). Our maintainers will review your work.
|
||||
🚀 Time to share your contribution! Head over to the original LobeHub repository and open a Pull Request (PR). Our maintainers will review your work.
|
||||
|
||||
## Review and Collaboration
|
||||
|
||||
@@ -81,8 +81,8 @@ This ensures you're working on the most current version of Lobe Chat. Stay fresh
|
||||
|
||||
## Celebrate 🎉
|
||||
|
||||
🎈 Congratulations! Your contribution is now part of Lobe Chat. 🥳
|
||||
🎈 Congratulations! Your contribution is now part of LobeHub. 🥳
|
||||
|
||||
Thank you for making Lobe Chat even more magical. We can't wait to see what you create! 🌠
|
||||
Thank you for making LobeHub even more magical. We can't wait to see what you create! 🌠
|
||||
|
||||
Happy Coding! 🚀🦄
|
||||
|
||||
+1
-1
@@ -111,7 +111,7 @@ COPY --from=base /distroless/ /
|
||||
COPY --from=builder /app/.next/standalone /app/
|
||||
COPY --from=builder /app/.next/static /app/.next/static
|
||||
# Copy SPA assets (Vite build output)
|
||||
COPY --from=builder /app/public/spa /app/public/spa
|
||||
COPY --from=builder /app/public/_spa /app/public/_spa
|
||||
# Copy database migrations
|
||||
COPY --from=builder /app/packages/database/migrations /app/migrations
|
||||
COPY --from=builder /app/scripts/migrateServerDB/docker.cjs /app/docker.cjs
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
We only provide security fixes for the **latest 2.x release**. Older versions (including all 1.x releases) are end-of-life and will not receive patches.
|
||||
|
||||
| Version | Supported |
|
||||
| ------------ | --------- |
|
||||
| 2.x (latest) | ✅ |
|
||||
| 1.x | ❌ |
|
||||
| 0.x | ❌ |
|
||||
|
||||
If you are running a 1.x deployment, we strongly recommend upgrading to the latest 2.x release.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report security vulnerabilities through the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/lobehub/lobehub/security/advisories/new) tab.
|
||||
|
||||
**Please do not report security vulnerabilities through public GitHub issues.**
|
||||
|
||||
### Response Timeline
|
||||
|
||||
- **Acknowledgement**: We aim to respond to all reports within **7 days**.
|
||||
- **Fix**: Confirmed vulnerabilities will be addressed within **30 days**.
|
||||
- **Urgent issues**: If you believe the vulnerability is critical and actively exploitable, you can reach out directly on Discord (`arvinxu`) for faster coordination.
|
||||
|
||||
### What to Include
|
||||
|
||||
A good vulnerability report should include:
|
||||
|
||||
- A clear description of the issue and its potential impact
|
||||
- The affected version (must be the latest 2.x release)
|
||||
- Step-by-step reproduction instructions or a working PoC
|
||||
- Any relevant logs, screenshots, or code references
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope
|
||||
|
||||
- Security issues affecting the **latest 2.x release** of LobeHub
|
||||
- Vulnerabilities in the **server-side deployment** (LobeHub Cloud or self-hosted server mode)
|
||||
- Issues that can be exploited **without requiring admin/owner access** to the deployment
|
||||
|
||||
### Out of Scope (Not a Vulnerability)
|
||||
|
||||
The following are considered **by design** or **out of scope** and will not be accepted as vulnerability reports:
|
||||
|
||||
#### 1. End-of-Life Versions
|
||||
|
||||
Any issue that only affects 1.x or earlier versions. This includes but is not limited to the `X-lobe-chat-auth` header mechanism, `webapi` route authentication, and other 1.x-specific architectures that have been completely removed in 2.x.
|
||||
|
||||
#### 2. File Proxy Public Access (`/f/:id`)
|
||||
|
||||
The file proxy endpoint `/f/:id` uses randomly generated, non-enumerable IDs as [capability URLs](https://www.w3.org/TR/capability-urls/). This is a deliberate design choice, similar to how S3 presigned URLs or Google Docs sharing links work. Knowing the URL grants access — this is by design, not an authorization bypass.
|
||||
|
||||
#### 3. User Enumeration on Login Flows
|
||||
|
||||
Endpoints such as `check-user` that indicate whether an account exists are part of the standard login UX. This is a common and intentional pattern used by most modern authentication flows.
|
||||
|
||||
#### 4. Self-Hosted Client-Side API Key Storage
|
||||
|
||||
In self-hosted client-side mode, users configure their own API keys which are stored in the browser's local storage. This is the expected behavior for client-side deployments where the user is both the operator and the consumer.
|
||||
|
||||
#### 5. Issues Requiring Admin or Owner Privileges
|
||||
|
||||
Actions that require administrative access to the deployment (e.g., environment variable configuration, server-side settings) are not considered security vulnerabilities, as the admin is already a trusted party.
|
||||
|
||||
#### 6. Theoretical Attacks Without Practical Impact
|
||||
|
||||
Reports based on theoretical attack scenarios without a working proof of concept against a realistic deployment, or issues that require unlikely preconditions (e.g., physical access to the server, pre-existing compromise of the host system).
|
||||
|
||||
## Disclosure Policy
|
||||
|
||||
- We follow [coordinated vulnerability disclosure](https://en.wikipedia.org/wiki/Coordinated_vulnerability_disclosure).
|
||||
- We will credit reporters in the security advisory unless they prefer to remain anonymous.
|
||||
- Please allow us reasonable time to address the issue before any public disclosure.
|
||||
|
||||
## Contact
|
||||
|
||||
- **Primary**: [GitHub Security Advisories](https://github.com/lobehub/lobehub/security/advisories/new)
|
||||
- **Urgent**: Discord — `arvinxu`
|
||||
@@ -15,6 +15,17 @@ LobeHub command-line interface.
|
||||
- To make `lh` available in your shell, run `bun run cli:link`.
|
||||
- After linking, if your shell still cannot find `lh`, run `rehash` in `zsh`.
|
||||
|
||||
## Custom Server URL
|
||||
|
||||
By default the CLI connects to `https://app.lobehub.com`. To point it at a different server (e.g. a local instance):
|
||||
|
||||
| Method | Command | Persistence |
|
||||
| -------------------- | --------------------------------------------------------------- | ----------------------------------- |
|
||||
| Environment variable | `LOBEHUB_SERVER=http://localhost:4000 bun run dev -- <command>` | Current command only |
|
||||
| Login flag | `lh login --server http://localhost:4000` | Saved to `~/.lobehub/settings.json` |
|
||||
|
||||
Priority: `LOBEHUB_SERVER` env var > `settings.json` > default official URL.
|
||||
|
||||
## Shell Completion
|
||||
|
||||
### Install completion for a linked CLI
|
||||
|
||||
@@ -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.1\-canary.12" "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
|
||||
@@ -27,7 +27,7 @@ For command-specific manuals, use the built-in manual command:
|
||||
.SH COMMANDS
|
||||
.TP
|
||||
.B login
|
||||
Log in to LobeHub via browser (Device Code Flow)
|
||||
Log in to LobeHub via browser (Device Code Flow) or configure API key server
|
||||
.TP
|
||||
.B logout
|
||||
Log out and remove stored credentials
|
||||
@@ -83,6 +83,9 @@ Manage agent skills
|
||||
.B session\-group
|
||||
Manage agent session groups
|
||||
.TP
|
||||
.B task
|
||||
Manage agent tasks
|
||||
.TP
|
||||
.B thread
|
||||
Manage message threads
|
||||
.TP
|
||||
@@ -112,6 +115,9 @@ View usage statistics
|
||||
.TP
|
||||
.B eval
|
||||
Manage evaluation workflows
|
||||
.TP
|
||||
.B migrate
|
||||
Migrate data from external tools (OpenClaw, ChatGPT, Claude, etc.)
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
.B \-V, \-\-version
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lobehub/cli",
|
||||
"version": "0.0.1-canary.14",
|
||||
"version": "0.0.3",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"lh": "./dist/index.js",
|
||||
@@ -37,6 +37,7 @@
|
||||
"debug": "^4.4.0",
|
||||
"diff": "^8.0.3",
|
||||
"fast-glob": "^3.3.3",
|
||||
"ignore": "^7.0.5",
|
||||
"picocolors": "^1.1.1",
|
||||
"superjson": "^2.2.6",
|
||||
"tsdown": "^0.21.4",
|
||||
|
||||
@@ -39,7 +39,9 @@ async function getAuthAndServer() {
|
||||
|
||||
const result = await getValidToken();
|
||||
if (!result) {
|
||||
log.error(`No authentication found. Run 'lh login' first, or set ${CLI_API_KEY_ENV}.`);
|
||||
log.error(
|
||||
`No authentication found. Run 'lh login' (or 'npx -y @lobehub/cli login') first, or set ${CLI_API_KEY_ENV}.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
+37
-22
@@ -3,29 +3,9 @@ import { CLI_API_KEY_ENV } from '../constants/auth';
|
||||
import { resolveServerUrl } from '../settings';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
// Must match the server's SECRET_XOR_KEY (src/envs/auth.ts)
|
||||
const SECRET_XOR_KEY = 'LobeHub · LobeHub';
|
||||
|
||||
/**
|
||||
* XOR-obfuscate a payload and encode as Base64.
|
||||
* The /webapi/* routes require `X-lobe-chat-auth` with this encoding.
|
||||
*/
|
||||
function obfuscatePayloadWithXOR(payload: Record<string, any>): string {
|
||||
const jsonString = JSON.stringify(payload);
|
||||
const dataBytes = new TextEncoder().encode(jsonString);
|
||||
const keyBytes = new TextEncoder().encode(SECRET_XOR_KEY);
|
||||
|
||||
const result = new Uint8Array(dataBytes.length);
|
||||
for (let i = 0; i < dataBytes.length; i++) {
|
||||
result[i] = dataBytes[i] ^ keyBytes[i % keyBytes.length];
|
||||
}
|
||||
|
||||
return btoa(String.fromCharCode(...result));
|
||||
}
|
||||
|
||||
export interface AuthInfo {
|
||||
accessToken: string;
|
||||
/** Headers required for /webapi/* endpoints (includes both X-lobe-chat-auth and Oidc-Auth) */
|
||||
/** Headers required for /webapi/* endpoints (Oidc-Auth for authentication) */
|
||||
headers: Record<string, string>;
|
||||
serverUrl: string;
|
||||
}
|
||||
@@ -52,8 +32,43 @@ export async function getAuthInfo(): Promise<AuthInfo> {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Oidc-Auth': accessToken,
|
||||
'X-lobe-chat-auth': obfuscatePayloadWithXOR({}),
|
||||
},
|
||||
serverUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAgentStreamAuthInfo(): Promise<Pick<AuthInfo, 'headers' | 'serverUrl'>> {
|
||||
const serverUrl = resolveServerUrl();
|
||||
|
||||
const envJwt = process.env.LOBEHUB_JWT;
|
||||
if (envJwt) {
|
||||
return {
|
||||
headers: { 'Oidc-Auth': envJwt },
|
||||
serverUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const envApiKey = process.env[CLI_API_KEY_ENV];
|
||||
if (envApiKey) {
|
||||
return {
|
||||
headers: { 'X-API-Key': envApiKey },
|
||||
serverUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await getValidToken();
|
||||
if (!result) {
|
||||
log.error(`No authentication found. Run 'lh login' first, or set ${CLI_API_KEY_ENV}.`);
|
||||
process.exit(1);
|
||||
|
||||
return {
|
||||
headers: {},
|
||||
serverUrl,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
headers: { 'Oidc-Auth': result.credentials.accessToken },
|
||||
serverUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
execAgent: { mutate: vi.fn() },
|
||||
getOperationStatus: { query: vi.fn() },
|
||||
},
|
||||
device: {
|
||||
listDevices: { query: vi.fn() },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -38,13 +41,18 @@ const { mockStreamAgentEvents } = vi.hoisted(() => ({
|
||||
mockStreamAgentEvents: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockGetAuthInfo } = vi.hoisted(() => ({
|
||||
mockGetAuthInfo: vi.fn(),
|
||||
const { mockGetAgentStreamAuthInfo } = vi.hoisted(() => ({
|
||||
mockGetAgentStreamAuthInfo: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockResolveLocalDeviceId } = vi.hoisted(() => ({
|
||||
mockResolveLocalDeviceId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient }));
|
||||
vi.mock('../api/http', () => ({ getAuthInfo: mockGetAuthInfo }));
|
||||
vi.mock('../api/http', () => ({ getAgentStreamAuthInfo: mockGetAgentStreamAuthInfo }));
|
||||
vi.mock('../utils/agentStream', () => ({ streamAgentEvents: mockStreamAgentEvents }));
|
||||
vi.mock('../utils/device', () => ({ resolveLocalDeviceId: mockResolveLocalDeviceId }));
|
||||
vi.mock('../utils/logger', () => ({
|
||||
log: { debug: vi.fn(), error: vi.fn(), heartbeat: vi.fn(), info: vi.fn(), warn: vi.fn() },
|
||||
setVerbose: vi.fn(),
|
||||
@@ -58,12 +66,12 @@ describe('agent command', () => {
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
|
||||
mockGetAuthInfo.mockResolvedValue({
|
||||
accessToken: 'test-token',
|
||||
headers: { 'Content-Type': 'application/json', 'Oidc-Auth': 'test-token' },
|
||||
mockGetAgentStreamAuthInfo.mockResolvedValue({
|
||||
headers: { 'Oidc-Auth': 'test-token' },
|
||||
serverUrl: 'https://example.com',
|
||||
});
|
||||
mockStreamAgentEvents.mockResolvedValue(undefined);
|
||||
mockResolveLocalDeviceId.mockReset();
|
||||
for (const method of Object.values(mockTrpcClient.agent)) {
|
||||
for (const fn of Object.values(method)) {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
@@ -74,6 +82,11 @@ describe('agent command', () => {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
}
|
||||
}
|
||||
for (const method of Object.values(mockTrpcClient.device)) {
|
||||
for (const fn of Object.values(method)) {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -297,7 +310,6 @@ describe('agent command', () => {
|
||||
expect.objectContaining({ json: undefined, verbose: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should support --slug option', async () => {
|
||||
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
|
||||
operationId: 'op-456',
|
||||
@@ -384,6 +396,186 @@ describe('agent command', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass --device local as deviceId', async () => {
|
||||
mockResolveLocalDeviceId.mockReturnValue('local-device-1');
|
||||
mockTrpcClient.device.listDevices.query.mockResolvedValue([
|
||||
{ deviceId: 'local-device-1', online: true },
|
||||
]);
|
||||
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
|
||||
operationId: 'op-device',
|
||||
success: true,
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'run',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--prompt',
|
||||
'Hi',
|
||||
'--device',
|
||||
'local',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.aiAgent.execAgent.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'a1', deviceId: 'local-device-1', prompt: 'Hi' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass --topic-id and --device local together', async () => {
|
||||
mockResolveLocalDeviceId.mockReturnValue('local-device-1');
|
||||
mockTrpcClient.device.listDevices.query.mockResolvedValue([
|
||||
{ deviceId: 'local-device-1', online: true },
|
||||
]);
|
||||
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
|
||||
operationId: 'op-topic-device',
|
||||
success: true,
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'run',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--prompt',
|
||||
'Hi',
|
||||
'--topic-id',
|
||||
't1',
|
||||
'--device',
|
||||
'local',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.aiAgent.execAgent.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ appContext: { topicId: 't1' }, deviceId: 'local-device-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass explicit --device id as deviceId', async () => {
|
||||
mockTrpcClient.device.listDevices.query.mockResolvedValue([
|
||||
{ deviceId: 'device-remote-1', online: true },
|
||||
]);
|
||||
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
|
||||
operationId: 'op-explicit-device',
|
||||
success: true,
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'run',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--prompt',
|
||||
'Hi',
|
||||
'--device',
|
||||
'device-remote-1',
|
||||
]);
|
||||
|
||||
expect(mockResolveLocalDeviceId).not.toHaveBeenCalled();
|
||||
expect(mockTrpcClient.aiAgent.execAgent.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'a1', deviceId: 'device-remote-1', prompt: 'Hi' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should exit when explicit device is not found', async () => {
|
||||
mockTrpcClient.device.listDevices.query.mockResolvedValue([
|
||||
{ deviceId: 'other-device', online: true },
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'run',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--prompt',
|
||||
'Hi',
|
||||
'--device',
|
||||
'device-remote-1',
|
||||
]);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('was not found'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should exit when local device cannot be resolved', async () => {
|
||||
mockResolveLocalDeviceId.mockReturnValue(undefined);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'run',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--prompt',
|
||||
'Hi',
|
||||
'--device',
|
||||
'local',
|
||||
]);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining("Run 'lh connect' first"));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should exit when local device is offline', async () => {
|
||||
mockResolveLocalDeviceId.mockReturnValue('local-device-1');
|
||||
mockTrpcClient.device.listDevices.query.mockResolvedValue([
|
||||
{ deviceId: 'local-device-1', online: false },
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'run',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--prompt',
|
||||
'Hi',
|
||||
'--device',
|
||||
'local',
|
||||
]);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('is not online'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should exit when explicit device is offline', async () => {
|
||||
mockTrpcClient.device.listDevices.query.mockResolvedValue([
|
||||
{ deviceId: 'device-remote-1', online: false },
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'run',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--prompt',
|
||||
'Hi',
|
||||
'--device',
|
||||
'device-remote-1',
|
||||
]);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Bring it online'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should pass --json to stream options', async () => {
|
||||
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
|
||||
operationId: 'op-j',
|
||||
|
||||
@@ -4,8 +4,14 @@ import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { getAuthInfo } from '../api/http';
|
||||
import { replayAgentEvents, streamAgentEvents } from '../utils/agentStream';
|
||||
import { getAgentStreamAuthInfo } from '../api/http';
|
||||
import { resolveAgentGatewayUrl } from '../settings';
|
||||
import {
|
||||
replayAgentEvents,
|
||||
streamAgentEvents,
|
||||
streamAgentEventsViaWebSocket,
|
||||
} from '../utils/agentStream';
|
||||
import { resolveLocalDeviceId } from '../utils/device';
|
||||
import { confirm, outputJson, printTable, truncate } from '../utils/format';
|
||||
import { log, setVerbose } from '../utils/logger';
|
||||
|
||||
@@ -248,17 +254,24 @@ export function registerAgentCommand(program: Command) {
|
||||
.option('-p, --prompt <text>', 'User prompt')
|
||||
.option('-t, --topic-id <id>', 'Reuse an existing topic')
|
||||
.option('--no-auto-start', 'Do not auto-start the agent')
|
||||
.option(
|
||||
'--device <target>',
|
||||
'Target device ID, or use "local" for the current connected device',
|
||||
)
|
||||
.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)')
|
||||
.option('--sse', 'Force SSE stream instead of WebSocket gateway')
|
||||
.action(
|
||||
async (options: {
|
||||
agentId?: string;
|
||||
autoStart?: boolean;
|
||||
device?: string;
|
||||
json?: boolean;
|
||||
prompt?: string;
|
||||
replay?: string;
|
||||
slug?: string;
|
||||
sse?: boolean;
|
||||
topicId?: string;
|
||||
verbose?: boolean;
|
||||
}) => {
|
||||
@@ -285,9 +298,45 @@ export function registerAgentCommand(program: Command) {
|
||||
|
||||
const client = await getTrpcClient();
|
||||
|
||||
let deviceId: string | undefined;
|
||||
if (options.device !== undefined) {
|
||||
if (options.device === 'local') {
|
||||
deviceId = resolveLocalDeviceId();
|
||||
if (!deviceId) {
|
||||
log.error(
|
||||
"No local device found. Run 'lh connect' first, then retry with --device local.",
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
deviceId = options.device;
|
||||
}
|
||||
|
||||
const devices = await client.device.listDevices.query();
|
||||
const matchedDevice = devices.find(
|
||||
(device: { deviceId?: string; online?: boolean }) => device.deviceId === deviceId,
|
||||
);
|
||||
if (!matchedDevice) {
|
||||
log.error(`Device "${deviceId}" was not found. Check 'lh device list' and try again.`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
if (!matchedDevice.online) {
|
||||
log.error(
|
||||
options.device === 'local'
|
||||
? `Local device "${deviceId}" is not online. Reconnect with 'lh connect' and try again.`
|
||||
: `Device "${deviceId}" is not online. Bring it online and try again.`,
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Exec agent to get operationId
|
||||
const input: Record<string, any> = { prompt: options.prompt };
|
||||
if (options.agentId) input.agentId = options.agentId;
|
||||
if (deviceId) input.deviceId = deviceId;
|
||||
if (options.slug) input.slug = options.slug;
|
||||
if (options.topicId) input.appContext = { topicId: options.topicId };
|
||||
if (options.autoStart === false) input.autoStart = false;
|
||||
@@ -305,14 +354,26 @@ export function registerAgentCommand(program: Command) {
|
||||
log.info(`Operation: ${pc.dim(operationId)} · Topic: ${pc.dim(r.topicId || 'n/a')}`);
|
||||
}
|
||||
|
||||
// 2. Connect to SSE stream
|
||||
const { serverUrl, headers } = await getAuthInfo();
|
||||
const streamUrl = `${serverUrl}/api/agent/stream?operationId=${encodeURIComponent(operationId)}`;
|
||||
// 2. Connect to stream (WebSocket via Gateway, or fallback to SSE)
|
||||
const { serverUrl, headers } = await getAgentStreamAuthInfo();
|
||||
const agentGatewayUrl = options.sse ? undefined : resolveAgentGatewayUrl();
|
||||
|
||||
await streamAgentEvents(streamUrl, headers, {
|
||||
json: options.json,
|
||||
verbose: options.verbose,
|
||||
});
|
||||
if (agentGatewayUrl) {
|
||||
const token = headers['Oidc-Auth'] || headers['X-API-Key'] || '';
|
||||
await streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: agentGatewayUrl,
|
||||
json: options.json,
|
||||
operationId,
|
||||
token,
|
||||
verbose: options.verbose,
|
||||
});
|
||||
} else {
|
||||
const streamUrl = `${serverUrl}/api/agent/stream?operationId=${encodeURIComponent(operationId)}`;
|
||||
await streamAgentEvents(streamUrl, headers, {
|
||||
json: options.json,
|
||||
verbose: options.verbose,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+242
-99
@@ -1,39 +1,130 @@
|
||||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import type { TrpcClient } from '../api/client';
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { confirm, outputJson, printTable } from '../utils/format';
|
||||
import { confirm, outputJson, printBoxTable, printTable, timeAgo } from '../utils/format';
|
||||
import { log } from '../utils/logger';
|
||||
import { registerBotMessageCommands } from './botMessage';
|
||||
|
||||
const SUPPORTED_PLATFORMS = ['discord', 'slack', 'telegram', 'lark', 'feishu', 'wechat'];
|
||||
// ── Helpers ──────────────────────────────────────────────
|
||||
|
||||
const PLATFORM_CREDENTIAL_FIELDS: Record<string, string[]> = {
|
||||
discord: ['botToken', 'publicKey'],
|
||||
feishu: ['appSecret'],
|
||||
lark: ['appSecret'],
|
||||
slack: ['botToken', 'signingSecret'],
|
||||
telegram: ['botToken'],
|
||||
wechat: ['botToken', 'botId'],
|
||||
function maskValue(val: string): string {
|
||||
if (val.length > 8) return val.slice(0, 4) + '****' + val.slice(-4);
|
||||
return '****';
|
||||
}
|
||||
|
||||
function camelToFlag(name: string): string {
|
||||
return '--' + name.replaceAll(/([A-Z])/g, '-$1').toLowerCase();
|
||||
}
|
||||
|
||||
/** Extract credential field definitions from a platform schema. */
|
||||
function getCredentialFields(platformDef: any): any[] {
|
||||
const credSchema = (platformDef.schema ?? []).find(
|
||||
(f: any) => f.key === 'credentials' && f.properties,
|
||||
);
|
||||
return credSchema?.properties ?? [];
|
||||
}
|
||||
|
||||
/** Extract credential values from CLI options based on platform schema. */
|
||||
function extractCredentials(
|
||||
platformDef: any,
|
||||
options: Record<string, any>,
|
||||
): { credentials: Record<string, string>; missing: any[] } {
|
||||
const fields = getCredentialFields(platformDef);
|
||||
const credentials: Record<string, string> = {};
|
||||
|
||||
for (const field of fields) {
|
||||
const value = options[field.key];
|
||||
if (typeof value === 'string') {
|
||||
credentials[field.key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const missing = fields.filter((f: any) => f.required && !credentials[f.key]);
|
||||
return { credentials, missing };
|
||||
}
|
||||
|
||||
/** Find a bot by ID from the user's bot list. */
|
||||
async function findBot(client: TrpcClient, botId: string) {
|
||||
const bots = await client.agentBotProvider.list.query();
|
||||
const bot = (bots as any[]).find((b: any) => b.id === botId);
|
||||
if (!bot) {
|
||||
log.error(`Bot integration not found: ${botId}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return bot;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, (s: string) => string> = {
|
||||
connected: pc.green,
|
||||
disconnected: pc.dim,
|
||||
failed: pc.red,
|
||||
queued: pc.yellow,
|
||||
starting: pc.yellow,
|
||||
unknown: pc.dim,
|
||||
};
|
||||
|
||||
function parseCredentials(
|
||||
platform: string,
|
||||
options: Record<string, string | undefined>,
|
||||
): Record<string, string> {
|
||||
const creds: Record<string, string> = {};
|
||||
|
||||
if (options.botToken) creds.botToken = options.botToken;
|
||||
if (options.botId) creds.botId = options.botId;
|
||||
if (options.publicKey) creds.publicKey = options.publicKey;
|
||||
if (options.signingSecret) creds.signingSecret = options.signingSecret;
|
||||
if (options.appSecret) creds.appSecret = options.appSecret;
|
||||
|
||||
return creds;
|
||||
/** Validate a platform ID and return its definition. */
|
||||
async function resolvePlatform(client: TrpcClient, platformId: string) {
|
||||
const platforms = await client.agentBotProvider.listPlatforms.query();
|
||||
const def = (platforms as any[]).find((p: any) => p.id === platformId);
|
||||
if (!def) {
|
||||
const ids = (platforms as any[]).map((p: any) => p.id).join(', ');
|
||||
log.error(`Invalid platform "${platformId}". Must be one of: ${ids}`);
|
||||
log.info('Run `lh bot platforms` to see required credentials for each platform.');
|
||||
process.exit(1);
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
||||
// ── Command Registration ─────────────────────────────────
|
||||
|
||||
export function registerBotCommand(program: Command) {
|
||||
const bot = program.command('bot').description('Manage bot integrations');
|
||||
|
||||
// Register message subcommand group
|
||||
registerBotMessageCommands(bot);
|
||||
|
||||
// ── platforms ───────────────────────────────────────────
|
||||
|
||||
bot
|
||||
.command('platforms')
|
||||
.description('List supported platforms and their required credentials')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(async (options: { json?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const platforms = await client.agentBotProvider.listPlatforms.query();
|
||||
|
||||
if (options.json) {
|
||||
outputJson(platforms);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(pc.bold('Supported platforms:\n'));
|
||||
|
||||
for (const p of platforms as any[]) {
|
||||
console.log(` ${pc.bold(pc.cyan(p.id))}`);
|
||||
if (p.name) console.log(` Name: ${p.name}`);
|
||||
|
||||
const fields = getCredentialFields(p);
|
||||
const required = fields.filter((f: any) => f.required);
|
||||
const optional = fields.filter((f: any) => !f.required);
|
||||
|
||||
if (required.length > 0) {
|
||||
console.log(
|
||||
` Required: ${required.map((f: any) => pc.yellow(camelToFlag(f.key))).join(', ')}`,
|
||||
);
|
||||
}
|
||||
if (optional.length > 0) {
|
||||
console.log(
|
||||
` Optional: ${optional.map((f: any) => pc.dim(camelToFlag(f.key))).join(', ')}`,
|
||||
);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
});
|
||||
|
||||
// ── list ──────────────────────────────────────────────
|
||||
|
||||
bot
|
||||
@@ -63,15 +154,20 @@ export function registerBotCommand(program: Command) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = items.map((b: any) => [
|
||||
b.id || '',
|
||||
b.platform || '',
|
||||
b.applicationId || '',
|
||||
b.agentId || '',
|
||||
b.enabled ? pc.green('enabled') : pc.dim('disabled'),
|
||||
]);
|
||||
const rows = items.map((b: any) => {
|
||||
const status = b.enabled ? (b.runtimeStatus ?? 'disconnected') : 'disabled';
|
||||
const colorFn = STATUS_COLORS[status] ?? pc.dim;
|
||||
return [
|
||||
b.id || '',
|
||||
b.platform || '',
|
||||
b.applicationId || '',
|
||||
b.agentId || '',
|
||||
colorFn(status),
|
||||
b.updatedAt ? timeAgo(b.updatedAt) : pc.dim('-'),
|
||||
];
|
||||
});
|
||||
|
||||
printTable(rows, ['ID', 'PLATFORM', 'APP ID', 'AGENT', 'STATUS']);
|
||||
printTable(rows, ['ID', 'PLATFORM', 'APP ID', 'AGENT', 'STATUS', 'UPDATED']);
|
||||
});
|
||||
|
||||
// ── view ──────────────────────────────────────────────
|
||||
@@ -79,44 +175,62 @@ export function registerBotCommand(program: Command) {
|
||||
bot
|
||||
.command('view <botId>')
|
||||
.description('View bot integration details')
|
||||
.requiredOption('-a, --agent <agentId>', 'Agent ID')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(async (botId: string, options: { agent: string; json?: string | boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.agentBotProvider.getByAgentId.query({
|
||||
agentId: options.agent,
|
||||
});
|
||||
const items = Array.isArray(result) ? result : [];
|
||||
const item = items.find((b: any) => b.id === botId);
|
||||
.option('--show-credentials', 'Show full credential values (unmasked)')
|
||||
.action(
|
||||
async (botId: string, options: { json?: string | boolean; showCredentials?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const b = await findBot(client, botId);
|
||||
|
||||
if (!item) {
|
||||
log.error(`Bot integration not found: ${botId}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(item, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
const b = item as any;
|
||||
console.log(pc.bold(`${b.platform} bot`));
|
||||
console.log(pc.dim(`ID: ${b.id}`));
|
||||
console.log(`Application ID: ${b.applicationId}`);
|
||||
console.log(`Status: ${b.enabled ? pc.green('enabled') : pc.dim('disabled')}`);
|
||||
|
||||
if (b.credentials && typeof b.credentials === 'object') {
|
||||
console.log();
|
||||
console.log(pc.bold('Credentials:'));
|
||||
for (const [key, value] of Object.entries(b.credentials)) {
|
||||
const val = String(value);
|
||||
const masked = val.length > 8 ? val.slice(0, 4) + '****' + val.slice(-4) : '****';
|
||||
console.log(` ${key}: ${masked}`);
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(b, fields);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const status = b.enabled ? (b.runtimeStatus ?? 'disconnected') : 'disabled';
|
||||
const statusColorFn = STATUS_COLORS[status] ?? pc.dim;
|
||||
|
||||
const credentialLines: string[] = [];
|
||||
if (b.credentials && typeof b.credentials === 'object') {
|
||||
for (const [key, value] of Object.entries(b.credentials)) {
|
||||
const val = String(value);
|
||||
const display = options.showCredentials ? val : maskValue(val);
|
||||
credentialLines.push(`${pc.dim(key)}: ${display}`);
|
||||
}
|
||||
}
|
||||
|
||||
const settingsLines: string[] = [];
|
||||
if (b.settings && typeof b.settings === 'object') {
|
||||
for (const [key, value] of Object.entries(b.settings)) {
|
||||
settingsLines.push(`${pc.dim(key)}: ${JSON.stringify(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
printBoxTable(
|
||||
[
|
||||
{ header: 'Field', key: 'field' },
|
||||
{ header: 'Value', key: 'value' },
|
||||
],
|
||||
[
|
||||
{ field: 'ID', value: b.id || '' },
|
||||
{ field: 'Platform', value: pc.cyan(b.platform || '') },
|
||||
{ field: 'Application ID', value: b.applicationId || '' },
|
||||
{ field: 'Agent ID', value: b.agentId || '' },
|
||||
{ field: 'Status', value: statusColorFn(status) },
|
||||
...(credentialLines.length > 0
|
||||
? [{ field: 'Credentials', value: credentialLines }]
|
||||
: []),
|
||||
...(settingsLines.length > 0 ? [{ field: 'Settings', value: settingsLines }] : []),
|
||||
...(b.createdAt
|
||||
? [{ field: 'Created', value: new Date(b.createdAt).toLocaleString() }]
|
||||
: []),
|
||||
...(b.updatedAt ? [{ field: 'Updated', value: timeAgo(b.updatedAt) }] : []),
|
||||
],
|
||||
`${b.platform} bot`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ── add ───────────────────────────────────────────────
|
||||
|
||||
@@ -124,13 +238,18 @@ export function registerBotCommand(program: Command) {
|
||||
.command('add')
|
||||
.description('Add a bot integration to an agent')
|
||||
.requiredOption('-a, --agent <agentId>', 'Agent ID')
|
||||
.requiredOption('--platform <platform>', `Platform: ${SUPPORTED_PLATFORMS.join(', ')}`)
|
||||
.requiredOption('--platform <platform>', 'Platform (run `lh bot platforms` to see options)')
|
||||
.requiredOption('--app-id <appId>', 'Application ID for webhook routing')
|
||||
.option('--bot-token <token>', 'Bot token')
|
||||
.option('--bot-token <token>', 'Bot token (Discord, Slack, Telegram)')
|
||||
.option('--bot-id <id>', 'Bot ID (WeChat)')
|
||||
.option('--public-key <key>', 'Public key (Discord)')
|
||||
.option('--signing-secret <secret>', 'Signing secret (Slack)')
|
||||
.option('--app-secret <secret>', 'App secret (Lark/Feishu)')
|
||||
.option('--app-secret <secret>', 'App secret (Lark, Feishu, QQ)')
|
||||
.option('--secret-token <token>', 'Secret token (Telegram)')
|
||||
.option('--webhook-proxy-url <url>', 'Webhook proxy URL (Telegram)')
|
||||
.option('--encrypt-key <key>', 'Encrypt key (Feishu)')
|
||||
.option('--verification-token <token>', 'Verification token (Feishu)')
|
||||
.option('--json', 'Output created bot as JSON')
|
||||
.action(
|
||||
async (options: {
|
||||
agent: string;
|
||||
@@ -138,34 +257,39 @@ export function registerBotCommand(program: Command) {
|
||||
appSecret?: string;
|
||||
botId?: string;
|
||||
botToken?: string;
|
||||
encryptKey?: string;
|
||||
json?: boolean;
|
||||
platform: string;
|
||||
publicKey?: string;
|
||||
secretToken?: string;
|
||||
signingSecret?: string;
|
||||
verificationToken?: string;
|
||||
webhookProxyUrl?: string;
|
||||
}) => {
|
||||
if (!SUPPORTED_PLATFORMS.includes(options.platform)) {
|
||||
log.error(`Invalid platform. Must be one of: ${SUPPORTED_PLATFORMS.join(', ')}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
const client = await getTrpcClient();
|
||||
const platformDef = await resolvePlatform(client, options.platform);
|
||||
|
||||
const credentials = parseCredentials(options.platform, options);
|
||||
const requiredFields = PLATFORM_CREDENTIAL_FIELDS[options.platform] || [];
|
||||
const missing = requiredFields.filter((f) => !credentials[f]);
|
||||
const { credentials, missing } = extractCredentials(platformDef, options);
|
||||
if (missing.length > 0) {
|
||||
log.error(
|
||||
`Missing required credentials for ${options.platform}: ${missing.map((f) => '--' + f.replaceAll(/([A-Z])/g, '-$1').toLowerCase()).join(', ')}`,
|
||||
`Missing required credentials for ${options.platform}: ${missing.map((f: any) => camelToFlag(f.key)).join(', ')}`,
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.agentBotProvider.create.mutate({
|
||||
agentId: options.agent,
|
||||
applicationId: options.appId,
|
||||
credentials,
|
||||
platform: options.platform,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
outputJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const r = result as any;
|
||||
console.log(
|
||||
`${pc.green('✓')} Added ${pc.bold(options.platform)} bot ${pc.bold(r.id || '')}`,
|
||||
@@ -183,6 +307,10 @@ export function registerBotCommand(program: Command) {
|
||||
.option('--public-key <key>', 'New public key')
|
||||
.option('--signing-secret <secret>', 'New signing secret')
|
||||
.option('--app-secret <secret>', 'New app secret')
|
||||
.option('--secret-token <token>', 'New secret token')
|
||||
.option('--webhook-proxy-url <url>', 'New webhook proxy URL')
|
||||
.option('--encrypt-key <key>', 'New encrypt key')
|
||||
.option('--verification-token <token>', 'New verification token')
|
||||
.option('--app-id <appId>', 'New application ID')
|
||||
.option('--platform <platform>', 'New platform')
|
||||
.action(
|
||||
@@ -193,20 +321,23 @@ export function registerBotCommand(program: Command) {
|
||||
appSecret?: string;
|
||||
botId?: string;
|
||||
botToken?: string;
|
||||
encryptKey?: string;
|
||||
platform?: string;
|
||||
publicKey?: string;
|
||||
secretToken?: string;
|
||||
signingSecret?: string;
|
||||
verificationToken?: string;
|
||||
webhookProxyUrl?: string;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const input: Record<string, any> = { id: botId };
|
||||
|
||||
const credentials: Record<string, string> = {};
|
||||
if (options.botToken) credentials.botToken = options.botToken;
|
||||
if (options.botId) credentials.botId = options.botId;
|
||||
if (options.publicKey) credentials.publicKey = options.publicKey;
|
||||
if (options.signingSecret) credentials.signingSecret = options.signingSecret;
|
||||
if (options.appSecret) credentials.appSecret = options.appSecret;
|
||||
const existing = await findBot(client, botId);
|
||||
const platform = options.platform ?? existing.platform;
|
||||
const platformDef = await resolvePlatform(client, platform);
|
||||
|
||||
const { credentials } = extractCredentials(platformDef, options);
|
||||
if (Object.keys(credentials).length > 0) input.credentials = credentials;
|
||||
if (options.appId) input.applicationId = options.appId;
|
||||
if (options.platform) input.platform = options.platform;
|
||||
@@ -217,7 +348,6 @@ export function registerBotCommand(program: Command) {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
await client.agentBotProvider.update.mutate(input as any);
|
||||
console.log(`${pc.green('✓')} Updated bot ${pc.bold(botId)}`);
|
||||
},
|
||||
@@ -263,28 +393,41 @@ export function registerBotCommand(program: Command) {
|
||||
console.log(`${pc.green('✓')} Disabled bot ${pc.bold(botId)}`);
|
||||
});
|
||||
|
||||
// ── test ───────────────────────────────────────────────
|
||||
|
||||
bot
|
||||
.command('test <botId>')
|
||||
.description('Test bot credentials against the platform API')
|
||||
.action(async (botId: string) => {
|
||||
const client = await getTrpcClient();
|
||||
const b = await findBot(client, botId);
|
||||
|
||||
log.status(`Testing ${b.platform} credentials for ${b.applicationId}...`);
|
||||
|
||||
try {
|
||||
await client.agentBotProvider.testConnection.mutate({
|
||||
applicationId: b.applicationId,
|
||||
platform: b.platform,
|
||||
});
|
||||
console.log(`${pc.green('✓')} Credentials are valid for ${pc.bold(b.platform)} bot`);
|
||||
} catch (err: any) {
|
||||
const message = err?.message || 'Connection test failed';
|
||||
log.error(`Credential test failed: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ── connect ───────────────────────────────────────────
|
||||
|
||||
bot
|
||||
.command('connect <botId>')
|
||||
.description('Connect and start a bot')
|
||||
.requiredOption('-a, --agent <agentId>', 'Agent ID')
|
||||
.action(async (botId: string, options: { agent: string }) => {
|
||||
// First fetch the bot to get platform and applicationId
|
||||
.action(async (botId: string) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.agentBotProvider.getByAgentId.query({
|
||||
agentId: options.agent,
|
||||
});
|
||||
const items = Array.isArray(result) ? result : [];
|
||||
const item = items.find((b: any) => b.id === botId);
|
||||
const b = await findBot(client, botId);
|
||||
|
||||
if (!item) {
|
||||
log.error(`Bot integration not found: ${botId}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
log.status(`Connecting ${b.platform} bot ${b.applicationId}...`);
|
||||
|
||||
const b = item as any;
|
||||
const connectResult = await client.agentBotProvider.connectBot.mutate({
|
||||
applicationId: b.applicationId,
|
||||
platform: b.platform,
|
||||
|
||||
@@ -0,0 +1,564 @@
|
||||
import { DEFAULT_BOT_HISTORY_LIMIT } from '@lobechat/const';
|
||||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { confirm, outputJson, printTable, truncate } from '../utils/format';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
export function registerBotMessageCommands(bot: Command) {
|
||||
const message = bot
|
||||
.command('message')
|
||||
.description('Send and manage messages on connected platforms');
|
||||
|
||||
// ── send ────────────────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('send <botId>')
|
||||
.description('Send a message to a channel')
|
||||
.requiredOption('--target <channelId>', 'Target channel / conversation ID')
|
||||
.requiredOption('--message <text>', 'Message content')
|
||||
.option('--reply-to <messageId>', 'Reply to a specific message')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(
|
||||
async (
|
||||
botId: string,
|
||||
options: { json?: boolean; message: string; replyTo?: string; target: string },
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.sendMessage.mutate({
|
||||
botId,
|
||||
channelId: options.target,
|
||||
content: options.message,
|
||||
replyTo: options.replyTo,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
outputJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const r = result as any;
|
||||
console.log(
|
||||
`${pc.green('✓')} Message sent${r.messageId ? ` (${pc.dim(r.messageId)})` : ''}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ── read ────────────────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('read <botId>')
|
||||
.description('Read messages from a channel')
|
||||
.requiredOption('--target <channelId>', 'Target channel / conversation ID')
|
||||
.option('--limit <n>', 'Max messages to fetch', String(DEFAULT_BOT_HISTORY_LIMIT))
|
||||
.option('--before <messageId>', 'Read messages before this ID')
|
||||
.option('--after <messageId>', 'Read messages after this ID')
|
||||
.option('--start-time <timestamp>', 'Start time as Unix seconds (Feishu/Lark)')
|
||||
.option('--end-time <timestamp>', 'End time as Unix seconds (Feishu/Lark)')
|
||||
.option('--cursor <token>', 'Pagination cursor from a previous response (Feishu/Lark)')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(
|
||||
async (
|
||||
botId: string,
|
||||
options: {
|
||||
after?: string;
|
||||
before?: string;
|
||||
cursor?: string;
|
||||
endTime?: string;
|
||||
json?: boolean;
|
||||
limit?: string;
|
||||
startTime?: string;
|
||||
target: string;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.readMessages.query({
|
||||
after: options.after,
|
||||
before: options.before,
|
||||
botId,
|
||||
channelId: options.target,
|
||||
cursor: options.cursor,
|
||||
endTime: options.endTime,
|
||||
limit: options.limit ? Number.parseInt(options.limit, 10) : undefined,
|
||||
startTime: options.startTime,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
outputJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const messages = (result as any).messages ?? [];
|
||||
if (messages.length === 0) {
|
||||
console.log('No messages found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = messages.map((m: any) => [
|
||||
m.id || '',
|
||||
m.author?.name || '',
|
||||
truncate(m.content || '', 60),
|
||||
m.timestamp || '',
|
||||
]);
|
||||
|
||||
printTable(rows, ['ID', 'AUTHOR', 'CONTENT', 'TIME']);
|
||||
|
||||
const r = result as any;
|
||||
if (r.hasMore && r.nextCursor) {
|
||||
console.log(
|
||||
`\nMore messages available. Use ${pc.dim(`--cursor ${r.nextCursor}`)} to fetch next page.`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── edit ────────────────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('edit <botId>')
|
||||
.description('Edit a message')
|
||||
.requiredOption('--target <channelId>', 'Channel ID')
|
||||
.requiredOption('--message-id <id>', 'Message ID to edit')
|
||||
.requiredOption('--message <text>', 'New message content')
|
||||
.action(
|
||||
async (botId: string, options: { message: string; messageId: string; target: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.botMessage.editMessage.mutate({
|
||||
botId,
|
||||
channelId: options.target,
|
||||
content: options.message,
|
||||
messageId: options.messageId,
|
||||
});
|
||||
|
||||
console.log(`${pc.green('✓')} Message ${pc.bold(options.messageId)} edited`);
|
||||
},
|
||||
);
|
||||
|
||||
// ── delete ──────────────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('delete <botId>')
|
||||
.description('Delete a message')
|
||||
.requiredOption('--target <channelId>', 'Channel ID')
|
||||
.requiredOption('--message-id <id>', 'Message ID to delete')
|
||||
.option('--yes', 'Skip confirmation prompt')
|
||||
.action(
|
||||
async (botId: string, options: { messageId: string; target: string; yes?: boolean }) => {
|
||||
if (!options.yes) {
|
||||
const confirmed = await confirm('Are you sure you want to delete this message?');
|
||||
if (!confirmed) {
|
||||
console.log('Cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
await client.botMessage.deleteMessage.mutate({
|
||||
botId,
|
||||
channelId: options.target,
|
||||
messageId: options.messageId,
|
||||
});
|
||||
|
||||
console.log(`${pc.green('✓')} Message ${pc.bold(options.messageId)} deleted`);
|
||||
},
|
||||
);
|
||||
|
||||
// ── search ──────────────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('search <botId>')
|
||||
.description('Search messages in a channel')
|
||||
.requiredOption('--target <channelId>', 'Channel ID to search in')
|
||||
.requiredOption('--query <text>', 'Search query')
|
||||
.option('--author-id <id>', 'Filter by author ID')
|
||||
.option('--limit <n>', 'Max results')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(
|
||||
async (
|
||||
botId: string,
|
||||
options: {
|
||||
authorId?: string;
|
||||
json?: boolean;
|
||||
limit?: string;
|
||||
query: string;
|
||||
target: string;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.searchMessages.query({
|
||||
authorId: options.authorId,
|
||||
botId,
|
||||
channelId: options.target,
|
||||
limit: options.limit ? Number.parseInt(options.limit, 10) : undefined,
|
||||
query: options.query,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
outputJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const messages = (result as any).messages ?? [];
|
||||
if (messages.length === 0) {
|
||||
console.log('No messages found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = messages.map((m: any) => [
|
||||
m.id || '',
|
||||
m.author?.name || '',
|
||||
truncate(m.content || '', 60),
|
||||
]);
|
||||
|
||||
printTable(rows, ['ID', 'AUTHOR', 'CONTENT']);
|
||||
},
|
||||
);
|
||||
|
||||
// ── react ───────────────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('react <botId>')
|
||||
.description('Add an emoji reaction to a message')
|
||||
.requiredOption('--target <channelId>', 'Channel ID')
|
||||
.requiredOption('--message-id <id>', 'Message ID to react to')
|
||||
.requiredOption('--emoji <emoji>', 'Emoji to react with')
|
||||
.action(
|
||||
async (botId: string, options: { emoji: string; messageId: string; target: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.botMessage.reactToMessage.mutate({
|
||||
botId,
|
||||
channelId: options.target,
|
||||
emoji: options.emoji,
|
||||
messageId: options.messageId,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`${pc.green('✓')} Reacted with ${options.emoji} to message ${pc.bold(options.messageId)}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ── reactions ───────────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('reactions <botId>')
|
||||
.description('List reactions on a message')
|
||||
.requiredOption('--target <channelId>', 'Channel ID')
|
||||
.requiredOption('--message-id <id>', 'Message ID')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(
|
||||
async (botId: string, options: { json?: boolean; messageId: string; target: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.getReactions.query({
|
||||
botId,
|
||||
channelId: options.target,
|
||||
messageId: options.messageId,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
outputJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const reactions = (result as any).reactions ?? [];
|
||||
if (reactions.length === 0) {
|
||||
console.log('No reactions found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = reactions.map((r: any) => [r.emoji || '', String(r.count || 0)]);
|
||||
printTable(rows, ['EMOJI', 'COUNT']);
|
||||
},
|
||||
);
|
||||
|
||||
// ── pin ─────────────────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('pin <botId>')
|
||||
.description('Pin a message')
|
||||
.requiredOption('--target <channelId>', 'Channel ID')
|
||||
.requiredOption('--message-id <id>', 'Message ID to pin')
|
||||
.action(async (botId: string, options: { messageId: string; target: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.botMessage.pinMessage.mutate({
|
||||
botId,
|
||||
channelId: options.target,
|
||||
messageId: options.messageId,
|
||||
});
|
||||
|
||||
console.log(`${pc.green('✓')} Pinned message ${pc.bold(options.messageId)}`);
|
||||
});
|
||||
|
||||
// ── unpin ───────────────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('unpin <botId>')
|
||||
.description('Unpin a message')
|
||||
.requiredOption('--target <channelId>', 'Channel ID')
|
||||
.requiredOption('--message-id <id>', 'Message ID to unpin')
|
||||
.action(async (botId: string, options: { messageId: string; target: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.botMessage.unpinMessage.mutate({
|
||||
botId,
|
||||
channelId: options.target,
|
||||
messageId: options.messageId,
|
||||
});
|
||||
|
||||
console.log(`${pc.green('✓')} Unpinned message ${pc.bold(options.messageId)}`);
|
||||
});
|
||||
|
||||
// ── pins ────────────────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('pins <botId>')
|
||||
.description('List pinned messages')
|
||||
.requiredOption('--target <channelId>', 'Channel ID')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(async (botId: string, options: { json?: boolean; target: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.listPins.query({
|
||||
botId,
|
||||
channelId: options.target,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
outputJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const messages = (result as any).messages ?? [];
|
||||
if (messages.length === 0) {
|
||||
console.log('No pinned messages.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = messages.map((m: any) => [
|
||||
m.id || '',
|
||||
m.author?.name || '',
|
||||
truncate(m.content || '', 60),
|
||||
]);
|
||||
|
||||
printTable(rows, ['ID', 'AUTHOR', 'CONTENT']);
|
||||
});
|
||||
|
||||
// ── poll ────────────────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('poll <botId>')
|
||||
.description('Create a poll')
|
||||
.requiredOption('--target <channelId>', 'Channel ID')
|
||||
.requiredOption('--poll-question <text>', 'Poll question')
|
||||
.requiredOption('--poll-option <option>', 'Poll option (repeatable)', collectOptions, [])
|
||||
.option('--poll-multi', 'Allow multiple answers')
|
||||
.option('--poll-duration-hours <n>', 'Poll duration in hours')
|
||||
.action(
|
||||
async (
|
||||
botId: string,
|
||||
options: {
|
||||
pollDurationHours?: string;
|
||||
pollMulti?: boolean;
|
||||
pollOption: string[];
|
||||
pollQuestion: string;
|
||||
target: string;
|
||||
},
|
||||
) => {
|
||||
if (options.pollOption.length < 2) {
|
||||
log.error('At least 2 poll options are required.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.createPoll.mutate({
|
||||
botId,
|
||||
channelId: options.target,
|
||||
duration: options.pollDurationHours
|
||||
? Number.parseInt(options.pollDurationHours, 10)
|
||||
: undefined,
|
||||
multipleAnswers: options.pollMulti,
|
||||
options: options.pollOption,
|
||||
question: options.pollQuestion,
|
||||
});
|
||||
|
||||
const r = result as any;
|
||||
console.log(`${pc.green('✓')} Poll created${r.pollId ? ` (${pc.dim(r.pollId)})` : ''}`);
|
||||
},
|
||||
);
|
||||
|
||||
// ── thread (subcommand group) ───────────────────────────
|
||||
|
||||
const thread = message.command('thread').description('Manage threads');
|
||||
|
||||
thread
|
||||
.command('create <botId>')
|
||||
.description('Create a new thread')
|
||||
.requiredOption('--target <channelId>', 'Channel ID')
|
||||
.requiredOption('--thread-name <name>', 'Thread name')
|
||||
.option('--message <text>', 'Initial message content')
|
||||
.option('--message-id <id>', 'Create thread from a message')
|
||||
.action(
|
||||
async (
|
||||
botId: string,
|
||||
options: { message?: string; messageId?: string; target: string; threadName: string },
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.createThread.mutate({
|
||||
botId,
|
||||
channelId: options.target,
|
||||
content: options.message,
|
||||
messageId: options.messageId,
|
||||
name: options.threadName,
|
||||
});
|
||||
|
||||
const r = result as any;
|
||||
console.log(
|
||||
`${pc.green('✓')} Thread created${r.threadId ? ` (${pc.dim(r.threadId)})` : ''}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
thread
|
||||
.command('list <botId>')
|
||||
.description('List threads in a channel')
|
||||
.requiredOption('--target <channelId>', 'Channel ID')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(async (botId: string, options: { json?: boolean; target: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.listThreads.query({
|
||||
botId,
|
||||
channelId: options.target,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
outputJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const threads = (result as any).threads ?? [];
|
||||
if (threads.length === 0) {
|
||||
console.log('No threads found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = threads.map((t: any) => [
|
||||
t.id || '',
|
||||
t.name || '',
|
||||
String(t.messageCount ?? ''),
|
||||
]);
|
||||
|
||||
printTable(rows, ['ID', 'NAME', 'MESSAGES']);
|
||||
});
|
||||
|
||||
thread
|
||||
.command('reply <botId>')
|
||||
.description('Reply to a thread')
|
||||
.requiredOption('--thread-id <id>', 'Thread ID')
|
||||
.requiredOption('--message <text>', 'Reply content')
|
||||
.action(async (botId: string, options: { message: string; threadId: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.replyToThread.mutate({
|
||||
botId,
|
||||
content: options.message,
|
||||
threadId: options.threadId,
|
||||
});
|
||||
|
||||
const r = result as any;
|
||||
console.log(`${pc.green('✓')} Reply sent${r.messageId ? ` (${pc.dim(r.messageId)})` : ''}`);
|
||||
});
|
||||
|
||||
// ── channel (subcommand group) ──────────────────────────
|
||||
|
||||
const channel = message.command('channel').description('Manage channels');
|
||||
|
||||
channel
|
||||
.command('list <botId>')
|
||||
.description('List channels')
|
||||
.option('--server-id <id>', 'Server / workspace ID')
|
||||
.option('--filter <type>', 'Filter by type')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(
|
||||
async (botId: string, options: { filter?: string; json?: boolean; serverId?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.listChannels.query({
|
||||
botId,
|
||||
filter: options.filter,
|
||||
serverId: options.serverId,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
outputJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const channels = (result as any).channels ?? [];
|
||||
if (channels.length === 0) {
|
||||
console.log('No channels found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = channels.map((c: any) => [c.id || '', c.name || '', c.type || '']);
|
||||
printTable(rows, ['ID', 'NAME', 'TYPE']);
|
||||
},
|
||||
);
|
||||
|
||||
channel
|
||||
.command('info <botId>')
|
||||
.description('Get channel details')
|
||||
.requiredOption('--target <channelId>', 'Channel ID')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(async (botId: string, options: { json?: boolean; target: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.getChannelInfo.query({
|
||||
botId,
|
||||
channelId: options.target,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
outputJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const r = result as any;
|
||||
console.log(`Channel: ${pc.bold(r.name || options.target)}`);
|
||||
if (r.type) console.log(` Type: ${r.type}`);
|
||||
if (r.memberCount != null) console.log(` Members: ${r.memberCount}`);
|
||||
if (r.description) console.log(` Description: ${r.description}`);
|
||||
});
|
||||
|
||||
// ── member ──────────────────────────────────────────────
|
||||
|
||||
const member = message.command('member').description('Member information');
|
||||
|
||||
member
|
||||
.command('info <botId>')
|
||||
.description('Get member details')
|
||||
.requiredOption('--member-id <id>', 'Member / user ID')
|
||||
.option('--server-id <id>', 'Server / workspace ID')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(
|
||||
async (botId: string, options: { json?: boolean; memberId: string; serverId?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.getMemberInfo.query({
|
||||
botId,
|
||||
memberId: options.memberId,
|
||||
serverId: options.serverId,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
outputJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const r = result as any;
|
||||
console.log(`Member: ${pc.bold(r.displayName || r.username || options.memberId)}`);
|
||||
if (r.status) console.log(` Status: ${r.status}`);
|
||||
if (r.roles?.length) console.log(` Roles: ${r.roles.join(', ')}`);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────
|
||||
|
||||
function collectOptions(value: string, previous: string[]): string[] {
|
||||
return [...previous, value];
|
||||
}
|
||||
@@ -96,7 +96,7 @@ vi.mock('@lobechat/device-gateway-client', () => ({
|
||||
// eslint-disable-next-line import-x/first
|
||||
import { resolveToken } from '../auth/resolveToken';
|
||||
// eslint-disable-next-line import-x/first
|
||||
import { spawnDaemon, stopDaemon } from '../daemon/manager';
|
||||
import { removeStatus, spawnDaemon, stopDaemon, writeStatus } from '../daemon/manager';
|
||||
// eslint-disable-next-line import-x/first
|
||||
import { loadSettings, saveSettings } from '../settings';
|
||||
// eslint-disable-next-line import-x/first
|
||||
@@ -130,6 +130,36 @@ describe('connect command', () => {
|
||||
return program;
|
||||
}
|
||||
|
||||
it('should persist deviceId in status for foreground connections', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'connect']);
|
||||
|
||||
expect(writeStatus).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ connectionStatus: 'connecting', deviceId: 'mock-device-id' }),
|
||||
);
|
||||
|
||||
clientEventHandlers.connected?.();
|
||||
|
||||
expect(writeStatus).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ connectionStatus: 'connected', deviceId: 'mock-device-id' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should persist deviceId in status for daemon child connections', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'connect', '--daemon-child']);
|
||||
|
||||
expect(writeStatus).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ connectionStatus: 'connecting', deviceId: 'mock-device-id' }),
|
||||
);
|
||||
|
||||
clientEventHandlers.connected?.();
|
||||
|
||||
expect(writeStatus).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ connectionStatus: 'connected', deviceId: 'mock-device-id' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should connect to gateway', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'connect']);
|
||||
@@ -288,6 +318,7 @@ describe('connect command', () => {
|
||||
}
|
||||
|
||||
expect(cleanupAllProcesses).toHaveBeenCalled();
|
||||
expect(removeStatus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle auth_expired when refresh fails', async () => {
|
||||
|
||||
@@ -173,7 +173,7 @@ function buildDaemonArgs(options: ConnectOptions): string[] {
|
||||
}
|
||||
|
||||
async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
const auth = await resolveToken(options);
|
||||
let auth = await resolveToken(options);
|
||||
const settings = loadSettings();
|
||||
const gatewayUrl = normalizeUrl(options.gateway) || settings?.gatewayUrl;
|
||||
|
||||
@@ -221,16 +221,15 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
info(` Mode : ${isDaemonChild ? 'daemon' : 'foreground'}`);
|
||||
info('───────────────────');
|
||||
|
||||
// Update status file for daemon mode
|
||||
// Update local connection status so other CLI commands can resolve the current device
|
||||
const updateStatus = (connectionStatus: string) => {
|
||||
if (isDaemonChild) {
|
||||
writeStatus({
|
||||
connectionStatus,
|
||||
gatewayUrl: resolvedGatewayUrl,
|
||||
pid: process.pid,
|
||||
startedAt: startedAt.toISOString(),
|
||||
});
|
||||
}
|
||||
writeStatus({
|
||||
connectionStatus,
|
||||
deviceId: client.currentDeviceId,
|
||||
gatewayUrl: resolvedGatewayUrl,
|
||||
pid: process.pid,
|
||||
startedAt: startedAt.toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
const startedAt = new Date();
|
||||
@@ -295,19 +294,30 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Handle auth expired
|
||||
// Handle auth expired — refresh token and reconnect automatically
|
||||
client.on('auth_expired', async () => {
|
||||
if (auth.tokenType === 'apiKey') {
|
||||
// API keys don't expire; ignore stale auth_expired signals
|
||||
return;
|
||||
}
|
||||
|
||||
error('Authentication expired. Attempting to refresh...');
|
||||
const refreshed = await resolveToken({});
|
||||
if (refreshed) {
|
||||
info('Token refreshed. Please reconnect.');
|
||||
} else {
|
||||
error("Could not refresh token. Run 'lh login' to re-authenticate.");
|
||||
info('Authentication expired. Attempting to refresh token...');
|
||||
|
||||
try {
|
||||
const refreshed = await resolveToken({});
|
||||
if (refreshed) {
|
||||
info('Token refreshed successfully. Reconnecting...');
|
||||
client.updateToken(refreshed.token);
|
||||
// Update cached auth so subsequent refreshes use the latest token
|
||||
auth = refreshed;
|
||||
await client.reconnect();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// refresh failed — fall through
|
||||
}
|
||||
|
||||
error("Could not refresh token. Run 'lh login' to re-authenticate.");
|
||||
cleanup();
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -322,8 +332,8 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
info('Shutting down...');
|
||||
cleanupAllProcesses();
|
||||
client.disconnect();
|
||||
removeStatus();
|
||||
if (isDaemonChild) {
|
||||
removeStatus();
|
||||
removePid();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -61,7 +61,6 @@ describe('generate command', () => {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Oidc-Auth': 'test-token',
|
||||
'X-lobe-chat-auth': 'test-xor-token',
|
||||
},
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
});
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { registerOpenClawMigration } from './openclaw';
|
||||
|
||||
export function registerMigrateCommand(program: Command) {
|
||||
const migrate = program
|
||||
.command('migrate')
|
||||
.description('Migrate data from external tools (OpenClaw, ChatGPT, Claude, etc.)');
|
||||
|
||||
registerOpenClawMigration(migrate);
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// ── Mocks ──────────────────────────────────────────────
|
||||
|
||||
const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
mockTrpcClient: {
|
||||
agent: {
|
||||
createAgent: { mutate: vi.fn() },
|
||||
getBuiltinAgent: { query: vi.fn() },
|
||||
},
|
||||
agentDocument: {
|
||||
upsertDocument: { mutate: vi.fn() },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({
|
||||
getTrpcClient: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockConfirm } = vi.hoisted(() => ({
|
||||
mockConfirm: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
getTrpcClient: mockGetTrpcClient,
|
||||
}));
|
||||
|
||||
vi.mock('../../settings', () => ({
|
||||
resolveServerUrl: () => 'https://app.lobehub.com',
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/format', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, confirm: mockConfirm };
|
||||
});
|
||||
|
||||
vi.mock('../../utils/logger', () => ({
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
setVerbose: vi.fn(),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import-x/first
|
||||
import { log } from '../../utils/logger';
|
||||
// eslint-disable-next-line import-x/first
|
||||
import { registerOpenClawMigration } from './openclaw';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
function createProgram() {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
const migrate = program.command('migrate');
|
||||
registerOpenClawMigration(migrate);
|
||||
return program;
|
||||
}
|
||||
|
||||
function writeFile(relativePath: string, content: string) {
|
||||
const fullPath = path.join(tmpDir, relativePath);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, content);
|
||||
}
|
||||
|
||||
// ── Setup / teardown ───────────────────────────────────
|
||||
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openclaw-test-'));
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
|
||||
throw new Error('process.exit');
|
||||
}) as any);
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
consoleSpy.mockRestore();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────
|
||||
|
||||
describe('migrate openclaw', () => {
|
||||
// ── Profile parsing ────────────────────────────────
|
||||
|
||||
describe('agent profile from workspace', () => {
|
||||
it('should read name, description, and emoji from IDENTITY.md', async () => {
|
||||
writeFile(
|
||||
'IDENTITY.md',
|
||||
['# IDENTITY.md', '- **Name:** 龙虾', '- **Creature:** AI 助手', '- **Emoji:** 🦞'].join(
|
||||
'\n',
|
||||
),
|
||||
);
|
||||
writeFile('hello.md', 'hello');
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agent.createAgent.mutate).toHaveBeenCalledWith({
|
||||
config: {
|
||||
avatar: '🦞',
|
||||
description: 'AI 助手',
|
||||
title: '龙虾',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter out placeholder emoji like (待定)', async () => {
|
||||
writeFile(
|
||||
'IDENTITY.md',
|
||||
['# IDENTITY.md', '- **Name:** TestBot', '- **Emoji:**', ' _(待定)_'].join('\n'),
|
||||
);
|
||||
writeFile('hello.md', 'hello');
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agent.createAgent.mutate).toHaveBeenCalledWith({
|
||||
config: {
|
||||
avatar: undefined,
|
||||
description: undefined,
|
||||
title: 'TestBot',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should fall back to "OpenClaw" when no identity files exist', async () => {
|
||||
writeFile('doc.md', 'content');
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agent.createAgent.mutate).toHaveBeenCalledWith({
|
||||
config: {
|
||||
avatar: undefined,
|
||||
description: undefined,
|
||||
title: 'OpenClaw',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── File filtering ─────────────────────────────────
|
||||
|
||||
describe('file collection and filtering', () => {
|
||||
it('should exclude common directories like node_modules and .git', async () => {
|
||||
writeFile('README.md', 'readme');
|
||||
writeFile('node_modules/pkg/index.js', 'module');
|
||||
writeFile('.git/config', 'git');
|
||||
writeFile('.idea/workspace.xml', 'ide');
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ filename: 'README.md' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should exclude files matching glob patterns like *.pyc and *.log', async () => {
|
||||
writeFile('main.py', 'print("hi")');
|
||||
writeFile('main.pyc', 'bytecode');
|
||||
writeFile('app.log', 'log data');
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ filename: 'main.py' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should respect workspace .gitignore', async () => {
|
||||
writeFile('.gitignore', 'secret.txt\ndata/\n');
|
||||
writeFile('README.md', 'readme');
|
||||
writeFile('secret.txt', 'password');
|
||||
writeFile('data/dump.sql', 'sql');
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
const filenames = mockTrpcClient.agentDocument.upsertDocument.mutate.mock.calls.map(
|
||||
(c: any[]) => c[0].filename,
|
||||
);
|
||||
expect(filenames).toContain('README.md');
|
||||
expect(filenames).not.toContain('secret.txt');
|
||||
expect(filenames).not.toContain('data/dump.sql');
|
||||
});
|
||||
|
||||
it('should skip binary files during import', async () => {
|
||||
writeFile('readme.md', 'text content');
|
||||
// Write a file with null bytes (binary)
|
||||
const binPath = path.join(tmpDir, 'image.dat');
|
||||
fs.writeFileSync(binPath, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x00, 0x01]));
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
// Only the text file should be upserted
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ filename: 'readme.md' }),
|
||||
);
|
||||
// Binary file should show as skipped in output
|
||||
const allOutput = consoleSpy.mock.calls.map((c: any[]) => c[0]).join('\n');
|
||||
expect(allOutput).toContain('skipped');
|
||||
});
|
||||
|
||||
it('should exclude database files by extension', async () => {
|
||||
writeFile('data.md', 'notes');
|
||||
writeFile('local.sqlite', 'fake-sqlite');
|
||||
writeFile('app.db', 'fake-db');
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ filename: 'data.md' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should collect files in subdirectories', async () => {
|
||||
writeFile('docs/guide.md', 'guide');
|
||||
writeFile('docs/api.md', 'api');
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
const filenames = mockTrpcClient.agentDocument.upsertDocument.mutate.mock.calls
|
||||
.map((c: any[]) => c[0].filename)
|
||||
.sort();
|
||||
expect(filenames).toEqual(['docs/api.md', 'docs/guide.md']);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Dry run ────────────────────────────────────────
|
||||
|
||||
describe('--dry-run', () => {
|
||||
it('should list files without calling API', async () => {
|
||||
writeFile('file.md', 'content');
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--dry-run',
|
||||
]);
|
||||
|
||||
expect(mockGetTrpcClient).not.toHaveBeenCalled();
|
||||
expect(mockTrpcClient.agent.createAgent.mutate).not.toHaveBeenCalled();
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).not.toHaveBeenCalled();
|
||||
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Dry run'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── Agent resolution ───────────────────────────────
|
||||
|
||||
describe('agent resolution', () => {
|
||||
it('should use --agent-id directly when provided', async () => {
|
||||
writeFile('file.md', 'content');
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--agent-id',
|
||||
'agt_existing',
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agent.createAgent.mutate).not.toHaveBeenCalled();
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'agt_existing' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve agent by --slug', async () => {
|
||||
writeFile('file.md', 'content');
|
||||
mockTrpcClient.agent.getBuiltinAgent.query.mockResolvedValue({ id: 'agt_inbox' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--slug',
|
||||
'inbox',
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agent.getBuiltinAgent.query).toHaveBeenCalledWith({ slug: 'inbox' });
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'agt_inbox' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should create a new agent by default', async () => {
|
||||
writeFile('file.md', 'content');
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_new' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agent.createAgent.mutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'agt_new' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Confirmation ───────────────────────────────────
|
||||
|
||||
describe('confirmation', () => {
|
||||
it('should cancel when user declines', async () => {
|
||||
writeFile('file.md', 'content');
|
||||
mockConfirm.mockResolvedValue(false);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'migrate', 'openclaw', '--source', tmpDir]);
|
||||
|
||||
expect(mockTrpcClient.agent.createAgent.mutate).not.toHaveBeenCalled();
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Cancelled.');
|
||||
});
|
||||
|
||||
it('should skip confirmation with --yes', async () => {
|
||||
writeFile('file.md', 'content');
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Error handling ─────────────────────────────────
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should exit when source path does not exist', async () => {
|
||||
const program = createProgram();
|
||||
await program
|
||||
.parseAsync(['node', 'test', 'migrate', 'openclaw', '--source', '/nonexistent/path'])
|
||||
.catch(() => {}); // process.exit throws
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found'));
|
||||
});
|
||||
|
||||
it('should report failed files without aborting', async () => {
|
||||
writeFile('a.md', 'ok');
|
||||
writeFile('b.md', 'fail');
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
// Files are iterated in readdir order; mock first success then failure
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate
|
||||
.mockResolvedValueOnce({})
|
||||
.mockRejectedValueOnce(new Error('upload error'));
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledTimes(2);
|
||||
const allOutput = consoleSpy.mock.calls.map((c: any[]) => c[0]).join('\n');
|
||||
expect(allOutput).toContain('1 imported');
|
||||
expect(allOutput).toContain('1 failed');
|
||||
});
|
||||
|
||||
it('should show no files message for empty workspace', async () => {
|
||||
// Only excluded items
|
||||
writeFile('.git/config', 'git');
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--dry-run',
|
||||
]);
|
||||
|
||||
expect(log.info).toHaveBeenCalledWith('No files found in workspace.');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Output ─────────────────────────────────────────
|
||||
|
||||
describe('output', () => {
|
||||
it('should print agent URL on completion', async () => {
|
||||
writeFile('file.md', 'content');
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_abc123' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
const allOutput = consoleSpy.mock.calls.map((c: any[]) => c[0]).join('\n');
|
||||
expect(allOutput).toContain('https://app.lobehub.com/agent/agt_abc123');
|
||||
});
|
||||
|
||||
it('should show friendly completion message on success', async () => {
|
||||
writeFile('file.md', 'content');
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
const allOutput = consoleSpy.mock.calls.map((c: any[]) => c[0]).join('\n');
|
||||
expect(allOutput).toContain('Migration complete');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,466 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { Command } from 'commander';
|
||||
import ignore from 'ignore';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import type { TrpcClient } from '../../api/client';
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
import { resolveServerUrl } from '../../settings';
|
||||
import { confirm } from '../../utils/format';
|
||||
import { log } from '../../utils/logger';
|
||||
|
||||
const DEFAULT_AGENT_NAME = 'OpenClaw';
|
||||
|
||||
// Files to look for agent identity (tried in order)
|
||||
const IDENTITY_FILES = ['IDENTITY.md', 'SOUL.md'];
|
||||
|
||||
// Default ignore rules (gitignore syntax) applied when no .gitignore is found
|
||||
const DEFAULT_IGNORE_RULES = [
|
||||
// VCS
|
||||
'.git',
|
||||
'.svn',
|
||||
'.hg',
|
||||
|
||||
// OpenClaw internal
|
||||
'.openclaw',
|
||||
|
||||
// OS artifacts
|
||||
'.DS_Store',
|
||||
'Thumbs.db',
|
||||
'desktop.ini',
|
||||
|
||||
// IDE / editor
|
||||
'.idea',
|
||||
'.vscode',
|
||||
'.fleet',
|
||||
'.cursor',
|
||||
'.zed',
|
||||
'*.swp',
|
||||
'*.swo',
|
||||
'*~',
|
||||
|
||||
// Dependencies
|
||||
'node_modules',
|
||||
'.pnp',
|
||||
'.yarn',
|
||||
'bower_components',
|
||||
'vendor',
|
||||
'jspm_packages',
|
||||
|
||||
// Python
|
||||
'.venv',
|
||||
'venv',
|
||||
'env',
|
||||
'__pycache__',
|
||||
'*.pyc',
|
||||
'*.pyo',
|
||||
'.mypy_cache',
|
||||
'.ruff_cache',
|
||||
'.pytest_cache',
|
||||
'.tox',
|
||||
'.eggs',
|
||||
'*.egg-info',
|
||||
|
||||
// Ruby
|
||||
'.bundle',
|
||||
|
||||
// Rust
|
||||
'target',
|
||||
|
||||
// Go
|
||||
'go.sum',
|
||||
|
||||
// Java / JVM
|
||||
'.gradle',
|
||||
'.m2',
|
||||
|
||||
// .NET
|
||||
'bin',
|
||||
'obj',
|
||||
'packages',
|
||||
|
||||
// Build / cache / output
|
||||
'.cache',
|
||||
'.parcel-cache',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.turbo',
|
||||
'.output',
|
||||
'dist',
|
||||
'build',
|
||||
'out',
|
||||
'.sass-cache',
|
||||
|
||||
// Env / secrets
|
||||
'.env',
|
||||
'.env.*',
|
||||
|
||||
// Test / coverage
|
||||
'coverage',
|
||||
'.nyc_output',
|
||||
|
||||
// Infra
|
||||
'.terraform',
|
||||
|
||||
// Temp
|
||||
'tmp',
|
||||
'.tmp',
|
||||
|
||||
// Logs
|
||||
'*.log',
|
||||
'logs',
|
||||
|
||||
// Databases
|
||||
'*.sqlite',
|
||||
'*.sqlite3',
|
||||
'*.db',
|
||||
'*.db-shm',
|
||||
'*.db-wal',
|
||||
'*.ldb',
|
||||
'*.mdb',
|
||||
'*.accdb',
|
||||
|
||||
// Archives / binaries
|
||||
'*.zip',
|
||||
'*.tar',
|
||||
'*.tar.gz',
|
||||
'*.tgz',
|
||||
'*.gz',
|
||||
'*.bz2',
|
||||
'*.xz',
|
||||
'*.rar',
|
||||
'*.7z',
|
||||
'*.jar',
|
||||
'*.war',
|
||||
'*.dll',
|
||||
'*.so',
|
||||
'*.dylib',
|
||||
'*.exe',
|
||||
'*.bin',
|
||||
'*.o',
|
||||
'*.a',
|
||||
'*.lib',
|
||||
'*.class',
|
||||
|
||||
// Images / media / fonts
|
||||
'*.png',
|
||||
'*.jpg',
|
||||
'*.jpeg',
|
||||
'*.gif',
|
||||
'*.bmp',
|
||||
'*.ico',
|
||||
'*.webp',
|
||||
'*.svg',
|
||||
'*.mp3',
|
||||
'*.mp4',
|
||||
'*.wav',
|
||||
'*.avi',
|
||||
'*.mov',
|
||||
'*.mkv',
|
||||
'*.flac',
|
||||
'*.ogg',
|
||||
'*.pdf',
|
||||
'*.woff',
|
||||
'*.woff2',
|
||||
'*.ttf',
|
||||
'*.otf',
|
||||
'*.eot',
|
||||
|
||||
// Lock files
|
||||
'package-lock.json',
|
||||
'yarn.lock',
|
||||
'pnpm-lock.yaml',
|
||||
'Gemfile.lock',
|
||||
'Cargo.lock',
|
||||
'poetry.lock',
|
||||
'composer.lock',
|
||||
];
|
||||
|
||||
interface AgentProfile {
|
||||
avatar?: string;
|
||||
description?: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to extract the agent name, description, and avatar emoji from
|
||||
* IDENTITY.md or SOUL.md. Falls back to "OpenClaw" if neither file
|
||||
* exists or parsing fails.
|
||||
*/
|
||||
function readAgentProfile(workspacePath: string): AgentProfile {
|
||||
for (const filename of IDENTITY_FILES) {
|
||||
const filePath = path.join(workspacePath, filename);
|
||||
if (!fs.existsSync(filePath)) continue;
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
|
||||
// Try to extract **Name:** value
|
||||
const nameMatch = content.match(/\*{0,2}Name:?\*{0,2}\s*(.+)/i);
|
||||
const title = nameMatch ? nameMatch[1].trim() : DEFAULT_AGENT_NAME;
|
||||
|
||||
// Try to extract **Creature:** or **Vibe:** or **Description:** as description
|
||||
const descMatch = content.match(/\*{0,2}(?:Creature|Vibe|Description):?\*{0,2}\s*(.+)/i);
|
||||
const description = descMatch ? descMatch[1].trim() : undefined;
|
||||
|
||||
// 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 (待定), _(待定)_, (TBD), N/A, etc.
|
||||
const isPlaceholder =
|
||||
rawAvatar && /^[_*((].*[))_*]$|^(?:tbd|todo|n\/?a|none|待定|未定)$/i.test(rawAvatar);
|
||||
const avatar = rawAvatar && !isPlaceholder ? rawAvatar : undefined;
|
||||
|
||||
return { avatar, description, title };
|
||||
}
|
||||
|
||||
return { title: DEFAULT_AGENT_NAME };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an ignore filter for the workspace. Uses .gitignore if present,
|
||||
* otherwise falls back to a comprehensive default rule set.
|
||||
*/
|
||||
function buildIgnoreFilter(workspacePath: string) {
|
||||
const ig = ignore();
|
||||
|
||||
const gitignorePath = path.join(workspacePath, '.gitignore');
|
||||
if (fs.existsSync(gitignorePath)) {
|
||||
ig.add(fs.readFileSync(gitignorePath, 'utf8'));
|
||||
}
|
||||
|
||||
// Always apply default rules on top
|
||||
ig.add(DEFAULT_IGNORE_RULES);
|
||||
|
||||
return ig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collect all files under `dir`, filtered by ignore rules.
|
||||
* Returns paths relative to `baseDir`.
|
||||
*/
|
||||
function collectFiles(dir: string, baseDir: string, ig: ReturnType<typeof ignore>): string[] {
|
||||
const results: string[] = [];
|
||||
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const relativePath = path.relative(baseDir, path.join(dir, entry.name));
|
||||
|
||||
// Directories need a trailing slash for ignore to match correctly
|
||||
const testPath = entry.isDirectory() ? `${relativePath}/` : relativePath;
|
||||
if (ig.ignores(testPath)) continue;
|
||||
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...collectFiles(fullPath, baseDir, ig));
|
||||
} else if (entry.isFile()) {
|
||||
results.push(relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick check: read the first 8KB and look for null bytes.
|
||||
* If found, the file is likely binary and should be skipped.
|
||||
*/
|
||||
function isBinaryFile(filePath: string): boolean {
|
||||
const fd = fs.openSync(filePath, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(8192);
|
||||
const bytesRead = fs.readSync(fd, buf, 0, 8192, 0);
|
||||
for (let i = 0; i < bytesRead; i++) {
|
||||
if (buf[i] === 0) return true;
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
function formatAgentLabel(profile: AgentProfile): string {
|
||||
return profile.avatar ? `${profile.avatar} ${profile.title}` : profile.title;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the target agent ID.
|
||||
* Priority: --agent-id > --slug > create new agent from workspace profile.
|
||||
*/
|
||||
async function resolveAgentId(
|
||||
client: TrpcClient,
|
||||
opts: { agentId?: string; slug?: string },
|
||||
profile: AgentProfile,
|
||||
): Promise<string> {
|
||||
if (opts.agentId) return opts.agentId;
|
||||
|
||||
if (opts.slug) {
|
||||
const agent = await client.agent.getBuiltinAgent.query({ slug: opts.slug });
|
||||
if (!agent) {
|
||||
log.error(`Agent not found for slug: ${opts.slug}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return agent.id;
|
||||
}
|
||||
|
||||
const label = formatAgentLabel(profile);
|
||||
log.info(`Creating new agent ${pc.bold(label)}...`);
|
||||
const result = await client.agent.createAgent.mutate({
|
||||
config: {
|
||||
avatar: profile.avatar,
|
||||
description: profile.description,
|
||||
title: profile.title,
|
||||
},
|
||||
});
|
||||
|
||||
const id = result.agentId;
|
||||
if (!id) {
|
||||
log.error('Failed to create agent — no agentId returned.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`${pc.green('✓')} Agent created: ${pc.bold(label)}`);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function registerOpenClawMigration(migrate: Command) {
|
||||
migrate
|
||||
.command('openclaw')
|
||||
.description('Import OpenClaw workspace files as agent documents')
|
||||
.option(
|
||||
'--source <path>',
|
||||
'Path to OpenClaw workspace',
|
||||
path.join(os.homedir(), '.openclaw', 'workspace'),
|
||||
)
|
||||
.option('--agent-id <id>', 'Import into an existing agent by ID')
|
||||
.option('--slug <slug>', 'Import into an existing agent by slug (e.g. "inbox")')
|
||||
.option('--dry-run', 'Preview files without importing')
|
||||
.option('--yes', 'Skip confirmation prompt')
|
||||
.action(
|
||||
async (options: {
|
||||
agentId?: string;
|
||||
dryRun?: boolean;
|
||||
slug?: string;
|
||||
source: string;
|
||||
yes?: boolean;
|
||||
}) => {
|
||||
// Check auth early so users don't scan files only to find out they're not logged in
|
||||
if (!options.dryRun) {
|
||||
await getTrpcClient();
|
||||
}
|
||||
|
||||
const workspacePath = path.resolve(options.source);
|
||||
|
||||
// Validate source directory
|
||||
if (!fs.existsSync(workspacePath)) {
|
||||
log.error(`OpenClaw workspace not found: ${workspacePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.statSync(workspacePath).isDirectory()) {
|
||||
log.error(`Not a directory: ${workspacePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Read agent profile from workspace identity files
|
||||
const profile = readAgentProfile(workspacePath);
|
||||
const label = formatAgentLabel(profile);
|
||||
|
||||
// Collect files (respects .gitignore + default rules)
|
||||
const ig = buildIgnoreFilter(workspacePath);
|
||||
const files = collectFiles(workspacePath, workspacePath, ig);
|
||||
|
||||
if (files.length === 0) {
|
||||
log.info('No files found in workspace.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Found ${pc.bold(String(files.length))} file(s) in ${pc.dim(workspacePath)}:\n`,
|
||||
);
|
||||
for (const f of files) {
|
||||
console.log(` ${pc.dim('•')} ${f}`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
if (options.dryRun) {
|
||||
log.info('Dry run — no changes made.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm
|
||||
if (!options.yes) {
|
||||
const target = options.agentId
|
||||
? `agent ${pc.bold(options.agentId)}`
|
||||
: options.slug
|
||||
? `agent slug "${pc.bold(options.slug)}"`
|
||||
: `a new ${pc.bold(label)} agent`;
|
||||
const confirmed = await confirm(
|
||||
`Import ${files.length} file(s) as agent documents into ${target}?`,
|
||||
);
|
||||
if (!confirmed) {
|
||||
console.log('Cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// Create or reuse agent
|
||||
const agentId = await resolveAgentId(client, options, profile);
|
||||
|
||||
console.log(`\nImporting to ${pc.bold(label)}...\n`);
|
||||
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
|
||||
let skipped = 0;
|
||||
|
||||
for (const relativePath of files) {
|
||||
const fullPath = path.join(workspacePath, relativePath);
|
||||
|
||||
try {
|
||||
// Skip binary files that slipped through the extension filter
|
||||
if (isBinaryFile(fullPath)) {
|
||||
console.log(` ${pc.dim('○')} ${relativePath} ${pc.dim('(binary, skipped)')}`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(fullPath, 'utf8');
|
||||
const stat = fs.statSync(fullPath);
|
||||
|
||||
await client.agentDocument.upsertDocument.mutate({
|
||||
agentId,
|
||||
content,
|
||||
createdAt: stat.birthtime,
|
||||
filename: relativePath,
|
||||
updatedAt: stat.mtime,
|
||||
});
|
||||
console.log(` ${pc.green('✓')} ${relativePath}`);
|
||||
success++;
|
||||
} catch (err: any) {
|
||||
console.log(` ${pc.red('✗')} ${relativePath} — ${err.message || err}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
const agentUrl = `${resolveServerUrl()}/agent/${agentId}`;
|
||||
const skippedInfo = skipped > 0 ? `, ${skipped} skipped` : '';
|
||||
console.log();
|
||||
if (failed === 0) {
|
||||
console.log(
|
||||
`${pc.green('✓')} Migration complete! ${pc.bold(String(success))} file(s) imported to ${pc.bold(label)}.${skippedInfo}`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`${pc.yellow('⚠')} Migration finished with issues: ${pc.bold(String(success))} imported, ${pc.red(String(failed))} failed${skippedInfo}.`,
|
||||
);
|
||||
}
|
||||
console.log(`\n ${pc.dim('→')} ${pc.underline(agentUrl)}`);
|
||||
console.log();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,12 @@ import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
import type { KanbanColumn } from '../../utils/format';
|
||||
import {
|
||||
confirm,
|
||||
displayWidth,
|
||||
outputJson,
|
||||
printKanban,
|
||||
printTable,
|
||||
timeAgo,
|
||||
truncate,
|
||||
@@ -37,10 +39,12 @@ export function registerTaskCommand(program: Command) {
|
||||
.option('-L, --limit <n>', 'Page size', '50')
|
||||
.option('--offset <n>', 'Offset', '0')
|
||||
.option('--tree', 'Display as tree structure')
|
||||
.option('--board', 'Display as kanban board grouped by status')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(
|
||||
async (options: {
|
||||
agent?: string;
|
||||
board?: boolean;
|
||||
json?: string | boolean;
|
||||
limit?: string;
|
||||
offset?: string;
|
||||
@@ -59,8 +63,8 @@ export function registerTaskCommand(program: Command) {
|
||||
if (options.limit) input.limit = Number.parseInt(options.limit, 10);
|
||||
if (options.offset) input.offset = Number.parseInt(options.offset, 10);
|
||||
|
||||
// For tree mode, fetch all tasks (no pagination limit)
|
||||
if (options.tree) {
|
||||
// For tree/board mode, fetch all tasks (no pagination limit)
|
||||
if (options.tree || options.board) {
|
||||
input.limit = 100;
|
||||
delete input.offset;
|
||||
}
|
||||
@@ -77,6 +81,58 @@ export function registerTaskCommand(program: Command) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.board) {
|
||||
// Kanban board grouped by status
|
||||
const statusOrder = [
|
||||
'backlog',
|
||||
'blocked',
|
||||
'running',
|
||||
'paused',
|
||||
'completed',
|
||||
'failed',
|
||||
'timeout',
|
||||
'canceled',
|
||||
];
|
||||
|
||||
const statusColors: Record<string, (s: string) => string> = {
|
||||
backlog: pc.dim,
|
||||
blocked: pc.red,
|
||||
canceled: pc.dim,
|
||||
completed: pc.green,
|
||||
failed: pc.red,
|
||||
paused: pc.yellow,
|
||||
running: pc.blue,
|
||||
timeout: pc.red,
|
||||
};
|
||||
|
||||
// Group tasks by status
|
||||
const grouped = new Map<string, any[]>();
|
||||
for (const t of result.data) {
|
||||
const status = t.status || 'backlog';
|
||||
const list = grouped.get(status) || [];
|
||||
list.push(t);
|
||||
grouped.set(status, list);
|
||||
}
|
||||
|
||||
const kanbanColumns: KanbanColumn[] = statusOrder
|
||||
.filter((s) => grouped.has(s))
|
||||
.map((status) => ({
|
||||
color: statusColors[status],
|
||||
items: grouped.get(status)!.map((t: any) => ({
|
||||
badge: pc.dim(t.identifier),
|
||||
meta: t.assigneeAgentId ? `agent: ${t.assigneeAgentId}` : undefined,
|
||||
title: t.name || t.instruction,
|
||||
})),
|
||||
title: status.toUpperCase(),
|
||||
}));
|
||||
|
||||
console.log();
|
||||
printKanban(kanbanColumns);
|
||||
console.log();
|
||||
log.info(`Total: ${result.total}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.tree) {
|
||||
// Build tree display
|
||||
const taskMap = new Map<string, any>();
|
||||
@@ -240,23 +296,34 @@ export function registerTaskCommand(program: Command) {
|
||||
}
|
||||
if (t.error) console.log(`${pc.red('Error:')} ${t.error}`);
|
||||
|
||||
// ── Subtasks ──
|
||||
// ── Subtasks (nested tree) ──
|
||||
if (t.subtasks && t.subtasks.length > 0) {
|
||||
// Build lookup: which subtasks are completed
|
||||
const completedIdentifiers = new Set(
|
||||
t.subtasks.filter((s) => s.status === 'completed').map((s) => s.identifier),
|
||||
);
|
||||
// Build lookup: which subtasks are completed (flatten tree)
|
||||
const collectCompleted = (nodes: typeof t.subtasks, set: Set<string>): Set<string> => {
|
||||
for (const s of nodes!) {
|
||||
if (s.status === 'completed') set.add(s.identifier);
|
||||
if (s.children) collectCompleted(s.children, set);
|
||||
}
|
||||
return set;
|
||||
};
|
||||
const completedIdentifiers = collectCompleted(t.subtasks, new Set());
|
||||
|
||||
const renderSubtasks = (nodes: typeof t.subtasks, indent: string) => {
|
||||
for (const s of nodes!) {
|
||||
const depInfo = s.blockedBy ? pc.dim(` ← blocks: ${s.blockedBy}`) : '';
|
||||
const isBlocked = s.blockedBy && !completedIdentifiers.has(s.blockedBy);
|
||||
const displayStatus = s.status === 'backlog' && isBlocked ? 'blocked' : s.status;
|
||||
console.log(
|
||||
`${indent}${pc.dim(s.identifier)} ${statusBadge(displayStatus)} ${s.name || '(unnamed)'}${depInfo}`,
|
||||
);
|
||||
if (s.children && s.children.length > 0) {
|
||||
renderSubtasks(s.children, indent + ' ');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
console.log(`\n${pc.bold('Subtasks:')}`);
|
||||
for (const s of t.subtasks) {
|
||||
const depInfo = s.blockedBy ? pc.dim(` ← blocks: ${s.blockedBy}`) : '';
|
||||
// Show 'blocked' instead of 'backlog' if task has unresolved dependencies
|
||||
const isBlocked = s.blockedBy && !completedIdentifiers.has(s.blockedBy);
|
||||
const displayStatus = s.status === 'backlog' && isBlocked ? 'blocked' : s.status;
|
||||
console.log(
|
||||
` ${pc.dim(s.identifier)} ${statusBadge(displayStatus)} ${s.name || '(unnamed)'}${depInfo}`,
|
||||
);
|
||||
}
|
||||
renderSubtasks(t.subtasks, ' ');
|
||||
}
|
||||
|
||||
// ── Dependencies ──
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export const OFFICIAL_AGENT_GATEWAY_URL = 'https://agent-gateway.lobehub.com';
|
||||
export const OFFICIAL_SERVER_URL = 'https://app.lobehub.com';
|
||||
export const OFFICIAL_GATEWAY_URL = 'https://device-gateway.lobehub.com';
|
||||
|
||||
@@ -23,6 +23,7 @@ function getLogFilePath() {
|
||||
|
||||
export interface DaemonStatus {
|
||||
connectionStatus: string;
|
||||
deviceId?: string;
|
||||
gatewayUrl: string;
|
||||
pid: number;
|
||||
startedAt: string;
|
||||
@@ -159,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, LOBEHUB_DAEMON: '1' },
|
||||
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', LOBEHUB_DAEMON: '1' },
|
||||
stdio: ['ignore', logFd, logFd],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import { createProgram } from './program';
|
||||
|
||||
createProgram().parse();
|
||||
createProgram().parse(process.argv, { from: 'node' });
|
||||
|
||||
@@ -20,13 +20,16 @@ import { registerLogoutCommand } from './commands/logout';
|
||||
import { registerManCommand } from './commands/man';
|
||||
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';
|
||||
import { registerSessionGroupCommand } from './commands/session-group';
|
||||
import { registerSkillCommand } from './commands/skill';
|
||||
import { registerStatusCommand } from './commands/status';
|
||||
import { registerTaskCommand } from './commands/task';
|
||||
import { registerThreadCommand } from './commands/thread';
|
||||
import { registerTopicCommand } from './commands/topic';
|
||||
import { registerUserCommand } from './commands/user';
|
||||
@@ -61,15 +64,18 @@ export function createProgram() {
|
||||
registerFileCommand(program);
|
||||
registerSkillCommand(program);
|
||||
registerSessionGroupCommand(program);
|
||||
registerTaskCommand(program);
|
||||
registerThreadCommand(program);
|
||||
registerTopicCommand(program);
|
||||
registerMessageCommand(program);
|
||||
registerModelCommand(program);
|
||||
registerNotifyCommand(program);
|
||||
registerProviderCommand(program);
|
||||
registerPluginCommand(program);
|
||||
registerUserCommand(program);
|
||||
registerConfigCommand(program);
|
||||
registerEvalCommand(program);
|
||||
registerMigrateCommand(program);
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { OFFICIAL_SERVER_URL } from '../constants/urls';
|
||||
import { OFFICIAL_AGENT_GATEWAY_URL, OFFICIAL_SERVER_URL } from '../constants/urls';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
export interface StoredSettings {
|
||||
agentGatewayUrl?: string;
|
||||
gatewayUrl?: string;
|
||||
serverUrl?: string;
|
||||
}
|
||||
@@ -25,15 +26,24 @@ export function resolveServerUrl(): string {
|
||||
return envServerUrl || settingsServerUrl || OFFICIAL_SERVER_URL;
|
||||
}
|
||||
|
||||
export function resolveAgentGatewayUrl(): string | undefined {
|
||||
const envUrl = normalizeUrl(process.env.AGENT_GATEWAY_URL);
|
||||
const settingsUrl = normalizeUrl(loadSettings()?.agentGatewayUrl);
|
||||
|
||||
return envUrl || settingsUrl || OFFICIAL_AGENT_GATEWAY_URL;
|
||||
}
|
||||
|
||||
export function saveSettings(settings: StoredSettings): void {
|
||||
const serverUrl = normalizeUrl(settings.serverUrl);
|
||||
const agentGatewayUrl = normalizeUrl(settings.agentGatewayUrl);
|
||||
const gatewayUrl = normalizeUrl(settings.gatewayUrl);
|
||||
const serverUrl = normalizeUrl(settings.serverUrl);
|
||||
const normalized: StoredSettings = {
|
||||
agentGatewayUrl: agentGatewayUrl === OFFICIAL_AGENT_GATEWAY_URL ? undefined : agentGatewayUrl,
|
||||
gatewayUrl,
|
||||
serverUrl: serverUrl === OFFICIAL_SERVER_URL ? undefined : serverUrl,
|
||||
};
|
||||
|
||||
if (!normalized.serverUrl && !normalized.gatewayUrl) {
|
||||
if (!normalized.serverUrl && !normalized.gatewayUrl && !normalized.agentGatewayUrl) {
|
||||
try {
|
||||
fs.unlinkSync(SETTINGS_FILE);
|
||||
} catch {}
|
||||
@@ -50,14 +60,16 @@ export function loadSettings(): StoredSettings | null {
|
||||
try {
|
||||
const data = fs.readFileSync(SETTINGS_FILE, 'utf8');
|
||||
const parsed = JSON.parse(data) as StoredSettings;
|
||||
const agentGatewayUrl = normalizeUrl(parsed.agentGatewayUrl);
|
||||
const gatewayUrl = normalizeUrl(parsed.gatewayUrl);
|
||||
const serverUrl = normalizeUrl(parsed.serverUrl);
|
||||
const normalized: StoredSettings = {
|
||||
agentGatewayUrl: agentGatewayUrl === OFFICIAL_AGENT_GATEWAY_URL ? undefined : agentGatewayUrl,
|
||||
gatewayUrl,
|
||||
serverUrl: serverUrl === OFFICIAL_SERVER_URL ? undefined : serverUrl,
|
||||
};
|
||||
|
||||
if (!normalized.serverUrl && !normalized.gatewayUrl) return null;
|
||||
if (!normalized.serverUrl && !normalized.gatewayUrl && !normalized.agentGatewayUrl) return null;
|
||||
|
||||
return normalized;
|
||||
} catch {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { streamAgentEvents } from './agentStream';
|
||||
import { streamAgentEvents, streamAgentEventsViaWebSocket } from './agentStream';
|
||||
|
||||
vi.mock('./logger', () => ({
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
heartbeat: vi.fn(),
|
||||
info: vi.fn(),
|
||||
@@ -193,3 +194,391 @@ describe('streamAgentEvents', () => {
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ── WebSocket stream tests ──────────────────────────────
|
||||
|
||||
let capturedWs: MockWebSocket | undefined;
|
||||
|
||||
class MockWebSocket {
|
||||
static OPEN = 1;
|
||||
static CONNECTING = 0;
|
||||
static CLOSED = 3;
|
||||
|
||||
readyState = MockWebSocket.CONNECTING;
|
||||
onopen: ((ev: any) => void) | null = null;
|
||||
onmessage: ((ev: any) => void) | null = null;
|
||||
onerror: ((ev: any) => void) | null = null;
|
||||
onclose: ((ev: any) => void) | null = null;
|
||||
|
||||
sent: string[] = [];
|
||||
private autoAuthSuccess = true;
|
||||
|
||||
constructor(
|
||||
public url: string,
|
||||
autoAuth = true,
|
||||
) {
|
||||
this.autoAuthSuccess = autoAuth;
|
||||
capturedWs = this; // eslint-disable-line @typescript-eslint/no-this-alias
|
||||
// Trigger onopen on next microtask (after handlers are assigned)
|
||||
queueMicrotask(() => {
|
||||
this.readyState = MockWebSocket.OPEN;
|
||||
this.onopen?.({ type: 'open' });
|
||||
});
|
||||
}
|
||||
|
||||
send(data: string) {
|
||||
this.sent.push(data);
|
||||
const msg = JSON.parse(data);
|
||||
|
||||
if (msg.type === 'auth' && this.autoAuthSuccess) {
|
||||
queueMicrotask(() => {
|
||||
this.onmessage?.({ data: JSON.stringify({ type: 'auth_success' }) });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = MockWebSocket.CLOSED;
|
||||
// Async like real WebSocket — fires after current microtask
|
||||
queueMicrotask(() => this.onclose?.({ code: 1000, reason: '' }));
|
||||
}
|
||||
|
||||
simulateMessage(msg: Record<string, unknown>) {
|
||||
this.onmessage?.({ data: JSON.stringify(msg) });
|
||||
}
|
||||
}
|
||||
|
||||
describe('streamAgentEventsViaWebSocket', () => {
|
||||
let stdoutSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
const originalWebSocket = globalThis.WebSocket;
|
||||
|
||||
beforeEach(() => {
|
||||
capturedWs = undefined;
|
||||
stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
(globalThis as any).WebSocket = MockWebSocket;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stdoutSpy.mockRestore();
|
||||
consoleSpy.mockRestore();
|
||||
globalThis.WebSocket = originalWebSocket;
|
||||
});
|
||||
|
||||
/** Wait for microtasks + short delay so WS open/auth cycle completes */
|
||||
const flush = () => new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
it('should connect, authenticate, and send resume', async () => {
|
||||
const promise = streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://gw.test.com',
|
||||
operationId: 'op-1',
|
||||
token: 'test-token',
|
||||
});
|
||||
|
||||
await flush();
|
||||
|
||||
const ws = capturedWs!;
|
||||
expect(ws.sent.map((s) => JSON.parse(s))).toEqual([
|
||||
{ token: 'test-token', type: 'auth' },
|
||||
{ lastEventId: '', type: 'resume' },
|
||||
]);
|
||||
|
||||
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',
|
||||
operationId: 'op-1',
|
||||
token: 'test-token',
|
||||
});
|
||||
|
||||
await flush();
|
||||
const ws = capturedWs!;
|
||||
|
||||
ws.simulateMessage({
|
||||
event: { data: null, operationId: 'op-1', stepIndex: 0, timestamp: 1, type: 'step_start' },
|
||||
id: '1',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: { chunkType: 'text', content: 'Hello WS!' },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 2,
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
id: '2',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: { stepCount: 1 },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 3,
|
||||
type: 'agent_runtime_end',
|
||||
},
|
||||
id: '3',
|
||||
type: 'agent_event',
|
||||
});
|
||||
|
||||
await promise;
|
||||
expect(stdoutSpy).toHaveBeenCalledWith('Hello WS!');
|
||||
});
|
||||
|
||||
it('should output JSON when json option is set', async () => {
|
||||
const promise = streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://gw.test.com',
|
||||
json: true,
|
||||
operationId: 'op-1',
|
||||
token: 'test-token',
|
||||
});
|
||||
|
||||
await flush();
|
||||
const ws = capturedWs!;
|
||||
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: null,
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 1,
|
||||
type: 'agent_runtime_init',
|
||||
},
|
||||
id: '1',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: { stepCount: 1 },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 2,
|
||||
type: 'agent_runtime_end',
|
||||
},
|
||||
id: '2',
|
||||
type: 'agent_event',
|
||||
});
|
||||
|
||||
await promise;
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('"agent_runtime_init"'));
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('"agent_runtime_end"'));
|
||||
});
|
||||
|
||||
it('should reject on auth failure', async () => {
|
||||
// Override mock to return auth_failed instead of auth_success
|
||||
(globalThis as any).WebSocket = class extends MockWebSocket {
|
||||
constructor(url: string) {
|
||||
super(url, false); // disable auto auth_success
|
||||
capturedWs = this; // eslint-disable-line @typescript-eslint/no-this-alias
|
||||
}
|
||||
|
||||
override send(data: string) {
|
||||
this.sent.push(data);
|
||||
const msg = JSON.parse(data);
|
||||
if (msg.type === 'auth') {
|
||||
queueMicrotask(() => {
|
||||
this.onmessage?.({
|
||||
data: JSON.stringify({ reason: 'invalid token', type: 'auth_failed' }),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await expect(
|
||||
streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://gw.test.com',
|
||||
operationId: 'op-1',
|
||||
token: 'bad-token',
|
||||
}),
|
||||
).rejects.toThrow('Gateway auth failed');
|
||||
});
|
||||
|
||||
it('should resolve on session_complete', async () => {
|
||||
const promise = streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://gw.test.com',
|
||||
operationId: 'op-1',
|
||||
token: 'test-token',
|
||||
});
|
||||
|
||||
await flush();
|
||||
capturedWs!.simulateMessage({ id: '1', summary: 'All done', type: 'session_complete' });
|
||||
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should ignore heartbeat_ack messages', async () => {
|
||||
const promise = streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://gw.test.com',
|
||||
operationId: 'op-1',
|
||||
token: 'test-token',
|
||||
});
|
||||
|
||||
await flush();
|
||||
const ws = capturedWs!;
|
||||
|
||||
ws.simulateMessage({ type: 'heartbeat_ack' });
|
||||
expect(stdoutSpy).not.toHaveBeenCalled();
|
||||
|
||||
ws.simulateMessage({ id: '1', type: 'session_complete' });
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('should construct correct WebSocket URL from HTTPS gateway URL', async () => {
|
||||
const promise = streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://agent-gateway.lobehub.com',
|
||||
operationId: 'op-123',
|
||||
token: 'tok',
|
||||
});
|
||||
|
||||
await flush();
|
||||
expect(capturedWs!.url).toBe('wss://agent-gateway.lobehub.com/ws?operationId=op-123');
|
||||
|
||||
capturedWs!.simulateMessage({ id: '1', type: 'session_complete' });
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('should render a multi-step agent run with tool calls', async () => {
|
||||
const promise = streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://gw.test.com',
|
||||
operationId: 'op-1',
|
||||
token: 'tok',
|
||||
verbose: true,
|
||||
});
|
||||
|
||||
await flush();
|
||||
const ws = capturedWs!;
|
||||
const { log } = await import('./logger');
|
||||
|
||||
// Step 1: thinking + text + tool call
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: null,
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 1,
|
||||
type: 'agent_runtime_init',
|
||||
},
|
||||
id: '1',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: { data: null, operationId: 'op-1', stepIndex: 0, timestamp: 2, type: 'step_start' },
|
||||
id: '2',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: { chunkType: 'reasoning', reasoning: 'Let me search...' },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 3,
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
id: '3',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: { chunkType: 'text', content: 'Searching for news.' },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 4,
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
id: '4',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: { toolCalling: { apiName: 'search', id: 'tc-1' } },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 5,
|
||||
type: 'tool_start',
|
||||
},
|
||||
id: '5',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: { data: null, operationId: 'op-1', stepIndex: 0, timestamp: 6, type: 'stream_end' },
|
||||
id: '6',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: { stepIndex: 0 },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 7,
|
||||
type: 'step_complete',
|
||||
},
|
||||
id: '7',
|
||||
type: 'agent_event',
|
||||
});
|
||||
|
||||
// Step 2: tool result + final text
|
||||
ws.simulateMessage({
|
||||
event: { data: null, operationId: 'op-1', stepIndex: 1, timestamp: 8, type: 'step_start' },
|
||||
id: '8',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: {
|
||||
isSuccess: true,
|
||||
payload: { toolCalling: { id: 'tc-1' } },
|
||||
result: { content: 'Results...' },
|
||||
},
|
||||
operationId: 'op-1',
|
||||
stepIndex: 1,
|
||||
timestamp: 9,
|
||||
type: 'tool_end',
|
||||
},
|
||||
id: '9',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: { chunkType: 'text', content: 'Here are the results.' },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 1,
|
||||
timestamp: 10,
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
id: '10',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: { cost: { total: 0.05 }, stepCount: 2, usage: { total_tokens: 500 } },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 1,
|
||||
timestamp: 11,
|
||||
type: 'agent_runtime_end',
|
||||
},
|
||||
id: '11',
|
||||
type: 'agent_event',
|
||||
});
|
||||
|
||||
await promise;
|
||||
|
||||
// Verify reasoning was rendered (dim)
|
||||
expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('Let me search...'));
|
||||
// Verify text chunks
|
||||
expect(stdoutSpy).toHaveBeenCalledWith('Searching for news.');
|
||||
expect(stdoutSpy).toHaveBeenCalledWith('Here are the results.');
|
||||
// Verify tool call was logged
|
||||
expect(log.toolCall).toHaveBeenCalledWith('search', 'tc-1', undefined);
|
||||
// Verify tool result was logged
|
||||
expect(log.toolResult).toHaveBeenCalled();
|
||||
// Verify finish line
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Agent finished'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import pc from 'picocolors';
|
||||
import urlJoin from 'url-join';
|
||||
|
||||
import { log } from './logger';
|
||||
|
||||
@@ -16,6 +17,12 @@ interface StreamOptions {
|
||||
verbose?: boolean;
|
||||
}
|
||||
|
||||
interface WebSocketStreamOptions extends StreamOptions {
|
||||
gatewayUrl: string;
|
||||
operationId: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the agent SSE stream and render events to the terminal.
|
||||
* Resolves when the stream ends (agent_runtime_end or connection close).
|
||||
@@ -152,6 +159,126 @@ export function replayAgentEvents(events: AgentStreamEvent[], options: StreamOpt
|
||||
}
|
||||
}
|
||||
|
||||
const HEARTBEAT_INTERVAL = 30_000;
|
||||
|
||||
/**
|
||||
* Connect to the Agent Gateway via WebSocket and render events to the terminal.
|
||||
* Resolves when the session completes or the connection closes.
|
||||
*/
|
||||
export async function streamAgentEventsViaWebSocket(
|
||||
options: WebSocketStreamOptions,
|
||||
): Promise<void> {
|
||||
const { gatewayUrl, operationId, token, ...streamOpts } = options;
|
||||
const wsUrl = urlJoin(
|
||||
gatewayUrl.replace(/^http/, 'ws'),
|
||||
`/ws?operationId=${encodeURIComponent(operationId)}`,
|
||||
);
|
||||
|
||||
log.debug(`Connecting to gateway: ${wsUrl}`);
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
const jsonEvents: AgentStreamEvent[] = [];
|
||||
const ctx = createRenderContext();
|
||||
let lastEventId = '';
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let jsonPrinted = false;
|
||||
|
||||
const cleanup = () => {
|
||||
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
||||
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
||||
ws.close();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ token, type: 'auth' }));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data as string);
|
||||
|
||||
if (msg.type === 'auth_success') {
|
||||
log.debug('Gateway authenticated');
|
||||
// Request all buffered events (covers events pushed before WS connected)
|
||||
ws.send(JSON.stringify({ lastEventId: '', type: 'resume' }));
|
||||
heartbeatTimer = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'heartbeat' }));
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'auth_failed') {
|
||||
cleanup();
|
||||
reject(new Error(`Gateway auth failed: ${msg.reason}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'heartbeat_ack') return;
|
||||
|
||||
if (msg.type === 'agent_event') {
|
||||
const agentEvent: AgentStreamEvent = msg.event;
|
||||
if (msg.id) lastEventId = msg.id;
|
||||
|
||||
if (streamOpts.json) {
|
||||
jsonEvents.push(agentEvent);
|
||||
} else {
|
||||
renderEvent(agentEvent, ctx, streamOpts);
|
||||
}
|
||||
|
||||
if (agentEvent.type === 'agent_runtime_end') {
|
||||
if (streamOpts.json && !jsonPrinted) {
|
||||
jsonPrinted = true;
|
||||
console.log(JSON.stringify(jsonEvents, null, 2));
|
||||
} else if (!streamOpts.json) {
|
||||
renderEnd(agentEvent);
|
||||
}
|
||||
cleanup();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (agentEvent.type === 'error') {
|
||||
if (streamOpts.json && !jsonPrinted) {
|
||||
jsonPrinted = true;
|
||||
console.log(JSON.stringify(jsonEvents, null, 2));
|
||||
}
|
||||
log.error(
|
||||
`Agent error: ${agentEvent.data?.message || agentEvent.data?.error || 'Unknown error'}`,
|
||||
);
|
||||
cleanup();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.type === 'session_complete') {
|
||||
if (streamOpts.json && jsonEvents.length > 0 && !jsonPrinted) {
|
||||
jsonPrinted = true;
|
||||
console.log(JSON.stringify(jsonEvents, null, 2));
|
||||
}
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (err) => {
|
||||
cleanup();
|
||||
reject(err);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
||||
if (streamOpts.json && jsonEvents.length > 0 && !jsonPrinted) {
|
||||
jsonPrinted = true;
|
||||
console.log(JSON.stringify(jsonEvents, null, 2));
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ── Render helpers ──────────────────────────────────────
|
||||
|
||||
interface RenderContext {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { readStatus } from '../daemon/manager';
|
||||
|
||||
export function resolveLocalDeviceId(): string | undefined {
|
||||
return readStatus()?.deviceId;
|
||||
}
|
||||
@@ -387,6 +387,102 @@ export function printCalendarHeatmap(
|
||||
console.log();
|
||||
}
|
||||
|
||||
// ── Kanban Board ─────────────────────────────────────
|
||||
|
||||
export interface KanbanColumn {
|
||||
color?: (s: string) => string;
|
||||
items: KanbanCard[];
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface KanbanCard {
|
||||
badge?: string;
|
||||
meta?: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a kanban board with side-by-side columns.
|
||||
* Adapts column width to terminal width automatically.
|
||||
*/
|
||||
export function printKanban(columns: KanbanColumn[]) {
|
||||
// Filter out empty columns
|
||||
const cols = columns.filter((c) => c.items.length > 0);
|
||||
if (cols.length === 0) return;
|
||||
|
||||
const termWidth = process.stdout.columns || 100;
|
||||
// Each column gets equal width, with 1-char gap between
|
||||
const colWidth = Math.max(20, Math.floor((termWidth - (cols.length - 1)) / cols.length));
|
||||
const innerWidth = colWidth - 4; // 2 chars border + 2 padding
|
||||
|
||||
const maxRows = Math.max(...cols.map((c) => c.items.length));
|
||||
|
||||
// ── Header ──
|
||||
const topBorder = cols
|
||||
.map((c) => {
|
||||
const titleStr = ` ${c.title} (${c.items.length}) `;
|
||||
const color = c.color || pc.white;
|
||||
const remaining = colWidth - 2 - displayWidth(titleStr);
|
||||
const left = Math.floor(remaining / 2);
|
||||
const right = remaining - left;
|
||||
return color(
|
||||
'┌' + '─'.repeat(Math.max(0, left)) + titleStr + '─'.repeat(Math.max(0, right)) + '┐',
|
||||
);
|
||||
})
|
||||
.join(' ');
|
||||
console.log(topBorder);
|
||||
|
||||
// ── Rows ──
|
||||
for (let row = 0; row < maxRows; row++) {
|
||||
const line = cols
|
||||
.map((c) => {
|
||||
const color = c.color || pc.white;
|
||||
const item = c.items[row];
|
||||
if (!item) {
|
||||
return color('│') + ' '.repeat(colWidth - 2) + color('│');
|
||||
}
|
||||
|
||||
const badge = item.badge ? item.badge + ' ' : '';
|
||||
const badgeWidth = displayWidth(badge);
|
||||
const titleMaxWidth = innerWidth - badgeWidth;
|
||||
const title = truncate(item.title, titleMaxWidth);
|
||||
const titleWidth = displayWidth(title);
|
||||
const pad = ' '.repeat(Math.max(0, colWidth - 2 - badgeWidth - titleWidth - 2));
|
||||
return color('│') + ' ' + badge + title + pad + ' ' + color('│');
|
||||
})
|
||||
.join(' ');
|
||||
console.log(line);
|
||||
|
||||
// Print meta line if any card in this row has meta
|
||||
const hasMeta = cols.some((c) => c.items[row]?.meta);
|
||||
if (hasMeta) {
|
||||
const metaLine = cols
|
||||
.map((c) => {
|
||||
const color = c.color || pc.white;
|
||||
const item = c.items[row];
|
||||
if (!item?.meta) {
|
||||
return color('│') + ' '.repeat(colWidth - 2) + color('│');
|
||||
}
|
||||
const meta = truncate(item.meta, innerWidth);
|
||||
const metaWidth = displayWidth(meta);
|
||||
const pad = ' '.repeat(Math.max(0, colWidth - 2 - metaWidth - 2));
|
||||
return color('│') + ' ' + pc.dim(meta) + pad + ' ' + color('│');
|
||||
})
|
||||
.join(' ');
|
||||
console.log(metaLine);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bottom border ──
|
||||
const bottomBorder = cols
|
||||
.map((c) => {
|
||||
const color = c.color || pc.white;
|
||||
return color('└' + '─'.repeat(colWidth - 2) + '┘');
|
||||
})
|
||||
.join(' ');
|
||||
console.log(bottomBorder);
|
||||
}
|
||||
|
||||
export function confirm(message: string): Promise<boolean> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
||||
return new Promise((resolve) => {
|
||||
|
||||
@@ -9,6 +9,10 @@ export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
fixedExtension: false,
|
||||
format: ['esm'],
|
||||
minify: true,
|
||||
outputOptions: {
|
||||
codeSplitting: false,
|
||||
},
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
});
|
||||
|
||||
@@ -109,6 +109,26 @@ 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', { stdio: 'inherit', cwd: path.resolve(__dirname, '../cli') });
|
||||
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:
|
||||
@@ -296,7 +316,10 @@ const config = {
|
||||
releaseNotes: process.env.RELEASE_NOTES || undefined,
|
||||
},
|
||||
|
||||
extraResources: [{ from: 'resources/bin', to: 'bin' }],
|
||||
extraResources: [
|
||||
{ from: 'resources/bin', to: 'bin' },
|
||||
{ from: 'resources/cli-package.json', to: 'package.json' },
|
||||
],
|
||||
|
||||
win: {
|
||||
executableName: 'LobeHub',
|
||||
|
||||
@@ -90,7 +90,6 @@ export default defineConfig({
|
||||
outDir: 'dist/preload',
|
||||
sourcemap: isDev ? 'inline' : false,
|
||||
},
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src/main'),
|
||||
|
||||
@@ -68,7 +68,9 @@
|
||||
if (resolvedTheme === 'dark' || resolvedTheme === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', resolvedTheme);
|
||||
}
|
||||
var locale = navigator.language || 'en-US';
|
||||
// Check URL query parameter for locale (set by Electron main process from stored settings)
|
||||
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 =
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"author": "LobeHub",
|
||||
"main": "./dist/main/index.js",
|
||||
"scripts": {
|
||||
"build:cli": "cd ../cli && 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",
|
||||
@@ -68,7 +69,7 @@
|
||||
"cookie": "^1.1.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"diff": "^8.0.4",
|
||||
"electron": "41.0.2",
|
||||
"electron": "41.1.0",
|
||||
"electron-builder": "^26.8.1",
|
||||
"electron-devtools-installer": "4.0.0",
|
||||
"electron-is": "^3.0.0",
|
||||
|
||||
@@ -5,7 +5,7 @@ import path from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const VERSION = '0.20.1';
|
||||
const VERSION = '0.24.0';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const binDir = path.join(__dirname, '..', 'resources', 'bin');
|
||||
|
||||
@@ -9,7 +9,7 @@ import { tagWhite, writeJSON } from './utils';
|
||||
export const genDefaultLocale = () => {
|
||||
consola.info(`默认语言为 ${i18nConfig.entryLocale}...`);
|
||||
|
||||
// 确保入口语言目录存在
|
||||
// Ensure entry locale directory exists
|
||||
const entryLocaleDir = localeDir(i18nConfig.entryLocale);
|
||||
if (!existsSync(entryLocaleDir)) {
|
||||
mkdirSync(entryLocaleDir, { recursive: true });
|
||||
@@ -23,7 +23,7 @@ export const genDefaultLocale = () => {
|
||||
for (const [ns, value] of data) {
|
||||
const filepath = entryLocaleJsonFilepath(`${ns}.json`);
|
||||
|
||||
// 确保目录存在
|
||||
// Ensure directory exists
|
||||
const dir = dirname(filepath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
@@ -5,7 +5,7 @@ import { genDefaultLocale } from './genDefaultLocale';
|
||||
import { genDiff } from './genDiff';
|
||||
import { split } from './utils';
|
||||
|
||||
// 确保所有语言目录存在
|
||||
// Ensure all locale directories exist
|
||||
const ensureLocalesDirs = () => {
|
||||
[i18nConfig.entryLocale, ...i18nConfig.outputLocales].forEach((locale) => {
|
||||
const dir = localeDir(locale);
|
||||
@@ -15,20 +15,20 @@ const ensureLocalesDirs = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// 运行工作流
|
||||
// Run workflow
|
||||
const run = async () => {
|
||||
// 确保目录存在
|
||||
// Ensure directories exist
|
||||
ensureLocalesDirs();
|
||||
|
||||
// 差异分析
|
||||
// Diff analysis
|
||||
split('差异分析');
|
||||
genDiff();
|
||||
|
||||
// 生成默认语言文件
|
||||
// Generate default locale files
|
||||
split('生成默认语言文件');
|
||||
genDefaultLocale();
|
||||
|
||||
// 生成国际化文件
|
||||
// Generate i18n files
|
||||
split('生成国际化文件');
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,17 +10,38 @@ 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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
import type { ElectronAppState, ThemeMode } from '@lobechat/electron-client-ipc';
|
||||
@@ -169,7 +171,7 @@ export default class SystemController extends ControllerModule {
|
||||
async selectFolder(payload?: {
|
||||
defaultPath?: string;
|
||||
title?: string;
|
||||
}): Promise<string | undefined> {
|
||||
}): Promise<{ path: string; repoType?: 'git' | 'github' } | undefined> {
|
||||
const mainWindow = this.app.browserManager.getMainWindow()?.browserWindow;
|
||||
|
||||
const result = await dialog.showOpenDialog(mainWindow!, {
|
||||
@@ -182,7 +184,10 @@ export default class SystemController extends ControllerModule {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return result.filePaths[0];
|
||||
const folderPath = result.filePaths[0];
|
||||
const repoType = await this.detectRepoType(folderPath);
|
||||
|
||||
return { path: folderPath, repoType };
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
@@ -230,6 +235,17 @@ export default class SystemController extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
private async detectRepoType(dirPath: string): Promise<'git' | 'github' | undefined> {
|
||||
const gitConfigPath = path.join(dirPath, '.git', 'config');
|
||||
try {
|
||||
const config = await readFile(gitConfigPath, 'utf8');
|
||||
if (config.includes('github.com')) return 'github';
|
||||
return 'git';
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async setSystemThemeMode(themeMode: ThemeMode) {
|
||||
nativeTheme.themeSource = themeMode;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { UpdateChannel, UpdaterState } from '@lobechat/electron-client-ipc';
|
||||
|
||||
import { UPDATE_CHANNEL } from '@/modules/updater/configs';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
@@ -46,11 +47,11 @@ export default class UpdaterCtr extends ControllerModule {
|
||||
|
||||
@IpcMethod()
|
||||
async getUpdateChannel(): Promise<UpdateChannel> {
|
||||
return this.app.storeManager.get('updateChannel') ?? 'stable';
|
||||
return this.app.storeManager.get('updateChannel') ?? UPDATE_CHANNEL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the build-time channel (stable, nightly, canary, beta).
|
||||
* Get the build-time channel (stable, canary, beta, or legacy nightly).
|
||||
* Used for display in About page to distinguish pre-release builds.
|
||||
*/
|
||||
@IpcMethod()
|
||||
@@ -61,11 +62,12 @@ export default class UpdaterCtr extends ControllerModule {
|
||||
|
||||
@IpcMethod()
|
||||
async setUpdateChannel(channel: UpdateChannel): Promise<void> {
|
||||
const validChannels = new Set(['stable', 'nightly', 'canary']);
|
||||
const validChannels = new Set<UpdateChannel>(['stable', 'canary']);
|
||||
if (!validChannels.has(channel)) {
|
||||
logger.warn(`Invalid update channel: ${channel}, ignoring`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Set update channel requested: ${channel}`);
|
||||
this.app.storeManager.set('updateChannel', channel);
|
||||
this.app.updaterManager.switchChannel(channel);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user