mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
Compare commits
230 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ef7e7e84e | |||
| aaa8de0254 | |||
| 644d1b6788 | |||
| c4f7995863 | |||
| 746bf4f316 | |||
| 58dd297141 | |||
| a4e5a20b4d | |||
| 95f41f8cec | |||
| f7fbc1c833 | |||
| 0f5fb54cb6 | |||
| feaaaba2a9 | |||
| 21f6f94bed | |||
| b180c03e04 | |||
| 0d39dff2d5 | |||
| 6fb24adbd2 | |||
| a09991af8c | |||
| 4c76d2430f | |||
| 8ed31dfca4 | |||
| c374892fea | |||
| 4617468e87 | |||
| 4c3a71a2c3 | |||
| 7892e553ea | |||
| 793a8deb43 | |||
| e56ccf6a5c | |||
| 9756daba2d | |||
| 2b165ec722 | |||
| 8105fc0b16 | |||
| 2d3332200a | |||
| cb8645f65a | |||
| cef69e9b72 | |||
| d0b938a0cb | |||
| af319af936 | |||
| 4ebd8f7f7c | |||
| de698eef92 | |||
| c03e79c118 | |||
| aef7158f4a | |||
| be42e056e6 | |||
| b47e32436e | |||
| 85b412270b | |||
| 0e216dec8e | |||
| 1d2db96a38 | |||
| 4dade3196f | |||
| f934e2ff46 | |||
| 1bc8d59922 | |||
| 8fab0b014e | |||
| 507909dc2c | |||
| 4721d14a81 | |||
| e1a5b27db0 | |||
| 03621d0664 | |||
| fcc5aa181a | |||
| 4d934f8275 | |||
| c760171f49 | |||
| c7b7717faa | |||
| 385afbcc57 | |||
| d051ac008c | |||
| 9b2832bba9 | |||
| 9b5cea7391 | |||
| f7f8bc625f | |||
| 83bc73c2ae | |||
| 75fd477bff | |||
| 26da6b9ad4 | |||
| 1d4fb21885 | |||
| 38c92fa04a | |||
| 555a375e67 | |||
| 6989e8f9e6 | |||
| e4d1d1fc17 | |||
| 026c79a4c2 | |||
| 1e2782ece4 | |||
| b5ddac56dc | |||
| ad0da3753e | |||
| e6905fe0fd | |||
| a9d2110565 | |||
| e4d5f69b27 | |||
| a372acd50d | |||
| 0af5e51477 | |||
| 40f0557158 | |||
| 62f06540ba | |||
| 43b064f803 | |||
| 8e8a463a05 | |||
| decc25554e | |||
| 1c8ec2681c | |||
| 0a32fbc737 | |||
| 7fc41a9677 | |||
| 22c880763d | |||
| d324736edf | |||
| 608498a950 | |||
| 5e1a35f259 | |||
| 6b010c8380 | |||
| ead5631bab | |||
| ddd5c20836 | |||
| c51835193f | |||
| 0c375e4428 | |||
| 58cda8a645 | |||
| 65ba4ad435 | |||
| 41ffd1e0d3 | |||
| 02767bac55 | |||
| be5d61d40a | |||
| 282b20c454 | |||
| cc506c036d | |||
| 5fca91a488 | |||
| c3530ad221 | |||
| 8b8b0f0579 | |||
| 958bf52978 | |||
| 480d4b2b4e | |||
| 4d00c22e7f | |||
| f30d9da5a9 | |||
| 831b4ee5ca | |||
| c744eab116 | |||
| 7697399da8 | |||
| 05a9eae504 | |||
| cc1e0d29d3 | |||
| 0e6eba61a9 | |||
| 3e8016b502 | |||
| 970733aaeb | |||
| c72b1ee698 | |||
| 7bf923d762 | |||
| 10300ba0e1 | |||
| 431abf36d6 | |||
| ce516fff9d | |||
| 9e231835b2 | |||
| 79b84a68ec | |||
| 56e811f5bd | |||
| 5fb795b092 | |||
| fbe71e76db | |||
| d83f0a0f2f | |||
| fe65741a32 | |||
| b5e4cd0805 | |||
| f565ca9450 | |||
| e6d49fdb76 | |||
| 47c524a388 | |||
| cb4412421f | |||
| 78b3dbed03 | |||
| 95375cec79 | |||
| aa3c7e585b | |||
| 11e6619a3c | |||
| 41719dfd29 | |||
| b66e83a57c | |||
| bc103b2e11 | |||
| d28b401aaf | |||
| a79cdd19f8 | |||
| 222f525bf4 | |||
| 317fdcec13 | |||
| 162d6cfa67 | |||
| 2870cc73c2 | |||
| d5097c7964 | |||
| aa3d245cfd | |||
| 61c3f42f10 | |||
| 2dd52c6813 | |||
| 3f82249ed1 | |||
| b49c1c15b7 | |||
| df32dd4966 | |||
| b5d7696dbd | |||
| d2d81ba64a | |||
| b2130f7612 | |||
| 626d274859 | |||
| 9c509680b9 | |||
| 70f81ad1a1 | |||
| c401d1b97f | |||
| eddb0c991b | |||
| 6340ab55e9 | |||
| 86a23b5555 | |||
| 3cb06e07e3 | |||
| c9b44935ed | |||
| 948ba5ec68 | |||
| d0091901dc | |||
| 8c3b83f8b3 | |||
| b031513321 | |||
| c2b379139d | |||
| 6d1d8a0d16 | |||
| dc3c48e469 | |||
| 79dc61ac50 | |||
| 506bb7b29f | |||
| 807af0688f | |||
| 1d9b6099bd | |||
| 5fc7eea754 | |||
| a9716975a7 | |||
| c77d201c49 | |||
| 39107ba107 | |||
| d0e99aada4 | |||
| 13e8ef9c7b | |||
| 8387067807 | |||
| 375e6381ce | |||
| f7c1ebf652 | |||
| 156a870cf3 | |||
| f017dcd0ea | |||
| 719a554456 | |||
| 3b1eef72d8 | |||
| 9e20cd6b3a | |||
| a5f4b4b569 | |||
| 5a15f759d6 | |||
| b7ecf2fd4d | |||
| 24062bb412 | |||
| 61d432a991 | |||
| f59954137a | |||
| 1324b67590 | |||
| f390d04ef2 | |||
| 84df8a9994 | |||
| 9aea74659f | |||
| 105321bfe1 | |||
| b0b6e67d5f | |||
| d2aa3cd1b4 | |||
| babdc6ade5 | |||
| 7e6255096a | |||
| 0e7eda4b47 | |||
| 71cfba9906 | |||
| b8fe675508 | |||
| 990942fb45 | |||
| ecec2e87e3 | |||
| 7b6978271a | |||
| 28c2e9002a | |||
| b9034ce9c1 | |||
| 2eb7ee824f | |||
| e78949cd23 | |||
| afae236628 | |||
| 8830c6d560 | |||
| f42fc7d65d | |||
| e5e154afcb | |||
| 346812ab88 | |||
| a099749b41 | |||
| fbe8ab3891 | |||
| 2965cbc83a | |||
| fc44aaef38 | |||
| a2b8f4c81a | |||
| 6f9f5643d1 | |||
| e4877436fe | |||
| 04775f66ff | |||
| 9fff5fccf0 | |||
| 5a46c5a971 | |||
| 5722b7159b | |||
| 682657ba50 |
@@ -1,298 +0,0 @@
|
||||
---
|
||||
name: bot
|
||||
description: 'Bot platform architecture (Discord, Slack, Telegram, Feishu/Lark, QQ, WeChat). Use when working on inbound webhooks, Chat SDK message routing, agent execution from chat platforms, queue-mode callbacks, gateway lifecycle (websocket/polling), bot provider CRUD/credentials, or platform-specific clients/adapters/schemas. Triggers on bot, channel, webhook, mention, Chat SDK, agent bot provider, gateway, bot-callback, qstash bot.'
|
||||
---
|
||||
|
||||
# Bot System
|
||||
|
||||
> **Last updated: 2026-04-08.** Implementation evolves quickly — this doc is a map, not the source of truth. Always read the key files below to verify behavior, especially per-platform quirks. Update this doc when the architecture changes.
|
||||
|
||||
LobeChat agents can answer inside external chat platforms. Inbound messages flow through the Chat SDK (`chat` npm package), get routed to the right agent by `(platform, applicationId)`, executed via `AiAgentService`, and replied back through a per-platform `PlatformClient`. There are **two execution modes** (in-memory vs queue/QStash) and **three connection modes** (`webhook`, `websocket`, `polling`).
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
| Platform | id | Default mode | Markdown | Edit | Notes |
|
||||
| -------- | ---------- | ------------------------------- | ----------------- | ------ | -------------------------------------------------------------------------------------- |
|
||||
| Discord | `discord` | `websocket` | yes | yes | Persistent gateway via Chat SDK adapter; reaction-thread quirks; native slash commands |
|
||||
| Slack | `slack` | `websocket` (Socket Mode) | yes (mrkdwn) | yes | Multi-mode — user can pick `webhook` per provider |
|
||||
| Telegram | `telegram` | `webhook` | yes (HTML) | yes | `setMyCommands` menu via `registerBotCommands` |
|
||||
| Feishu | `feishu` | `websocket` (Lark SDK WSClient) | **no** (stripped) | yes | Multi-mode; shared client with Lark |
|
||||
| Lark | `lark` | `websocket` | **no** | yes | Same client/schema as Feishu, different domain |
|
||||
| QQ | `qq` | `websocket` | **no** | **no** | All replies are final-only |
|
||||
| WeChat | `wechat` | `polling` (iLink long-poll) | **no** | **no** | 10-minute gateway window |
|
||||
|
||||
`supportsMarkdown=false` ⇒ outbound markdown is stripped to plain text via `stripMarkdown` and the AI is told not to use markdown. `supportsMessageEdit=false` ⇒ no progress edits — only the final reply is sent.
|
||||
|
||||
**Multi-mode connection** — Slack/Feishu/Lark/QQ ship as websocket but support `webhook` per-provider via `settings.connectionMode`. The runtime always merges schema defaults into stored settings before resolving the mode (`resolveBotProviderConfig` / `resolveConnectionMode` in `platforms/utils.ts`), so the schema's `field.default` is the source of truth — set it correctly when adding a new multi-mode platform.
|
||||
|
||||
## Inbound Flow (one webhook → reply)
|
||||
|
||||
```
|
||||
Platform server
|
||||
│ POST /api/agent/webhooks/[platform]/[appId]
|
||||
▼
|
||||
route.ts ── catch-all `[[...appId]]` route
|
||||
│
|
||||
▼
|
||||
BotMessageRouter (singleton)
|
||||
│ • lazy-loads bot per `platform:applicationId`
|
||||
│ • merges schema defaults + provider.settings (mergeWithDefaults)
|
||||
│ • builds Chat SDK Chat<any> with createIoRedisState (if Redis available)
|
||||
│ • registerHandlers: onNewMention / onSubscribedMessage / onNewMessage(/.dm)
|
||||
│ • registerCommands: /new (reset topic), /stop (interrupt)
|
||||
│
|
||||
▼
|
||||
chatBot.webhooks[platform](req) ← Chat SDK parses → fires events
|
||||
│
|
||||
▼
|
||||
AgentBridgeService.handleMention / handleSubscribedMessage
|
||||
│ • activeThreads guard (no duplicate runs per thread)
|
||||
│ • adds 👀 reaction (eyes), startTyping
|
||||
│ • merges debounced/queued skipped messages (mergeSkippedMessages)
|
||||
│ • extractFiles (buffer → fetchData → url)
|
||||
│ • formatPrompt (sanitize mention + speaker tag + referenced_message)
|
||||
│
|
||||
├── In-memory mode ──► AiAgentService.execAgent({ stepCallbacks })
|
||||
│ → onAfterStep edits progress message live
|
||||
│ → onComplete edits final reply, splits via splitMessage(charLimit)
|
||||
│
|
||||
└── Queue mode (isQueueAgentRuntimeEnabled) ──► execAgent({ stepWebhook, completionWebhook, webhookDelivery: 'qstash' })
|
||||
→ returns immediately, callbacks land at /api/agent/webhooks/bot-callback
|
||||
```
|
||||
|
||||
The router caches loaded bots in memory. Cache is **invalidated** by `BotMessageRouter.invalidateBot(platform, appId)` whenever the TRPC `update`/`delete` mutations run, so new credentials/settings take effect on the next webhook.
|
||||
|
||||
## Execution Modes
|
||||
|
||||
### In-memory (default)
|
||||
|
||||
`AgentBridgeService.executeWithInMemoryCallbacks` wraps `execAgent` with `stepCallbacks`. Lives in one process — Promise-based wait, 30-min timeout, edits the same `progressMessage` after every step. Topic title is summarized inline via `SystemAgentService`.
|
||||
|
||||
### Queue (`isQueueAgentRuntimeEnabled`)
|
||||
|
||||
`AgentBridgeService.executeWithWebhooks`:
|
||||
|
||||
1. Posts the `renderStart` placeholder, captures `progressMessageId`.
|
||||
2. Calls `execAgent` with `stepWebhook` and `completionWebhook` pointing at `${INTERNAL_APP_URL ?? APP_URL}/api/agent/webhooks/bot-callback`, plus `webhookDelivery: 'qstash'`.
|
||||
3. Returns immediately; the bridge `finally` block keeps the active-thread marker held until the `completion` callback fires.
|
||||
|
||||
`/api/agent/webhooks/bot-callback/route.ts` verifies the QStash signature and hands off to `BotCallbackService.handleCallback`:
|
||||
|
||||
- `type: 'step'` → `handleStep` re-renders `renderStepProgress`, edits `progressMessageId` (skipped if `displayToolCalls=false` or platform `supportsMessageEdit=false`).
|
||||
- `type: 'completion'` → `handleCompletion` writes the final reply (or error/interrupted message), removes the 👀 reaction, clears active-thread tracker, fires async `summarizeTopicTitle`.
|
||||
|
||||
`BotCallbackService.createMessenger` reloads provider + credentials from DB and rebuilds a `PlatformClient` per call (no in-memory state).
|
||||
|
||||
## Commands
|
||||
|
||||
Defined in `BotMessageRouter.buildCommands` and registered via two paths:
|
||||
|
||||
- **Native slash commands** (Slack/Discord): `bot.onSlashCommand('/<name>', ...)`
|
||||
- **Text-based fallback** (Telegram/Feishu/QQ/Lark/WeChat): `bot.onNewMessage(/^\/(new|stop)(\s|$|@)/, ...)` plus a per-mention `tryDispatch` so commands work even before subscribe.
|
||||
|
||||
Built-in commands:
|
||||
|
||||
- `/new` — clears `topicId` in thread state, next message starts a fresh topic.
|
||||
- `/stop` — interrupts the active execution (calls `AiAgentService.interruptTask` if `operationId` is known; otherwise queues a deferred stop via `requestStop`/`pendingStopThreads`, also aborts the startup phase via `startupControllers`).
|
||||
|
||||
To add a command, append to `buildCommands` — it auto-registers everywhere; on Telegram it also surfaces in the `/` menu via `client.registerBotCommands` → `setMyCommands`.
|
||||
|
||||
## Active-thread State (statics on `AgentBridgeService`)
|
||||
|
||||
- `activeThreads: Set<threadId>` — prevents duplicate runs per thread (must guard before stale-topic check, otherwise concurrent messages can drop).
|
||||
- `activeOperations: Map<threadId, operationId>` — needed by `/stop` once `execAgent` returns.
|
||||
- `startupControllers: Map<threadId, AbortController>` — cancels pre-`operationId` work (topic/tool prep).
|
||||
- `pendingStopThreads: Set<threadId>` — `/stop` arrived before `operationId` existed; consumed once available.
|
||||
|
||||
In **queue mode**, the bridge `finally` skips cleanup so the marker persists until `BotCallbackService.handleCompletion` calls `clearActiveThread`.
|
||||
|
||||
## Topic Lifecycle in Threads
|
||||
|
||||
- `handleMention` always treats the message as the start of a new conversation.
|
||||
- `handleSubscribedMessage` reads `topicId` from `thread.state`. If the topic is stale (`> 4 hours` since `updatedAt`), state is cleared and it retries as a fresh mention.
|
||||
- If `execAgent` fails with a Postgres FK violation on `topic_id` (cached topic was deleted), the bridge clears state and retries as a mention.
|
||||
- `subscribe()` is gated by `client.shouldSubscribe(threadId)` — Discord top-level channels return `false` so we don't follow up there.
|
||||
|
||||
## Attachments
|
||||
|
||||
`AgentBridgeService.extractFiles` resolves attachments in priority order:
|
||||
|
||||
1. `att.buffer` — already downloaded by the adapter (WeChat/Feishu inbound).
|
||||
2. `att.fetchData()` — adapter-provided lazy download with auth (Telegram, Slack, Feishu history). **Required** when URLs are token-protected — naive `fetch(url)` later in `ingestAttachment.ts` has no credentials.
|
||||
3. `att.url` — public CDN fallback (Discord, public QQ).
|
||||
|
||||
`inferMimeType` / `inferName` patch Telegram-style `photo` payloads (no `mimeType`/`name` from Bot API → defaults to `image/jpeg`) so vision models actually see them. Quoted-message attachments are also pulled from `raw.referenced_message.attachments` (Discord).
|
||||
|
||||
## Concurrency
|
||||
|
||||
`settings.concurrency` is `'queue'` or `'debounce'`:
|
||||
|
||||
- `debounce` → Chat SDK debounces inbound messages by `debounceMs`; `mergeSkippedMessages` joins skipped texts/attachments into the current message before handing to the agent.
|
||||
- `queue` → Chat SDK serializes per-thread; the bridge's own `activeThreads` set is still required because in queue mode the SDK lock releases before the agent finishes.
|
||||
|
||||
## Gateway (persistent platforms)
|
||||
|
||||
Webhook platforms run fine in serverless functions. Persistent platforms (`websocket`, `polling`) need a long-running listener — that's the **gateway**.
|
||||
|
||||
**`GatewayService.startClient(platform, appId, userId)`** (`src/server/services/gateway/index.ts`):
|
||||
|
||||
- On Vercel + persistent mode → `BotConnectQueue.push` (Redis hash) and mark runtime status `queued`. The cron picks it up.
|
||||
- On Vercel + webhook mode → start the client inline (one HTTP call).
|
||||
- Off-Vercel → `GatewayManager` singleton holds long-lived clients in process.
|
||||
|
||||
**`GET /api/agent/gateway/route.ts`** (cron, `Bearer ${CRON_SECRET}`):
|
||||
|
||||
- Iterates registered platforms and starts every enabled persistent provider with `durationMs = 10min`, then in `after(...)` polls `BotConnectQueue` every 30s for new connect requests, until the window expires.
|
||||
- `getEffectiveConnectionMode(platform, settings)` is the only place that resolves per-provider mode — respect it everywhere.
|
||||
|
||||
**`POST /api/agent/gateway/start/route.ts`** is the non-Vercel `ensureRunning` entry point (`Bearer ${KEY_VAULTS_SECRET}`).
|
||||
|
||||
**Runtime status** is stored in Redis at `bot:runtime-status:platform:appId` with TTL ≈ `durationMs + 60s`. States: `starting | connected | disconnected | failed | queued`. Updated by each `PlatformClient.start/stop` and by the gateway service.
|
||||
|
||||
## Platform Definitions
|
||||
|
||||
Each platform exposes a `PlatformDefinition` registered in `platforms/index.ts`:
|
||||
|
||||
```ts
|
||||
{
|
||||
id: 'discord',
|
||||
name: 'Discord',
|
||||
connectionMode: 'websocket', // recommended default
|
||||
schema: FieldSchema[], // applicationId + credentials + settings
|
||||
clientFactory: new DiscordClientFactory(),
|
||||
supportsMarkdown?: boolean, // default true
|
||||
supportsMessageEdit?: boolean, // default true
|
||||
documentation?: { portalUrl, setupGuideUrl },
|
||||
}
|
||||
```
|
||||
|
||||
`schema` drives both server validation (`mergeWithDefaults`, `extractDefaults`) **and** the auto-generated UI form. Top-level keys `applicationId` / `credentials` / `settings` map to DB columns. Common settings fields live in `platforms/const.ts` (`displayToolCallsField`, `makeServerIdField(platform?)`, `makeUserIdField(platform?)`). The `serverId` / `userId` factories take a platform identifier so the field's hint can render platform-specific "how to find this ID" guidance (Discord Developer Mode, Telegram @userinfobot, etc.); pass no argument to fall back to generic copy.
|
||||
|
||||
Each platform implements `PlatformClient` (see `platforms/types.ts`):
|
||||
|
||||
- Lifecycle: `start(opts?)`, `stop()`
|
||||
- Inbound: `createAdapter()` → Chat SDK adapter map
|
||||
- Outbound: `getMessenger(platformThreadId)` → `{ createMessage, editMessage, removeReaction, triggerTyping, updateThreadName? }`
|
||||
- Formatting: `formatMarkdown?`, `formatReply?` (usage-stats footer when `showUsageStats`)
|
||||
- Helpers: `extractChatId`, `parseMessageId`, `sanitizeUserInput`, `shouldSubscribe`, `resolveReactionThreadId`
|
||||
- Optional patches: `applyChatPatches(chatBot)` (Discord uses this for `forwardedInteractions` + `threadRecovery`)
|
||||
- Optional menu: `registerBotCommands(commands)` (Telegram `setMyCommands`)
|
||||
|
||||
`ClientFactory.validateCredentials` is called from the TRPC `testConnection` mutation — implement it to hit the platform API and return useful per-field errors.
|
||||
|
||||
## Database
|
||||
|
||||
**Schema** (`packages/database/src/schemas/agentBotProvider.ts`):
|
||||
|
||||
```ts
|
||||
agent_bot_providers (
|
||||
id uuid pk,
|
||||
agent_id text fk → agents.id (cascade),
|
||||
user_id text fk → users.id (cascade),
|
||||
platform varchar(50), // 'discord' | 'slack' | …
|
||||
application_id varchar(255),
|
||||
credentials text, // KeyVaults-encrypted JSON
|
||||
settings jsonb default '{}',
|
||||
enabled boolean default true,
|
||||
…timestamps
|
||||
)
|
||||
unique (platform, application_id)
|
||||
```
|
||||
|
||||
**Model** (`packages/database/src/models/agentBotProvider.ts`):
|
||||
|
||||
- User-scoped: `create / update / delete / query / findById / findByAgentId / findEnabledByApplicationId`. Credentials are encrypted/decrypted via the injected `KeyVaultsGateKeeper`.
|
||||
- Static (system-wide): `findByPlatformAndAppId`, `findEnabledByPlatform` — used by webhook routing & gateway sync, since they don't have a user context yet.
|
||||
|
||||
**TRPC router** (`src/server/routers/lambda/agentBotProvider.ts`):
|
||||
|
||||
| Procedure | Notes | |
|
||||
| -------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------ |
|
||||
| `listPlatforms` | Returns `SerializedPlatformDefinition[]` (no `clientFactory`) | |
|
||||
| `create` / `update` / `delete` | Calls `BotMessageRouter.invalidateBot` + `GatewayService.stopClient` so changes take effect | |
|
||||
| `list` / `getByAgentId` / `getRuntimeStatus` | Decorate rows with Redis runtime status | |
|
||||
| `connectBot` | Returns \`{ status: 'started' | 'queued' }\` |
|
||||
| `testConnection` | Calls `clientFactory.validateCredentials` | |
|
||||
| `wechatGetQrCode` / `wechatPollQrStatus` | iLink onboarding flow | |
|
||||
|
||||
Client service: `src/services/agentBotProvider.ts`. Store actions: `src/store/agent/slices/bot/action.ts`. UI: `src/routes/(main)/agent/channel/{list,detail}` — settings form is auto-generated from each platform's `schema`.
|
||||
|
||||
## Reply Templates
|
||||
|
||||
`src/server/services/bot/replyTemplate.ts` exports `renderStart`, `renderStepProgress`, `renderFinalReply`, `renderError`, `renderStopped`, `splitMessage`. Step progress carries elapsed time, last LLM content, last tools, totals; final reply uses `client.formatMarkdown` then `client.formatReply` (which optionally appends `formatUsageStats`). `splitMessage(text, charLimit)` chunks at paragraph → line → hard cut.
|
||||
|
||||
`src/server/services/bot/ackPhrases/` provides randomized ack phrases.
|
||||
|
||||
## Key Files
|
||||
|
||||
```plaintext
|
||||
Webhook routes:
|
||||
src/app/(backend)/api/agent/webhooks/[platform]/[[...appId]]/route.ts — inbound catch-all
|
||||
src/app/(backend)/api/agent/webhooks/bot-callback/route.ts — qstash bot callback
|
||||
src/app/(backend)/api/agent/gateway/route.ts — cron gateway (10min window)
|
||||
src/app/(backend)/api/agent/gateway/start/route.ts — non-Vercel ensureRunning
|
||||
|
||||
Bot service:
|
||||
src/server/services/bot/index.ts — barrel
|
||||
src/server/services/bot/BotMessageRouter.ts — lazy bot loading + handler registration + commands
|
||||
src/server/services/bot/AgentBridgeService.ts — Chat SDK ↔ AiAgentService bridge, both exec modes
|
||||
src/server/services/bot/BotCallbackService.ts — qstash callback handler
|
||||
src/server/services/bot/formatPrompt.ts — speaker tag + referenced_message + sanitize
|
||||
src/server/services/bot/replyTemplate.ts — render*/splitMessage
|
||||
src/server/services/bot/ackPhrases/ — randomized acks
|
||||
src/server/services/bot/__tests__/ — unit tests for the above
|
||||
|
||||
Platform abstraction:
|
||||
src/server/services/bot/platforms/index.ts — registry singleton + exports
|
||||
src/server/services/bot/platforms/types.ts — PlatformClient/Definition/FieldSchema/ClientFactory
|
||||
src/server/services/bot/platforms/registry.ts — PlatformRegistry class
|
||||
src/server/services/bot/platforms/utils.ts — mergeWithDefaults, getEffectiveConnectionMode, formatUsageStats, runtimeKey
|
||||
src/server/services/bot/platforms/const.ts — shared FieldSchema fragments (displayToolCalls, serverId, userId)
|
||||
src/server/services/bot/platforms/stripMarkdown.ts — used by no-markdown platforms
|
||||
|
||||
Per-platform (each ships definition.ts, schema.ts, client.ts, const.ts, protocol-spec.md):
|
||||
src/server/services/bot/platforms/discord/ — websocket gateway + chat patches
|
||||
src/server/services/bot/platforms/slack/ — multi-mode (Socket Mode / webhook), markdownToMrkdwn
|
||||
src/server/services/bot/platforms/telegram/ — webhook, markdownToHTML, registerBotCommands
|
||||
src/server/services/bot/platforms/feishu/ — feishu + lark share client/schema (definitions/{feishu,lark,shared}.ts)
|
||||
src/server/services/bot/platforms/qq/ — websocket, no markdown, no edit
|
||||
src/server/services/bot/platforms/wechat/ — long-poll, no markdown, no edit
|
||||
|
||||
Gateway:
|
||||
src/server/services/gateway/index.ts — GatewayService (Vercel-aware startClient/stopClient)
|
||||
src/server/services/gateway/GatewayManager.ts — long-running client registry (non-Vercel)
|
||||
src/server/services/gateway/botConnectQueue.ts — Redis hash queue with TTL
|
||||
src/server/services/gateway/runtimeStatus.ts — Redis bot:runtime-status keys
|
||||
|
||||
Database:
|
||||
packages/database/src/schemas/agentBotProvider.ts — agent_bot_providers table
|
||||
packages/database/src/models/agentBotProvider.ts — encrypted CRUD + system-wide finders
|
||||
|
||||
TRPC + client:
|
||||
src/server/routers/lambda/agentBotProvider.ts — TRPC router
|
||||
src/services/agentBotProvider.ts — client wrapper
|
||||
src/store/agent/slices/bot/action.ts — Zustand actions
|
||||
|
||||
UI:
|
||||
src/routes/(main)/agent/channel/list.tsx — channel list
|
||||
src/routes/(main)/agent/channel/detail/ — auto-generated form (Header/Body/Footer)
|
||||
src/routes/(main)/agent/channel/const.ts — platform icons
|
||||
|
||||
Types & runtime status:
|
||||
src/types/botRuntimeStatus.ts — BOT_RUNTIME_STATUSES enum + snapshot type
|
||||
```
|
||||
|
||||
## Adding a New Platform
|
||||
|
||||
1. Create `src/server/services/bot/platforms/<id>/`:
|
||||
- `definition.ts` — `PlatformDefinition` registered in `platforms/index.ts`
|
||||
- `schema.ts` — `FieldSchema[]` (`applicationId` + `credentials` + `settings`); reuse fragments from `../const.ts`
|
||||
- `client.ts` — `class XClientFactory extends ClientFactory` returning a `PlatformClient` (lifecycle + adapter + messenger + helpers)
|
||||
- `const.ts` — `DEFAULT_X_CONNECTION_MODE`, history limits, etc.
|
||||
- `protocol-spec.md` — protocol notes (every existing platform has one)
|
||||
2. Pick the right `connectionMode` — webhook is much simpler if the platform supports it.
|
||||
3. If the platform can't render markdown, set `supportsMarkdown: false` and implement `formatMarkdown` via `stripMarkdown`.
|
||||
4. If it can't edit messages, set `supportsMessageEdit: false` — `BotCallbackService` will skip step edits and only send the final reply.
|
||||
5. Implement `validateCredentials` so the UI's "Test connection" button gives useful errors.
|
||||
6. Add the platform icon in `src/routes/(main)/agent/channel/const.ts` and register the platform in `src/server/services/bot/platforms/index.ts`.
|
||||
7. Add i18n keys under `channel.*` in `src/locales/default/setting.ts` (or wherever the channel namespace lives) — the schema's `label`/`description`/`placeholder`/`enumLabels` are i18n keys.
|
||||
@@ -0,0 +1,130 @@
|
||||
---
|
||||
name: builtin-tool
|
||||
description: Build a new builtin tool package under `packages/builtin-tool-<name>/`. Use when adding a new agent-callable toolset, designing its API surface (manifest / ApiName / Params / State), implementing the Executor + ExecutionRuntime, building the Inspector / Render / Placeholder / Streaming / Intervention / Portal UI, or wiring a tool into the central registries (`packages/builtin-tools/src/{index,identifiers,inspectors,renders,placeholders,streamings,interventions,portals}.ts` and `src/store/tool/slices/builtin/executors/index.ts`). Triggers on "new builtin tool", "add a tool", "tool inspector", "tool render", "tool placeholder", "tool streaming", "tool intervention", "BuiltinToolManifest", "BaseExecutor", "ExecutionRuntime".
|
||||
---
|
||||
|
||||
# Builtin Tool Authoring Guide
|
||||
|
||||
A builtin tool is a package the agent runtime can call. It ships **five faces**:
|
||||
|
||||
| Face | Lives in | Audience |
|
||||
| -------------------- | -------------------------------------------------------------------------------------- | ------------------------------------- |
|
||||
| **Manifest + types** | `src/{manifest,types,systemRole}.ts` | The LLM (tool spec + system prompt) |
|
||||
| **ExecutionRuntime** | `src/ExecutionRuntime/` | Server / desktop / any runtime caller |
|
||||
| **Executor** | `src/client/executor/` | Frontend (wraps stores/services) |
|
||||
| **Client UI** | `src/client/{Inspector,Render,…}/` | Chat UI |
|
||||
| **Registry wiring** | `packages/builtin-tools/src/*.ts` + `src/store/tool/slices/builtin/executors/index.ts` | Framework |
|
||||
|
||||
---
|
||||
|
||||
## Read These First
|
||||
|
||||
| Question | Doc |
|
||||
| ------------------------------------------------------------------------------------ | ---------------------------------- |
|
||||
| Where do files live? What does each face do? Wiring? | [architecture.md](architecture.md) |
|
||||
| How do I name the tool, design APIs, write the manifest, executor, ExecutionRuntime? | [tool-design.md](tool-design.md) |
|
||||
| How do I build Inspector / Render / Placeholder / Streaming / Intervention / Portal? | [ui.md](ui.md) |
|
||||
|
||||
---
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
- Creating a new `packages/builtin-tool-<name>/` package
|
||||
- Adding a new API method to an existing builtin tool
|
||||
- Building or restyling any of the 6 client surfaces for a tool
|
||||
- Wiring a tool into the central registries
|
||||
- Debugging "tool not found / API not found / render not showing / placeholder stuck" errors
|
||||
|
||||
---
|
||||
|
||||
## Top-Level Design Principles
|
||||
|
||||
1. **`lobe-<domain>` identifier is permanent.** It's stored in message history. Renames need `@deprecated` aliases (see `packages/builtin-tools/src/inspectors.ts:88-89`). Get it right the first time.
|
||||
2. **ApiName is an `as const` object**, not a TS enum. It doubles as the runtime list `BaseExecutor` iterates over.
|
||||
3. **Three result fields, three audiences:**
|
||||
- `content: string` → the LLM reads it
|
||||
- `state: Record<…>` → the UI's `pluginState`; **result-domain only**, never echo all params back
|
||||
- `error: { type, message, body? }` → both LLM and UI; `type` is a stable code
|
||||
4. **Split execution from frontend wiring.**
|
||||
- `src/ExecutionRuntime/` — pure runtime, no React, no Zustand, accepts services via constructor. **The default place for new logic.**
|
||||
- `src/client/executor/` — `BaseExecutor` subclass that calls `ExecutionRuntime` (or stores/services directly when frontend-only).
|
||||
5. **UI defaults to "do nothing".** Inspector is required (the header strip). Render/Placeholder/Streaming/Intervention/Portal are added **only when there's something specific to show** — empty registries are fine.
|
||||
6. **Style with `createStaticStyles + cssVar.*`** (zero-runtime). Fall back to `createStyles + token` only when you genuinely need runtime values. Use `@lobehub/ui` components, not raw antd.
|
||||
7. **i18n keys live in `src/locales/default/plugin.ts`.** Inspector titles must come from `t('builtins.<identifier>.apiName.<api>')` so something renders while args stream.
|
||||
|
||||
---
|
||||
|
||||
## Package Layout (preferred, post-2026 convention)
|
||||
|
||||
```
|
||||
packages/builtin-tool-<name>/
|
||||
├── package.json
|
||||
└── src/
|
||||
├── index.ts # exports manifest + types + systemRole + Identifier (no React, no stores)
|
||||
├── manifest.ts # BuiltinToolManifest with JSON Schema for every API
|
||||
├── types.ts # ApiName const + Params/State interfaces per API
|
||||
├── systemRole.ts # System prompt teaching the model when/how to use the APIs
|
||||
├── ExecutionRuntime/ # ✅ Default home for runtime logic (server- or anywhere-callable)
|
||||
│ └── index.ts
|
||||
└── client/
|
||||
├── index.ts # Re-exports for the registries
|
||||
├── executor/ # ✅ Frontend executor — extends BaseExecutor, often delegates to ExecutionRuntime
|
||||
│ └── index.ts
|
||||
├── Inspector/ # required — header chip per API
|
||||
├── Render/ # optional — rich result card
|
||||
├── Placeholder/ # optional — skeleton during streaming/execution
|
||||
├── Streaming/ # optional — live output renderer (e.g. RunCommand, WriteFile)
|
||||
├── Intervention/ # optional — approval / edit-before-run UI
|
||||
├── Portal/ # optional — full-screen detail view
|
||||
└── components/ # shared subcomponents used by the surfaces above
|
||||
```
|
||||
|
||||
**Older packages** (`builtin-tool-task`, `builtin-tool-calculator`, etc.) still have `src/executor/` as a sibling of `src/client/`. That's grandfathered; **don't relocate without a deliberate refactor**. New packages and new APIs added to existing packages should follow the layout above.
|
||||
|
||||
`package.json` exports map:
|
||||
|
||||
```json
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./client": "./src/client/index.ts",
|
||||
"./executor": "./src/client/executor/index.ts",
|
||||
"./executionRuntime": "./src/ExecutionRuntime/index.ts"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authoring Checklist
|
||||
|
||||
Before opening the PR:
|
||||
|
||||
- [ ] Identifier follows `lobe-<domain>` and is **stable** (lives in message history).
|
||||
- [ ] Every `<Name>ApiName` value has: a manifest `api[]` entry, an executor method, an Inspector, an i18n `apiName.*` key.
|
||||
- [ ] `Params` interfaces match the JSON Schema; `State` interfaces match what the executor returns and what the UI surfaces read.
|
||||
- [ ] System prompt disambiguates confusable APIs and points to batch variants.
|
||||
- [ ] Runtime logic lives in `ExecutionRuntime/`; the `client/executor/` only wires stores/services and delegates.
|
||||
- [ ] Executor returns `{ success, content, state, error? }` via a single `toResult()` funnel — `content` always non-empty (default to `error.message`).
|
||||
- [ ] Inspector handles `isArgumentsStreaming`, `isLoading`, `partialArgs`, missing `pluginState`.
|
||||
- [ ] Render returns `null` until it has data; only created for APIs with rich results.
|
||||
- [ ] Placeholder added if the API has a perceivable execution lag (search, list, crawl).
|
||||
- [ ] Streaming added for APIs that emit incremental output (run command, write file, code execution).
|
||||
- [ ] Intervention added if `humanIntervention` is set in the manifest.
|
||||
- [ ] All registry files updated (see [architecture.md → Registry wiring](architecture.md#registry-wiring)).
|
||||
- [ ] i18n keys in `src/locales/default/plugin.ts` plus dev seeds in `en-US`/`zh-CN`.
|
||||
- [ ] `bunx vitest run --silent='passed-only' 'packages/builtin-tool-<name>'` passes.
|
||||
- [ ] `bun run type-check` passes.
|
||||
|
||||
---
|
||||
|
||||
## Reference Tools
|
||||
|
||||
Pick the closest neighbor and copy:
|
||||
|
||||
| If your tool is… | Read first |
|
||||
| ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| Pure-compute, no UI state | `packages/builtin-tool-calculator/` — `ExecutionRuntime` reuses executor (mathjs/nerdamer work everywhere) |
|
||||
| CRUD over a domain entity | `packages/builtin-tool-task/` — full Inspector + Render set, batch variants |
|
||||
| Heavy UI (Inspector/Render/Placeholder/Portal) | `packages/builtin-tool-web-browsing/` — search-style result UI, Portal for detail view |
|
||||
| Desktop / filesystem with all surfaces (incl. Streaming + Intervention) | `packages/builtin-tool-local-system/` — `ExecutionRuntime` injects an `ILocalSystemService`, executor calls it |
|
||||
| Server-side pure (no client executor) | `packages/builtin-tool-web-browsing/` — only `ExecutionRuntime` is exported; the chat client doesn't run it |
|
||||
| Needs human approval before running | `packages/builtin-tool-local-system/src/client/Intervention/` — per-API approval components |
|
||||
@@ -0,0 +1,315 @@
|
||||
# Builtin Tool Architecture
|
||||
|
||||
## The Five Faces
|
||||
|
||||
A builtin tool ships five distinct faces, each compiled into a different bundle:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ./ │
|
||||
│ Manifest + Types + systemRole │
|
||||
│ ─ Pure data, no React, no Node-only deps. │
|
||||
│ ─ Imported by: server (LLM tool spec), client (registries), │
|
||||
│ anyone who needs to know "what tools exist". │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ./executionRuntime │
|
||||
│ src/ExecutionRuntime/index.ts │
|
||||
│ ─ Pure runtime logic. Accepts services via constructor — │
|
||||
│ never imports concrete services or stores directly. │
|
||||
│ ─ Imported by: server (BuiltinServerRuntimeOutput), tests, │
|
||||
│ and the client executor as a delegate. │
|
||||
│ ─ Returns: BuiltinServerRuntimeOutput { content, state, … } │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ./executor │
|
||||
│ src/client/executor/index.ts │
|
||||
│ ─ BaseExecutor subclass. Wires Zustand stores and frontend │
|
||||
│ services into ExecutionRuntime, then funnels through │
|
||||
│ toResult() into BuiltinToolResult { content, state, error, │
|
||||
│ success }. │
|
||||
│ ─ Imported by: src/store/tool/slices/builtin/executors/ │
|
||||
│ index.ts (registered as a singleton). │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ./client │
|
||||
│ src/client/{Inspector,Render,Placeholder,Streaming, │
|
||||
│ Intervention,Portal,components}/ │
|
||||
│ ─ React 'use client' surfaces. Read args + pluginState. │
|
||||
│ ─ Imported by: packages/builtin-tools/src/{inspectors, │
|
||||
│ renders,placeholders,streamings,interventions,portals}.ts. │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Registry wiring │
|
||||
│ packages/builtin-tools/src/*.ts │
|
||||
│ src/store/tool/slices/builtin/executors/index.ts │
|
||||
│ ─ Aggregator maps: identifier → { apiName → component }. │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The split exists so:
|
||||
|
||||
- Server bundles import only `./` and `./executionRuntime` and never touch React.
|
||||
- Frontend bundles import `./client` and never touch Node-only services.
|
||||
- The runtime is testable without React or Electron present.
|
||||
|
||||
---
|
||||
|
||||
## Why ExecutionRuntime is the Default Home for Logic
|
||||
|
||||
**Old pattern (grandfathered):** business logic in `src/executor/` directly. Examples: `builtin-tool-task`, older tools. Works, but the executor mixes runtime logic with frontend service plumbing — hard to reuse on the server.
|
||||
|
||||
**New pattern (preferred):** business logic in `src/ExecutionRuntime/`, frontend wiring in `src/client/executor/`. Examples: `builtin-tool-local-system`, `builtin-tool-web-browsing`, `builtin-tool-calculator`.
|
||||
|
||||
```
|
||||
ExecutionRuntime
|
||||
├─ accepts services via constructor (or `static create(opts)`)
|
||||
├─ returns BuiltinServerRuntimeOutput (content + state + success)
|
||||
└─ no React, no Zustand, no `@/services/...` direct imports
|
||||
|
||||
client/executor
|
||||
├─ extends BaseExecutor<typeof <Name>ApiName>
|
||||
├─ holds a `runtime = new <Name>ExecutionRuntime(realService)` instance
|
||||
├─ each ApiName method:
|
||||
│ 1. resolve scope / pull defaults from BuiltinToolContext
|
||||
│ 2. call runtime.<method>(args)
|
||||
│ 3. funnel through toResult() → BuiltinToolResult
|
||||
└─ exported singleton: export const <name>Executor = new <Name>Executor()
|
||||
```
|
||||
|
||||
### Service injection
|
||||
|
||||
`ExecutionRuntime` should declare a TypeScript interface for the services it needs and accept the implementation via constructor. Server callers wire in real implementations; tests wire in mocks. Example from `local-system`:
|
||||
|
||||
```ts
|
||||
export interface ILocalSystemService {
|
||||
readLocalFile: (params: any) => Promise<any>;
|
||||
writeFile: (params: any) => Promise<any>;
|
||||
/* … */
|
||||
}
|
||||
|
||||
export class LocalSystemExecutionRuntime extends ComputerRuntime {
|
||||
constructor(private service: ILocalSystemService) {
|
||||
super();
|
||||
}
|
||||
/* methods delegate to this.service.* */
|
||||
}
|
||||
```
|
||||
|
||||
The `client/executor` instantiates it once with the real service:
|
||||
|
||||
```ts
|
||||
import { localFileService } from '@/services/electron/localFileService';
|
||||
import { LocalSystemExecutionRuntime } from '../../ExecutionRuntime';
|
||||
|
||||
class LocalSystemExecutor extends BaseExecutor<typeof LocalSystemApiEnum> {
|
||||
private runtime = new LocalSystemExecutionRuntime(localFileService);
|
||||
/* … */
|
||||
}
|
||||
```
|
||||
|
||||
### When ExecutionRuntime is the only thing you ship
|
||||
|
||||
Some tools are server-only — there's no frontend executor. `builtin-tool-web-browsing` is the canonical example: only `./` and `./executionRuntime` are exported, no `./executor`, and the runtime is constructed by the server-side `ToolExecutionService`. Skip `client/executor/` entirely for those.
|
||||
|
||||
### When the executor reuses the runtime as-is
|
||||
|
||||
Pure-compute tools (`builtin-tool-calculator`) often have an executor whose ApiName methods call `executor.calculate(args)` and an `ExecutionRuntime` whose methods call `calculatorExecutor.calculate(args)` — same logic, two thin wrappers. That's fine; the duplication buys you the bundle split.
|
||||
|
||||
---
|
||||
|
||||
## The Result Contract
|
||||
|
||||
### `BuiltinServerRuntimeOutput` (what ExecutionRuntime returns)
|
||||
|
||||
```ts
|
||||
{
|
||||
content: string; // the LLM-facing text — never undefined; default to error message
|
||||
state?: any; // result-domain object the UI reads as pluginState
|
||||
success: boolean; // mandatory
|
||||
error?: any; // raw error; the executor will repackage
|
||||
}
|
||||
```
|
||||
|
||||
### `BuiltinToolResult` (what the executor returns to the runtime)
|
||||
|
||||
```ts
|
||||
{
|
||||
success: boolean;
|
||||
content?: string;
|
||||
state?: any;
|
||||
error?: { type: string; message: string; body?: any };
|
||||
metadata?: Record<string, any>; // rare; e.g. { agentCouncil: true }
|
||||
stop?: boolean; // rare; halt the orchestration step
|
||||
}
|
||||
```
|
||||
|
||||
### The `toResult` funnel (mandatory)
|
||||
|
||||
Every executor method returns through a single `toResult()` to enforce two invariants:
|
||||
|
||||
1. **`content` is never undefined.** A missing content collapses downstream into `''`, leaving the Debug pane blank while `pluginState` was already saved. See the `globLocalFiles` regression in `local-system/src/client/executor/index.ts:60-84`.
|
||||
2. **`state` survives failures.** Renderers can keep showing partial output even when `success: false`.
|
||||
|
||||
```ts
|
||||
private toResult(output: BuiltinServerRuntimeOutput): BuiltinToolResult {
|
||||
const errorMessage = typeof output.error?.message === 'string' ? output.error.message : undefined;
|
||||
const safeContent = output.content || errorMessage || 'Tool execution failed';
|
||||
|
||||
if (!output.success) {
|
||||
return {
|
||||
success: false,
|
||||
content: safeContent,
|
||||
state: output.state,
|
||||
error: output.error
|
||||
? { type: 'PluginServerError', message: errorMessage ?? safeContent, body: output.error }
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
return { success: true, content: safeContent, state: output.state };
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `BaseExecutor` — How Method Dispatch Works
|
||||
|
||||
`BaseExecutor.invoke(apiName, params, ctx)` does:
|
||||
|
||||
```ts
|
||||
if (!this.hasApi(apiName)) return { error: { type: 'ApiNotFound', … }, success: false };
|
||||
return (this as any)[apiName](params, ctx); // method name MUST equal apiName value
|
||||
```
|
||||
|
||||
So:
|
||||
|
||||
- **Method names must equal `<Name>ApiName` values, exactly.** A typo silently routes to "ApiNotFound".
|
||||
- **Methods must be class fields, not class methods**, because `this` is lost when registry calls `executor.invoke(apiName, params, ctx)`. Always declare as `methodName = async (…) => { … }`.
|
||||
- **Always destructure `apiEnum` and `identifier` as `readonly` instance fields**, not getters — `BaseExecutor.hasApi/getApiNames` reads them synchronously.
|
||||
|
||||
---
|
||||
|
||||
## `BuiltinToolContext` — What the Executor Receives
|
||||
|
||||
The runtime hands every executor method an optional `BuiltinToolContext` as the second argument:
|
||||
|
||||
| Field | Use |
|
||||
| ----------------------------- | -------------------------------------------------------------- |
|
||||
| `agentId` | Default agent for "current agent" semantics (e.g. `listTasks`) |
|
||||
| `groupId` | Group chat scope |
|
||||
| `topicId` | Current topic — needed when creating messages/operations |
|
||||
| `taskId` | Current task identifier — fallback for "implicit" param |
|
||||
| `documentId` | Current page/document scope |
|
||||
| `messageId` | The tool message being created (for state attachments) |
|
||||
| `sourceMessageId` | The user message that triggered this tool turn |
|
||||
| `operationId` | Operation lineage (use for cancellation, tracing) |
|
||||
| `scope` | `'task' \| 'agent' \| …` — toggles default behaviors |
|
||||
| `signal: AbortSignal` | Honor for long-running ops |
|
||||
| `stepContext` | Cross-message runtime state (GTD todos, etc.) |
|
||||
| `registerAfterCompletion(cb)` | Defer side-effects past message-update race |
|
||||
| `groupOrchestration` | Group orchestration callbacks |
|
||||
|
||||
**Use rule:** read with `?.`, fall back to explicit params, **never silently override** an explicit param with a context value.
|
||||
|
||||
---
|
||||
|
||||
## i18n Integration
|
||||
|
||||
Source of truth: `src/locales/default/plugin.ts`. Keys follow `builtins.<identifier>.<topic>.<…>`:
|
||||
|
||||
| Key | Use |
|
||||
| ------------------------------------- | ------------------------------------------------------------ |
|
||||
| `builtins.<identifier>.title` | Display title (overrides `manifest.meta.title` when present) |
|
||||
| `builtins.<identifier>.apiName.<api>` | Inspector header label (one per ApiName) |
|
||||
| `builtins.<identifier>.inspector.<…>` | Extra Inspector strings ("no results", chips, counters) |
|
||||
| `builtins.<identifier>.<feature>.<…>` | Render / Intervention strings, free-form per tool |
|
||||
|
||||
For dev preview, also seed `locales/zh-CN/plugin.json` and `locales/en-US/plugin.json`. Run `pnpm i18n` before opening a PR — it's slow, so do it once at the end. (See the **i18n** skill for the full workflow.)
|
||||
|
||||
---
|
||||
|
||||
## Registry Wiring
|
||||
|
||||
Five core files plus optional ones. Miss any and you'll see "tool not found", a missing chip, a blank result card, a stuck spinner, or an approval dialog that never appears.
|
||||
|
||||
| File | Add what |
|
||||
| -------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| **Required** | |
|
||||
| `packages/builtin-tools/src/index.ts` | Import `<Name>Manifest`; push entry to `builtinTools`. Set `hidden`/`discoverable` flags. |
|
||||
| `packages/builtin-tools/src/identifiers.ts` | Add `<Name>Manifest.identifier` to `builtinToolIdentifiers`. |
|
||||
| `packages/builtin-tools/src/inspectors.ts` | Import `<Name>Inspectors, <Name>Manifest`; add to `BuiltinToolInspectors`. |
|
||||
| `src/store/tool/slices/builtin/executors/index.ts` | Import `<name>Executor`; add to `registerExecutors([…])`. |
|
||||
| **Conditional — add only if the surface exists** | |
|
||||
| `packages/builtin-tools/src/renders.ts` | Add to `BuiltinToolsRenders` if any API has a Render. |
|
||||
| `packages/builtin-tools/src/placeholders.ts` | Add to `BuiltinToolPlaceholders` if any API has a Placeholder. |
|
||||
| `packages/builtin-tools/src/streamings.ts` | Add to `BuiltinToolStreamings` if any API has a Streaming renderer. |
|
||||
| `packages/builtin-tools/src/interventions.ts` | Add to `BuiltinToolInterventions` if any API has an Intervention component. |
|
||||
| `packages/builtin-tools/src/portals.ts` | Add to `BuiltinToolsPortals` if the tool has a Portal. |
|
||||
| `packages/builtin-tools/src/displayControls.ts` | Add if Render must show/hide based on result content (rare; see ClaudeCode/Codex). |
|
||||
|
||||
### Optional flags in `packages/builtin-tools/src/index.ts`
|
||||
|
||||
```ts
|
||||
{
|
||||
identifier: TaskManifest.identifier,
|
||||
manifest: TaskManifest,
|
||||
type: 'builtin',
|
||||
hidden: true, // hide from chat-input Tools popover
|
||||
discoverable: false, // exclude from agent builder / skill discovery
|
||||
}
|
||||
```
|
||||
|
||||
Lists in the same file you may need to touch:
|
||||
|
||||
- `defaultToolIds` — added to the agent's tool list by default
|
||||
- `alwaysOnToolIds` — forced on regardless of user selection (use sparingly)
|
||||
- `runtimeManagedToolIds` — enable state controlled by runtime, not user UI; **must mirror the rules map** in `src/server/modules/Mecha/AgentToolsEngine/index.ts` and `src/helpers/toolEngineering/index.ts`
|
||||
|
||||
---
|
||||
|
||||
## File-Map at a Glance
|
||||
|
||||
```
|
||||
packages/builtin-tool-<name>/
|
||||
├── package.json # exports: ., ./client, ./executor, ./executionRuntime
|
||||
└── src/
|
||||
├── index.ts # export Manifest, Identifier, types, systemPrompt
|
||||
├── manifest.ts # BuiltinToolManifest + Identifier const
|
||||
├── types.ts # ApiName + Params/State per API
|
||||
├── systemRole.ts # System prompt (multiple variants OK: systemRole.desktop.ts)
|
||||
├── ExecutionRuntime/
|
||||
│ └── index.ts # <Name>ExecutionRuntime — pure runtime, service injection
|
||||
└── client/
|
||||
├── index.ts # exports for the registries
|
||||
├── executor/
|
||||
│ └── index.ts # <Name>Executor extends BaseExecutor; export <name>Executor
|
||||
├── Inspector/
|
||||
│ ├── index.ts # <Name>Inspectors record
|
||||
│ └── <ApiName>/index.tsx # one folder per API (or .tsx file when trivial)
|
||||
├── Render/
|
||||
│ ├── index.ts # <Name>Renders record
|
||||
│ └── <ApiName>/ # rich renders → folder with subcomponents
|
||||
├── Placeholder/
|
||||
│ ├── index.ts
|
||||
│ └── <ApiName>.tsx # usually a single skeleton file
|
||||
├── Streaming/
|
||||
│ ├── index.ts
|
||||
│ └── <ApiName>/ # live-output renderer
|
||||
├── Intervention/
|
||||
│ ├── index.ts
|
||||
│ └── <ApiName>/ # approval / edit-before-run UI
|
||||
├── Portal/
|
||||
│ ├── index.tsx # routing component (switch on apiName)
|
||||
│ └── <ApiName>/ # full-screen detail view
|
||||
└── components/ # FileItem, EngineAvatar, etc. — shared subcomponents
|
||||
```
|
||||
|
||||
Skip every `client/<surface>/` directory you don't need — empty registries are fine.
|
||||
@@ -0,0 +1,478 @@
|
||||
# Tool Design (Naming, Manifest, Executor, Runtime)
|
||||
|
||||
This doc covers everything that **isn't UI**: the tool's identifier, API surface, manifest, types, system prompt, ExecutionRuntime, and the executor that wires it into the frontend.
|
||||
|
||||
For UI surfaces (Inspector / Render / Placeholder / Streaming / Intervention / Portal), see [ui.md](ui.md).
|
||||
For where files live and how registries work, see [architecture.md](architecture.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Naming
|
||||
|
||||
| Thing | Convention | Example |
|
||||
| ----------------------- | -------------------------------------------------------------- | ------------------------------------------------------------ |
|
||||
| Package directory | `packages/builtin-tool-<kebab>/` | `builtin-tool-task` |
|
||||
| npm name | `@lobechat/builtin-tool-<kebab>` | `@lobechat/builtin-tool-task` |
|
||||
| Tool `identifier` | `lobe-<kebab-domain>` — **persisted in message history** | `lobe-task`, `lobe-calculator`, `lobe-knowledge-base` |
|
||||
| Identifier const | `<Name>Identifier` exported from `manifest.ts` (or `types.ts`) | `export const TaskIdentifier = 'lobe-task'` |
|
||||
| API name const | `<Name>ApiName` — `as const` object, **camelCase verbs** | `createTask`, `listTasks`, `runTask` |
|
||||
| Executor class | `<Name>Executor extends BaseExecutor<typeof <Name>ApiName>` | `TaskExecutor` |
|
||||
| Executor singleton | `<name>Executor` (camelCase) | `export const taskExecutor = new TaskExecutor()` |
|
||||
| ExecutionRuntime class | `<Name>ExecutionRuntime` | `LocalSystemExecutionRuntime`, `WebBrowsingExecutionRuntime` |
|
||||
| Inspector / Render etc. | `<ApiName>Inspector` / `<ApiName>Render` | `CreateTaskInspector`, `SearchInspector` |
|
||||
|
||||
### Identifier rules
|
||||
|
||||
- **`lobe-` prefix is mandatory** — many switches in the codebase key off it.
|
||||
- Pick a **domain noun**, not a verb (`lobe-task`, not `lobe-task-manager`).
|
||||
- The identifier is **persisted in message history** — renaming after release means the `@deprecated` alias trick (register the legacy identifier as a second key in `inspectors.ts` / `renders.ts` pointing at the new module). Get it right the first time.
|
||||
|
||||
### ApiName rules
|
||||
|
||||
- Verb + noun, camelCase: `createTask`, `viewTask`, `runTasks`.
|
||||
- **Plural variant for batch** (`createTasks`, `runTasks`) — describe in the manifest description that it's preferred over multiple single calls. The system prompt should also push the batch form.
|
||||
- Reserve **clear separation between mutating verbs** (`updateTaskStatus`, `editTask`) and **execution verbs** (`runTask`). The system prompt must warn the model when these are confusable — see `task` for the canonical "do NOT use updateTaskStatus(running) to start a task" warning.
|
||||
- Read-only verbs: `list*`, `view*`, `get*`, `search*`. Mutating: `create*`, `edit*`, `update*`, `delete*`. Triggers/effects: `run*`, `execute*`, `submit*`.
|
||||
|
||||
---
|
||||
|
||||
## 2. `types.ts` — ApiName + Params/State
|
||||
|
||||
Define `<Name>ApiName` as `as const` so it doubles as a runtime enum (used by `BaseExecutor`) and a literal type. Then declare `Params` and `State` per API.
|
||||
|
||||
```ts
|
||||
export const TaskIdentifier = 'lobe-task';
|
||||
|
||||
export const TaskApiName = {
|
||||
createTask: 'createTask',
|
||||
createTasks: 'createTasks',
|
||||
listTasks: 'listTasks',
|
||||
/* …one entry per API, group logically (CRUD then run-style) */
|
||||
} as const;
|
||||
|
||||
export type TaskApiNameType = (typeof TaskApiName)[keyof typeof TaskApiName];
|
||||
|
||||
// One block per API
|
||||
export interface CreateTaskParams {
|
||||
name: string;
|
||||
instruction: string; /* … */
|
||||
}
|
||||
export interface CreateTaskState {
|
||||
identifier?: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface CreateTasksParams {
|
||||
tasks: CreateTaskParams[];
|
||||
}
|
||||
export interface CreateTasksItemResult {
|
||||
error?: string;
|
||||
identifier?: string;
|
||||
name: string;
|
||||
success: boolean;
|
||||
}
|
||||
export interface CreateTasksState {
|
||||
failed: number;
|
||||
results: CreateTasksItemResult[];
|
||||
succeeded: number;
|
||||
}
|
||||
```
|
||||
|
||||
**The result-domain rule for `State`** (memory: "pluginState is result-domain, not call-domain"):
|
||||
|
||||
- Include only fields the UI **renders after the call returns** — ids the LLM didn't have when calling, counts, summary numbers, server-assigned status.
|
||||
- **Don't echo all params.** The Inspector/Render gets `args` for free.
|
||||
- Keep batch results as `{ succeeded, failed, results }` so the Render can show a one-line summary plus a detail list.
|
||||
|
||||
---
|
||||
|
||||
## 3. `manifest.ts` — JSON Schema for the LLM
|
||||
|
||||
```ts
|
||||
import type { BuiltinToolManifest } from '@lobechat/types';
|
||||
|
||||
import { systemPrompt } from './systemRole';
|
||||
import { TaskApiName, TaskIdentifier } from './types';
|
||||
|
||||
export const TaskManifest: BuiltinToolManifest = {
|
||||
identifier: TaskIdentifier,
|
||||
type: 'builtin',
|
||||
systemRole: systemPrompt,
|
||||
meta: {
|
||||
avatar: '📋',
|
||||
title: 'Task Tools',
|
||||
description: 'Create, list, edit, delete tasks with dependencies',
|
||||
readme: 'Optional long description shown in tool detail pages',
|
||||
},
|
||||
api: [
|
||||
{
|
||||
name: TaskApiName.createTask,
|
||||
description:
|
||||
'Create a new task. Optionally attach as a subtask via parentIdentifier. ' +
|
||||
'Prefer createTasks when planning a batch.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
required: ['name', 'instruction'],
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Short, descriptive name.' },
|
||||
instruction: {
|
||||
type: 'string',
|
||||
description: 'Detailed instruction for what the task should accomplish.',
|
||||
},
|
||||
parentIdentifier: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Identifier of the parent task (e.g. "TASK-1"). If provided, the new task becomes a subtask.',
|
||||
},
|
||||
priority: {
|
||||
type: 'number',
|
||||
description: 'Priority level: 0=none, 1=urgent, 2=high, 3=normal, 4=low. Default is 0.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
/* …one entry per ApiName */
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
### Manifest writing checklist
|
||||
|
||||
- **Every API in `<Name>ApiName` has exactly one entry in `api[]`.** Easy to drift after a refactor.
|
||||
- **`description` on each API is the model's only docs.** Make it long enough for the LLM to pick the right tool. Mention edge cases ("If you provide any filter, omitted filters are not applied implicitly"), defaults, and the relationship to sibling APIs ("To START a task, use runTask — updateTaskStatus only flips a flag").
|
||||
- **`parameters` is JSON Schema** (`LobeChatPluginApi`). Use `enum`, `required`, `items`, `oneOf`, `additionalProperties: false` etc. — these survive into the LLM's tool spec.
|
||||
- **Use `additionalProperties: false`** on parameter objects so the model can't sneak unknown fields past validation.
|
||||
- **Number parameters with semantic values** (`priority: 0=none, 1=urgent, …`) should describe the mapping in the description. Don't rely on `enum` alone for numbers — the model often fills the wrong one.
|
||||
- **`enum` arrays for known string sets** (statuses, categories, engines). Spread from a constants module (`enum: [...TASK_STATUSES]`) so the manifest stays in sync.
|
||||
|
||||
### Optional manifest fields
|
||||
|
||||
```ts
|
||||
{
|
||||
/* Where this tool can run.
|
||||
'client' → Agent Gateway dispatches to the desktop client (filesystem, Electron only)
|
||||
'server' → ToolExecutionService runs it on the server
|
||||
omitted → server only */
|
||||
executors: ['client', 'server'],
|
||||
|
||||
/* Default human intervention policy for all APIs that don't specify one.
|
||||
Pair with an Intervention component (see ui.md). */
|
||||
humanIntervention: 'never' | 'always' | { /* extended config */ },
|
||||
}
|
||||
```
|
||||
|
||||
Per-API `humanIntervention` and `renderDisplayControl` go inside each `api[]` entry.
|
||||
|
||||
---
|
||||
|
||||
## 4. `systemRole.ts` — Operator Instructions for the Model
|
||||
|
||||
This is appended to the agent system prompt whenever the tool is enabled. Treat it as a **how-to-use guide for the LLM**, not marketing copy.
|
||||
|
||||
```ts
|
||||
export const systemPrompt = `You have access to Task management tools. Use them to:
|
||||
|
||||
- **createTask**: Create a new task. Use parentIdentifier to make it a subtask.
|
||||
- **createTasks**: Prefer this over multiple createTask calls when planning a batch
|
||||
(e.g. all subtasks under one parent, or all chapters of an outline).
|
||||
- **runTask**: Actually START a task — kicks off the agent in a new (or continued)
|
||||
topic. Do NOT use updateTaskStatus(running) to start a task; that only flips a
|
||||
flag without executing. The task must have an assigneeAgentId.
|
||||
- **updateTaskStatus**: Change a task's status (completed/cancelled/paused/failed).
|
||||
If you mark a task as failed, include an error message explaining why.
|
||||
- ...
|
||||
|
||||
When planning work:
|
||||
1. Create tasks for each major piece (use parentIdentifier to organize as subtasks).
|
||||
2. Use editTask with addDependencies to control execution order.
|
||||
3. Use updateTaskStatus to mark the current task completed when done.`;
|
||||
```
|
||||
|
||||
### Patterns that work well
|
||||
|
||||
- **Bulleted list, bold the API name, one line per API.** The model picks tools by skimming.
|
||||
- **Disambiguate confusable APIs explicitly** (`runTask` vs `updateTaskStatus`).
|
||||
- **Push toward batched APIs** ("Prefer this when…").
|
||||
- **End with a numbered workflow** if the tool has a typical sequence.
|
||||
- **For tools with multiple environments** (e.g. desktop vs cloud), keep variants in `systemRole.ts` and `systemRole.desktop.ts` and pick at the manifest level. See `builtin-tool-local-system`.
|
||||
|
||||
### Dynamic system prompts
|
||||
|
||||
If the prompt depends on runtime state (current date, available models), export a function and call it in the manifest:
|
||||
|
||||
```ts
|
||||
// systemRole.ts
|
||||
export const systemPrompt = (today: string) => `Today is ${today}. You have web search tools…`;
|
||||
|
||||
// manifest.ts
|
||||
import dayjs from 'dayjs';
|
||||
systemRole: systemPrompt(dayjs(new Date()).format('YYYY-MM-DD')),
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. `ExecutionRuntime/index.ts` — Pure Runtime
|
||||
|
||||
This is **the default home for new tool logic** going forward. The runtime is a class that:
|
||||
|
||||
- Has no React, no Zustand, no `@/services/...` direct imports.
|
||||
- Receives services as **constructor injection** (or as method args).
|
||||
- Returns `BuiltinServerRuntimeOutput` from each method.
|
||||
- Is unit-testable by passing in mocks.
|
||||
|
||||
### Pattern A: Inject a service interface
|
||||
|
||||
Use when the runtime calls out to IPC, network, or DB.
|
||||
|
||||
```ts
|
||||
// ExecutionRuntime/index.ts
|
||||
import type { BuiltinServerRuntimeOutput } from '@lobechat/types';
|
||||
|
||||
export interface IWebBrowsingService {
|
||||
search: (q: SearchQuery) => Promise<UniformSearchResponse>;
|
||||
crawlPages: (urls: string[]) => Promise<CrawlResults>;
|
||||
}
|
||||
|
||||
export interface WebBrowsingRuntimeOptions {
|
||||
searchService: IWebBrowsingService;
|
||||
documentService?: WebBrowsingDocumentService;
|
||||
agentId?: string;
|
||||
topicId?: string;
|
||||
}
|
||||
|
||||
export class WebBrowsingExecutionRuntime {
|
||||
constructor(private opts: WebBrowsingRuntimeOptions) {}
|
||||
|
||||
async search(
|
||||
args: SearchQuery,
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<BuiltinServerRuntimeOutput> {
|
||||
try {
|
||||
const data = await this.opts.searchService.search(args, options);
|
||||
if (data.errorDetail) {
|
||||
return {
|
||||
success: false,
|
||||
content: data.errorDetail,
|
||||
error: { message: data.errorDetail },
|
||||
state: data,
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
content: searchResultsPrompt(data.results.slice(0, 10)),
|
||||
state: data,
|
||||
};
|
||||
} catch (e) {
|
||||
return { success: false, content: (e as Error).message, error: e };
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern B: Reuse the executor
|
||||
|
||||
Use when the same logic runs in browser and Node (e.g. mathjs, nerdamer). The runtime is a thin wrapper that imports the executor and re-types the state per API. See `builtin-tool-calculator/src/ExecutionRuntime/index.ts` for the canonical example.
|
||||
|
||||
### Pattern C: Extend a shared base
|
||||
|
||||
When you're implementing a domain that already has a base runtime (file ops via `ComputerRuntime`), extend and only override `callService` + result normalization. See `builtin-tool-local-system/src/ExecutionRuntime/index.ts`.
|
||||
|
||||
### Runtime contract
|
||||
|
||||
Every method returns:
|
||||
|
||||
```ts
|
||||
{
|
||||
content: string; // LLM-facing — never undefined; default to error message
|
||||
state?: any; // result-domain — what the UI's pluginState becomes
|
||||
success: boolean; // mandatory
|
||||
error?: any; // raw error object; the executor will repackage
|
||||
}
|
||||
```
|
||||
|
||||
Use `@lobechat/prompts` formatters (`searchResultsPrompt`, `crawlResultsPrompt`, `formatTaskCreated`, etc.) to produce structured `content`. They emit XML/markdown that's already tuned for token efficiency.
|
||||
|
||||
---
|
||||
|
||||
## 6. `client/executor/index.ts` — Frontend Wiring
|
||||
|
||||
The executor's job is to **resolve frontend defaults** (current agent, current task, scope) and **call the runtime**. It then funnels through `toResult()` into the `BuiltinToolResult` shape.
|
||||
|
||||
```ts
|
||||
import { BaseExecutor, type BuiltinToolContext, type BuiltinToolResult } from '@lobechat/types';
|
||||
import debug from 'debug';
|
||||
|
||||
import { taskService } from '@/services/task';
|
||||
import { getTaskStoreState } from '@/store/task';
|
||||
|
||||
import { TaskIdentifier } from '../../manifest';
|
||||
import { TaskApiName, type CreateTaskParams } from '../../types';
|
||||
|
||||
const log = debug('lobe-task:executor');
|
||||
|
||||
class TaskExecutor extends BaseExecutor<typeof TaskApiName> {
|
||||
readonly identifier = TaskIdentifier;
|
||||
protected readonly apiEnum = TaskApiName;
|
||||
|
||||
// ⚠ class FIELD, not a method — preserves `this` when invoked via registry
|
||||
createTask = async (
|
||||
params: CreateTaskParams,
|
||||
ctx?: BuiltinToolContext,
|
||||
): Promise<BuiltinToolResult> => {
|
||||
try {
|
||||
log('createTask params=%o', params);
|
||||
const task = await getTaskStoreState().createTask({
|
||||
name: params.name,
|
||||
instruction: params.instruction,
|
||||
// Default assignee from context — never silently override an explicit value
|
||||
assigneeAgentId:
|
||||
params.assigneeAgentId ?? (ctx?.scope === 'task' ? undefined : ctx?.agentId),
|
||||
parentTaskId: params.parentIdentifier?.trim() || undefined,
|
||||
priority: params.priority,
|
||||
});
|
||||
|
||||
if (!task) return this.errorResult('Failed to create task', 'CreateFailed');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
content: formatTaskCreated({ identifier: task.identifier, name: task.name /* … */ }),
|
||||
state: { identifier: task.identifier, success: true },
|
||||
};
|
||||
} catch (error) {
|
||||
return this.errorResult(error, 'CreateTaskFailed');
|
||||
}
|
||||
};
|
||||
|
||||
private errorResult(err: unknown, type: string): BuiltinToolResult {
|
||||
const message = err instanceof Error ? err.message : String(err) || 'Unknown error';
|
||||
return { success: false, content: `Failed: ${message}`, error: { type, message } };
|
||||
}
|
||||
}
|
||||
|
||||
export const taskExecutor = new TaskExecutor();
|
||||
```
|
||||
|
||||
### Hard rules
|
||||
|
||||
1. **Methods are class fields** (`name = async (…) => {…}`), not class methods. The registry calls `(executor as any)[apiName](params, ctx)`; arrow-function fields keep `this` bound.
|
||||
2. **`identifier` and `apiEnum` are `readonly` instance fields**, not getters — `BaseExecutor.hasApi/getApiNames` reads them synchronously at registration time.
|
||||
3. **Default missing params from `ctx`**, but never silently override explicit values. Use `params.foo ?? ctx?.foo`, not `ctx?.foo ?? params.foo`.
|
||||
4. **One funnel for all returns.** Either always return through `toResult(runtime.x())` (when delegating) or through `errorResult(…)` for the catch arm. Never inline `{ success: false, content: '' }` — `content: ''` collapses the Debug pane to blank.
|
||||
5. **`debug('lobe-<name>:executor')`.** Match the namespace to the identifier minus `lobe-` when convenient.
|
||||
6. **Singleton export.** `export const <name>Executor = new <Name>Executor()` — the registry imports the instance, not the class.
|
||||
|
||||
### When the executor delegates to ExecutionRuntime
|
||||
|
||||
```ts
|
||||
class LocalSystemExecutor extends BaseExecutor<typeof LocalSystemApiEnum> {
|
||||
readonly identifier = LocalSystemIdentifier;
|
||||
protected readonly apiEnum = LocalSystemApiEnum;
|
||||
private runtime = new LocalSystemExecutionRuntime(localFileService);
|
||||
|
||||
readLocalFile = async (params: LocalReadFileParams): Promise<BuiltinToolResult> => {
|
||||
try {
|
||||
const result = await this.runtime.readFile({
|
||||
path: params.path,
|
||||
startLine: params.loc?.[0],
|
||||
endLine: params.loc?.[1],
|
||||
});
|
||||
return this.toResult(result);
|
||||
} catch (error) {
|
||||
return this.errorResult(error);
|
||||
}
|
||||
};
|
||||
|
||||
private toResult(out: BuiltinServerRuntimeOutput): BuiltinToolResult {
|
||||
const errMsg = typeof out.error?.message === 'string' ? out.error.message : undefined;
|
||||
const safe = out.content || errMsg || 'Tool execution failed';
|
||||
if (!out.success) {
|
||||
return {
|
||||
success: false,
|
||||
content: safe,
|
||||
state: out.state, // ← preserve partial state on failure
|
||||
error: out.error
|
||||
? { type: 'PluginServerError', message: errMsg ?? safe, body: out.error }
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
return { success: true, content: safe, state: out.state };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `toResult` funnel is **mandatory**: it enforces never-undefined `content` and partial-state preservation. Both invariants caught real production bugs (`globLocalFiles` Response empty, `editLocalFile` partial state lost).
|
||||
|
||||
---
|
||||
|
||||
## 7. `index.ts` — Package Entry Point
|
||||
|
||||
Keep it pure data + the manifest. **No React, no stores, no Node-only imports.**
|
||||
|
||||
```ts
|
||||
export { TaskIdentifier, TaskManifest } from './manifest';
|
||||
export { systemPrompt } from './systemRole';
|
||||
export {
|
||||
TaskApiName,
|
||||
type TaskApiNameType,
|
||||
type CreateTaskParams,
|
||||
type CreateTaskState,
|
||||
/* …all Params/State types */
|
||||
} from './types';
|
||||
|
||||
// Optional helpers used by both the runtime and the UI
|
||||
export { TASK_STATUSES, UNFINISHED_TASK_STATUSES } from './constants';
|
||||
```
|
||||
|
||||
This entry is what `packages/builtin-tools/src/index.ts` and `identifiers.ts` import — it must be importable from server bundles.
|
||||
|
||||
---
|
||||
|
||||
## 8. `package.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"@lobechat/prompts": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lobechat/types": "workspace:*"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./client": "./src/client/index.ts",
|
||||
"./executor": "./src/client/executor/index.ts",
|
||||
"./executionRuntime": "./src/ExecutionRuntime/index.ts"
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"name": "@lobechat/builtin-tool-<name>",
|
||||
"peerDependencies": {
|
||||
"@lobehub/ui": "^5",
|
||||
"antd": "^6",
|
||||
"antd-style": "*",
|
||||
"lucide-react": "*",
|
||||
"react": "*",
|
||||
"react-i18next": "*"
|
||||
},
|
||||
"private": true,
|
||||
"version": "1.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
**Why peer not direct deps for client libs:** the `./` and `./executionRuntime` entry points must be importable from server code. Listing React etc. as peer deps prevents bundlers from following them when only the runtime is consumed.
|
||||
|
||||
**Skip `./executor`** if the package has no frontend executor (server-only tools like `builtin-tool-web-browsing`).
|
||||
|
||||
---
|
||||
|
||||
## 9. Common Pitfalls
|
||||
|
||||
| Symptom | Likely cause |
|
||||
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| "ApiNotFound" at runtime | Method name in executor doesn't match `ApiName` value (typo, wrong case) |
|
||||
| Method works once, then "this is undefined" | Method declared as `async fn() {}` instead of `fn = async () => {}` — `this` lost when registry invokes |
|
||||
| Debug "Response" pane blank but `pluginState` populated | Returning `content: ''` or letting `output.content` be undefined — use the `toResult` funnel |
|
||||
| Partial result vanishes on failure | `toResult` discarded `state` when `success: false`; preserve it |
|
||||
| Tool shows up but doesn't run on desktop | `executors` in manifest doesn't include `'client'` (or vice versa for server-only) |
|
||||
| Same tool registered twice / legacy identifier ghost | Identifier collision; check `@deprecated` aliases in `inspectors.ts`/`renders.ts` |
|
||||
| Manifest test fails after adding API | Forgot to add the corresponding i18n `apiName.<api>` key |
|
||||
| TypeScript error on `BaseExecutor<typeof X>` | `X` declared with `enum` instead of `as const` object — must be the const-object form |
|
||||
@@ -0,0 +1,721 @@
|
||||
# Tool UI Surfaces
|
||||
|
||||
A builtin tool can ship up to **six client-side surfaces**, each with a different role in the chat UI. Only `Inspector` is required; the other five are added on demand and registered in their own central files.
|
||||
|
||||
| Surface | Required? | When the chat shows it | Registered in |
|
||||
| ------------ | --------- | --------------------------------------------------------------------- | --------------------------------------------- |
|
||||
| Inspector | ✅ Always | Header strip of every tool call (one-line chip) | `packages/builtin-tools/src/inspectors.ts` |
|
||||
| Render | Optional | Rich result card below the header, after the call returns | `packages/builtin-tools/src/renders.ts` |
|
||||
| Placeholder | Optional | Skeleton between "args streaming complete" and "result arrives" | `packages/builtin-tools/src/placeholders.ts` |
|
||||
| Streaming | Optional | Live output during execution (e.g. command stdout) | `packages/builtin-tools/src/streamings.ts` |
|
||||
| Intervention | Optional | Approval / edit-before-run dialog (when `humanIntervention` triggers) | `packages/builtin-tools/src/interventions.ts` |
|
||||
| Portal | Optional | Full-screen detail view (right-side or modal) | `packages/builtin-tools/src/portals.ts` |
|
||||
|
||||
The two reference tools to read end-to-end:
|
||||
|
||||
- **`builtin-tool-web-browsing/src/client/`** — Inspector + Render + Placeholder + Portal (no Intervention/Streaming).
|
||||
- **`builtin-tool-local-system/src/client/`** — all six surfaces, including `components/` for shared building blocks.
|
||||
|
||||
---
|
||||
|
||||
## 0. Shared Style Rules
|
||||
|
||||
These apply across every surface.
|
||||
|
||||
### 0.1 Use `'use client'` at the top of every component file
|
||||
|
||||
Tool surfaces are leaves in the chat tree and must not block server rendering.
|
||||
|
||||
### 0.2 Prefer `createStaticStyles + cssVar.*`
|
||||
|
||||
Zero-runtime CSS-in-JS — the styles compile once and read CSS variables at runtime.
|
||||
|
||||
```tsx
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
chip: css`
|
||||
padding-block: 2px;
|
||||
padding-inline: 8px;
|
||||
border-radius: 999px;
|
||||
color: ${cssVar.colorText};
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
`,
|
||||
}));
|
||||
```
|
||||
|
||||
Fall back to `createStyles + token` only when you need runtime token computation (rare). Inline `style={{ color: cssVar.colorTextSecondary }}` is fine for one-off dynamic values.
|
||||
|
||||
### 0.3 Use `@lobehub/ui`, not raw `antd`
|
||||
|
||||
`Block`, `Text`, `Flexbox`, `Highlighter`, `Alert`, `Tooltip`, `Skeleton` all come from `@lobehub/ui`. Modals come from `@lobehub/ui/base-ui` (`createModal`, `useModalContext`, `confirmModal`) — see the **modal** skill.
|
||||
|
||||
Memory note: `@lobehub/ui`'s `<Text type='secondary'>` is a lighter shade than `colorTextSecondary`. If you need that exact token color, write `<Text style={{ color: cssVar.colorTextSecondary }}>`.
|
||||
|
||||
### 0.4 Always `memo` and set `displayName`
|
||||
|
||||
```tsx
|
||||
export const SearchInspector = memo<BuiltinInspectorProps<SearchQuery, UniformSearchResponse>>(
|
||||
({ args /* … */ }) => {
|
||||
/* … */
|
||||
},
|
||||
);
|
||||
SearchInspector.displayName = 'SearchInspector';
|
||||
export default SearchInspector;
|
||||
```
|
||||
|
||||
### 0.5 Always type with `BuiltinXProps<Args, State>` generics
|
||||
|
||||
Don't widen to `any`. The Args generic is the JSON Schema params, the State generic is the executor's `state` field. The two should match `<Name>Params` and `<Name>State` from `types.ts`.
|
||||
|
||||
### 0.6 Pull strings from `t('plugin')`
|
||||
|
||||
```tsx
|
||||
const { t } = useTranslation('plugin');
|
||||
t('builtins.<identifier>.apiName.<api>');
|
||||
```
|
||||
|
||||
Every Inspector should default to `t('builtins.<identifier>.apiName.<api>')` so it shows something while args stream in.
|
||||
|
||||
### 0.7 Read store state from `@/store/chat`, not props
|
||||
|
||||
Tool surfaces sometimes need cross-cutting state (loading, streaming buffer). Read it inside the component via Zustand selectors, not from props — props only carry args/state/messageId.
|
||||
|
||||
---
|
||||
|
||||
## 1. Inspector — Header Chip (required)
|
||||
|
||||
**Lifecycle:** Inspector renders for **every phase** of a tool call: while args are streaming in, while the executor is running, and after results come back. It's the only surface that's always visible.
|
||||
|
||||
**Goal:** keep it to a single line. Show what's happening with as much context as is currently available.
|
||||
|
||||
### Props (`BuiltinInspectorProps<Args, State>`)
|
||||
|
||||
```ts
|
||||
interface BuiltinInspectorProps<Arguments = any, State = any> {
|
||||
apiName: string;
|
||||
args: Arguments; // final args (only after the assistant stops streaming)
|
||||
identifier: string;
|
||||
isArgumentsStreaming?: boolean; // args still arriving
|
||||
isLoading?: boolean; // args complete, executor running
|
||||
partialArgs?: Arguments; // partial JSON during streaming
|
||||
pluginState?: State; // executor's `state` after success
|
||||
result?: { content: string | null; error?: any };
|
||||
}
|
||||
```
|
||||
|
||||
### State machine
|
||||
|
||||
| Phase | What's available | What to show |
|
||||
| ----------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------- |
|
||||
| Args streaming, no useful field yet | `isArgumentsStreaming === true`, `partialArgs.X` undefined | Just the API title with `shinyTextStyles.shinyText` |
|
||||
| Args streaming, key field arrived | `partialArgs.X` populated | Title + key field chip, still pulse-animated |
|
||||
| Args complete, executor running | `args` populated, `isLoading === true` | Same as above, still pulse-animated |
|
||||
| Result arrived | `pluginState` populated, `isLoading === false` | Title + chips + result summary (count, identifier, status) |
|
||||
|
||||
### Canonical example — Search
|
||||
|
||||
`packages/builtin-tool-web-browsing/src/client/Inspector/Search/index.tsx`:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import type { BuiltinInspectorProps, SearchQuery, UniformSearchResponse } from '@lobechat/types';
|
||||
import { Text } from '@lobehub/ui';
|
||||
import { cssVar, cx } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
|
||||
|
||||
export const SearchInspector = memo<BuiltinInspectorProps<SearchQuery, UniformSearchResponse>>(
|
||||
({ args, partialArgs, isArgumentsStreaming, isLoading, pluginState }) => {
|
||||
const { t } = useTranslation('plugin');
|
||||
|
||||
const query = args?.query || partialArgs?.query || '';
|
||||
const resultCount = pluginState?.results?.length ?? 0;
|
||||
const hasResults = resultCount > 0;
|
||||
|
||||
if (isArgumentsStreaming && !query) {
|
||||
return (
|
||||
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
|
||||
<span>{t('builtins.lobe-web-browsing.apiName.search')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
inspectorTextStyles.root,
|
||||
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
|
||||
)}
|
||||
>
|
||||
<span>{t('builtins.lobe-web-browsing.apiName.search')}: </span>
|
||||
{query && <span className={highlightTextStyles.primary}>{query}</span>}
|
||||
{!isLoading &&
|
||||
!isArgumentsStreaming &&
|
||||
pluginState?.results &&
|
||||
(hasResults ? (
|
||||
<span style={{ marginInlineStart: 4 }}>({resultCount})</span>
|
||||
) : (
|
||||
<Text as="span" color={cssVar.colorTextDescription} fontSize={12}>
|
||||
({t('builtins.lobe-web-browsing.inspector.noResults')})
|
||||
</Text>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
SearchInspector.displayName = 'SearchInspector';
|
||||
export default SearchInspector;
|
||||
```
|
||||
|
||||
### Inspector rules
|
||||
|
||||
- Wrap the whole row with `inspectorTextStyles.root` (provides correct flex / line-height baseline).
|
||||
- Pulse with `shinyTextStyles.shinyText` whenever `isArgumentsStreaming || isLoading`.
|
||||
- Show the i18n title first so the row is non-empty during the earliest streaming phase.
|
||||
- Read both `args?.X` and `partialArgs?.X` together — `args` is final, `partialArgs` is in-stream.
|
||||
- Use chips/tags for distinct facets (identifier, name, parent, status, count). Each chip should clip with `text-overflow: ellipsis` and have a `max-width` so long values don't blow out the chat bubble.
|
||||
- Append `pluginState`-derived suffixes only **after** loading finishes — count or "(no results)" should not appear while still searching.
|
||||
|
||||
### Inspector registry — `client/Inspector/index.ts`
|
||||
|
||||
```ts
|
||||
import type { BuiltinInspector } from '@lobechat/types';
|
||||
|
||||
import { TaskApiName } from '../../types';
|
||||
import { CreateTaskInspector } from './CreateTask';
|
||||
import { ListTasksInspector } from './ListTasks';
|
||||
/* … */
|
||||
|
||||
export const TaskInspectors: Record<string, BuiltinInspector> = {
|
||||
[TaskApiName.createTask]: CreateTaskInspector as BuiltinInspector,
|
||||
[TaskApiName.listTasks]: ListTasksInspector as BuiltinInspector,
|
||||
/* one entry per ApiName */
|
||||
};
|
||||
|
||||
export { CreateTaskInspector } from './CreateTask';
|
||||
export { ListTasksInspector } from './ListTasks';
|
||||
/* re-export each */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Render — Rich Result Card (optional)
|
||||
|
||||
**Lifecycle:** rendered **once the result arrives** (after Placeholder/Streaming hand off). Sits below the Inspector header.
|
||||
|
||||
**Skip if** the API is read-only or the result is just text — the framework already shows the executor's `content` string. Add a Render only when there's a structured artifact worth seeing: a card, a chart, a diff, a list of files.
|
||||
|
||||
### Props (`BuiltinRenderProps<Args, State, Content>`)
|
||||
|
||||
```ts
|
||||
interface BuiltinRenderProps<Arguments = any, State = any, Content = any> {
|
||||
apiName?: string;
|
||||
args: Arguments; // final params from the LLM
|
||||
content: Content; // executor's content string (or parsed)
|
||||
identifier?: string;
|
||||
messageId: string; // for store lookups
|
||||
pluginError?: any; // from BuiltinToolResult.error
|
||||
pluginState?: State; // executor's state
|
||||
toolCallId?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Two patterns
|
||||
|
||||
**Pattern A — Single-file Render** (web-browsing CrawlSinglePage):
|
||||
|
||||
```tsx
|
||||
// client/Render/CrawlSinglePage.tsx
|
||||
import type { BuiltinRenderProps, CrawlPluginState, CrawlSinglePageQuery } from '@lobechat/types';
|
||||
import { memo } from 'react';
|
||||
|
||||
import PageContent from './PageContent';
|
||||
|
||||
const CrawlSinglePage = memo<BuiltinRenderProps<CrawlSinglePageQuery, CrawlPluginState>>(
|
||||
({ messageId, pluginState, args }) => (
|
||||
<PageContent messageId={messageId} results={pluginState?.results} urls={[args?.url]} />
|
||||
),
|
||||
);
|
||||
export default CrawlSinglePage;
|
||||
```
|
||||
|
||||
**Pattern B — Folder with subcomponents** (web-browsing Search):
|
||||
|
||||
```
|
||||
client/Render/Search/
|
||||
├── index.tsx # composes the subcomponents, handles error states
|
||||
├── ConfigForm.tsx # appears when pluginError.type === 'PluginSettingsInvalid'
|
||||
├── SearchQuery.tsx # editable query header
|
||||
└── SearchResult.tsx # result list
|
||||
```
|
||||
|
||||
Use Pattern B when the Render has internal state (editing mode, expanded items), error variants, or is large enough to benefit from splitting.
|
||||
|
||||
### Error handling in Render
|
||||
|
||||
Renders are the canonical place to surface `pluginError` because the chat doesn't auto-render typed errors:
|
||||
|
||||
```tsx
|
||||
if (pluginError) {
|
||||
if (pluginError?.type === 'PluginSettingsInvalid') {
|
||||
return <ConfigForm id={messageId} provider={pluginError.body?.provider} />;
|
||||
}
|
||||
return (
|
||||
<Alert
|
||||
title={pluginError?.message}
|
||||
type="error"
|
||||
extra={<Highlighter language="json">{JSON.stringify(pluginError.body, null, 2)}</Highlighter>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Render rules
|
||||
|
||||
- **Return `null`** if there's nothing useful to draw yet (avoids empty cards during stream).
|
||||
- Use `pluginState` for server-truth (ids, counts, server-assigned status) and `args` for what the LLM asked. **Combine — neither alone is enough.**
|
||||
- For lists, summarize with a header line and show top N items with a "+N more" tail rather than rendering everything.
|
||||
- For modals from a Render, use `@lobehub/ui/base-ui` (`createModal`, `useModalContext`, `confirmModal`) — see the **modal** skill.
|
||||
|
||||
### Render registry — `client/Render/index.ts`
|
||||
|
||||
```ts
|
||||
import type { BuiltinRender } from '@lobechat/types';
|
||||
|
||||
import { TaskApiName } from '../../types';
|
||||
import CreateTaskRender from './CreateTask';
|
||||
import RunTasksRender from './RunTasks';
|
||||
|
||||
export const TaskRenders: Record<string, BuiltinRender> = {
|
||||
[TaskApiName.createTask]: CreateTaskRender as BuiltinRender,
|
||||
[TaskApiName.runTasks]: RunTasksRender as BuiltinRender,
|
||||
/* only the APIs with rich result UI — others fall back to text content */
|
||||
};
|
||||
|
||||
export { default as CreateTaskRender } from './CreateTask';
|
||||
export { default as RunTasksRender } from './RunTasks';
|
||||
```
|
||||
|
||||
### Render display control (rare)
|
||||
|
||||
If the Render should hide for certain results (e.g. ClaudeCode's TodoWrite hides when the agent is mid-stream), add a `RenderDisplayControl` to `packages/builtin-tools/src/displayControls.ts`. See `ClaudeCodeRenderDisplayControls` for the pattern.
|
||||
|
||||
---
|
||||
|
||||
## 3. Placeholder — Skeleton Between Args and Result (optional)
|
||||
|
||||
**Lifecycle:** rendered when the args have finished streaming but the executor hasn't returned yet. Disappears when `pluginState` arrives. Bridges the moment of perceived lag.
|
||||
|
||||
**Add for** APIs with noticeable execution time: web search, network crawl, file list, large grep. **Skip for** instant ops (status flips, calculator).
|
||||
|
||||
### Props (`BuiltinPlaceholderProps<Args>`)
|
||||
|
||||
```ts
|
||||
interface BuiltinPlaceholderProps<T extends Record<string, any> = any> {
|
||||
apiName: string;
|
||||
args?: T;
|
||||
identifier: string;
|
||||
}
|
||||
```
|
||||
|
||||
No `pluginState` — Placeholder lives entirely in the "executing" gap.
|
||||
|
||||
### Canonical example — Search Placeholder
|
||||
|
||||
`packages/builtin-tool-web-browsing/src/client/Placeholder/Search.tsx`:
|
||||
|
||||
```tsx
|
||||
import type { BuiltinPlaceholderProps, SearchQuery } from '@lobechat/types';
|
||||
import { Flexbox, Icon, Skeleton } from '@lobehub/ui';
|
||||
import { createStaticStyles, cx } from 'antd-style';
|
||||
import { SearchIcon } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
import { shinyTextStyles } from '@/styles';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
query: cx(
|
||||
css`
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
&:hover {
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
}
|
||||
`,
|
||||
shinyTextStyles.shinyText,
|
||||
),
|
||||
}));
|
||||
|
||||
export const Search = memo<BuiltinPlaceholderProps<SearchQuery>>(({ args }) => {
|
||||
const { query } = args || {};
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<Flexbox gap={8}>
|
||||
<Flexbox horizontal={!isMobile} gap={isMobile ? 8 : 40}>
|
||||
<Flexbox horizontal align="center" className={styles.query} gap={8}>
|
||||
<Icon icon={SearchIcon} />
|
||||
{query ? query : <Skeleton.Block active style={{ height: 20, width: 40 }} />}
|
||||
</Flexbox>
|
||||
<Skeleton.Block active style={{ height: 20, width: 40 }} />
|
||||
</Flexbox>
|
||||
<Flexbox horizontal gap={12}>
|
||||
{[1, 2, 3, 4, 5].map((id) => (
|
||||
<Skeleton.Button active key={id} style={{ borderRadius: 8, height: 80, width: 160 }} />
|
||||
))}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### Placeholder rules
|
||||
|
||||
- **Mirror the eventual Render's layout.** When the result arrives the Placeholder unmounts and the Render mounts; if they share dimensions, the chat doesn't jump.
|
||||
- Use `Skeleton.Block` / `Skeleton.Button` from `@lobehub/ui` for placeholder shapes.
|
||||
- Embed any args you have (e.g. the query text) — context helps the user know what's loading.
|
||||
- Pulse with `shinyTextStyles.shinyText` if the Placeholder includes literal text.
|
||||
|
||||
### Placeholder registry — `client/Placeholder/index.ts`
|
||||
|
||||
```ts
|
||||
import { WebBrowsingApiName } from '../../types';
|
||||
import CrawlMultiPages from './CrawlMultiPages';
|
||||
import CrawlSinglePage from './CrawlSinglePage';
|
||||
import { Search } from './Search';
|
||||
|
||||
export const WebBrowsingPlaceholders = {
|
||||
[WebBrowsingApiName.crawlMultiPages]: CrawlMultiPages,
|
||||
[WebBrowsingApiName.crawlSinglePage]: CrawlSinglePage,
|
||||
[WebBrowsingApiName.search]: Search,
|
||||
};
|
||||
|
||||
export { CrawlMultiPages, CrawlSinglePage, Search };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Streaming — Live Output During Execution (optional)
|
||||
|
||||
**Lifecycle:** rendered **while the executor is still running** for APIs that emit incremental output. The component is responsible for fetching the in-flight stream from the chat store and rendering it.
|
||||
|
||||
**Add for** long-running ops with continuous output: shell command execution (stdout/stderr), file write progress, code interpreter cells.
|
||||
|
||||
### Props (`BuiltinStreamingProps<Args>`)
|
||||
|
||||
```ts
|
||||
interface BuiltinStreamingProps<Arguments = any> {
|
||||
apiName: string;
|
||||
args: Arguments;
|
||||
identifier: string;
|
||||
messageId: string; // use to fetch the streaming buffer from store
|
||||
toolCallId: string;
|
||||
}
|
||||
```
|
||||
|
||||
Note there's **no `state` or `result` prop** — the Streaming component is for the in-flight phase. It pulls the live buffer from the store itself (typically via `chatToolSelectors.streamingContent(messageId)` or similar).
|
||||
|
||||
### Canonical example — RunCommandStreaming
|
||||
|
||||
`packages/builtin-tool-local-system/src/client/Streaming/RunCommand/index.tsx`:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import type { BuiltinStreamingProps } from '@lobechat/types';
|
||||
import { Highlighter } from '@lobehub/ui';
|
||||
import { memo } from 'react';
|
||||
|
||||
interface RunCommandParams {
|
||||
command?: string;
|
||||
description?: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export const RunCommandStreaming = memo<BuiltinStreamingProps<RunCommandParams>>(({ args }) => {
|
||||
const { command } = args || {};
|
||||
if (!command) return null;
|
||||
|
||||
return (
|
||||
<Highlighter
|
||||
animated
|
||||
wrap
|
||||
language="sh"
|
||||
showLanguage={false}
|
||||
style={{ padding: '4px 8px' }}
|
||||
variant="outlined"
|
||||
>
|
||||
{command}
|
||||
</Highlighter>
|
||||
);
|
||||
});
|
||||
RunCommandStreaming.displayName = 'RunCommandStreaming';
|
||||
```
|
||||
|
||||
For real-time output beyond just the command (stderr/stdout streaming), pull from the chat store:
|
||||
|
||||
```tsx
|
||||
const buffer = useChatStore((state) =>
|
||||
chatToolSelectors.streamingBuffer(messageId, toolCallId)(state),
|
||||
);
|
||||
```
|
||||
|
||||
### Streaming rules
|
||||
|
||||
- Render `null` until you have something to display (avoids flash).
|
||||
- For terminal-style output, use `Highlighter` with `animated` to show typing-like effect.
|
||||
- The Streaming component must **unmount cleanly** when execution ends — typically the framework swaps it out for the Render automatically.
|
||||
|
||||
### Streaming registry — `client/Streaming/index.ts`
|
||||
|
||||
```ts
|
||||
import { LocalSystemApiName } from '../..';
|
||||
import { RunCommandStreaming } from './RunCommand';
|
||||
import { WriteFileStreaming } from './WriteFile';
|
||||
|
||||
export const LocalSystemStreamings = {
|
||||
[LocalSystemApiName.runCommand]: RunCommandStreaming,
|
||||
[LocalSystemApiName.writeLocalFile]: WriteFileStreaming,
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Intervention — Approval / Edit-Before-Run (optional)
|
||||
|
||||
**Lifecycle:** rendered **before the executor runs** for APIs whose manifest sets `humanIntervention`. The user sees a preview of the args, can edit them, then approves or skips/cancels.
|
||||
|
||||
**Add for** destructive or sensitive ops: shell commands, file writes, file moves, payments, message broadcasts.
|
||||
|
||||
### Props (`BuiltinInterventionProps<Args>`)
|
||||
|
||||
```ts
|
||||
interface BuiltinInterventionProps<Arguments = any> {
|
||||
apiName?: string;
|
||||
args: Arguments;
|
||||
identifier?: string;
|
||||
interactionMode?: 'approval' | 'custom';
|
||||
messageId: string;
|
||||
|
||||
/** Called when the user edits the args; the approve action awaits this. */
|
||||
onArgsChange?: (args: Arguments) => void | Promise<void>;
|
||||
|
||||
/** Called on approve / skip / cancel. */
|
||||
onInteractionAction?: (
|
||||
action:
|
||||
| { type: 'submit'; payload: Record<string, unknown> }
|
||||
| { type: 'skip'; payload?: Record<string, unknown>; reason?: string }
|
||||
| { type: 'cancel'; payload?: Record<string, unknown> },
|
||||
) => Promise<void>;
|
||||
|
||||
/** Register a callback to flush pending saves before approval. Returns cleanup. */
|
||||
registerBeforeApprove?: (id: string, callback: () => void | Promise<void>) => () => void;
|
||||
}
|
||||
```
|
||||
|
||||
### Canonical example — RunCommand Intervention
|
||||
|
||||
`packages/builtin-tool-local-system/src/client/Intervention/RunCommand/index.tsx`:
|
||||
|
||||
```tsx
|
||||
import type { RunCommandParams } from '@lobechat/electron-client-ipc';
|
||||
import type { BuiltinInterventionProps } from '@lobechat/types';
|
||||
import { Flexbox, Highlighter, Text } from '@lobehub/ui';
|
||||
import { memo } from 'react';
|
||||
|
||||
const RunCommand = memo<BuiltinInterventionProps<RunCommandParams>>(({ args }) => {
|
||||
const { description, command, timeout } = args;
|
||||
return (
|
||||
<Flexbox gap={8}>
|
||||
<Flexbox horizontal justify="space-between">
|
||||
{description && <Text>{description}</Text>}
|
||||
{timeout && (
|
||||
<Text style={{ fontSize: 12 }} type="secondary">
|
||||
timeout: {formatTimeout(timeout)}
|
||||
</Text>
|
||||
)}
|
||||
</Flexbox>
|
||||
{command && (
|
||||
<Highlighter wrap language="sh" showLanguage={false} variant="outlined">
|
||||
{command}
|
||||
</Highlighter>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
export default RunCommand;
|
||||
```
|
||||
|
||||
### Intervention rules
|
||||
|
||||
- **Show a preview, not a form by default.** Editing UI is opt-in via `onArgsChange` and is usually inline (click to edit a code block, etc.).
|
||||
- For args with debounced edit state (text fields), use `registerBeforeApprove(id, flushFn)` so the approve action waits for the debounce to flush. Always return the cleanup function.
|
||||
- Call `onInteractionAction({ type: 'submit', payload })` when the user approves; `'skip'` if they skip with a reason; `'cancel'` if they cancel the whole turn.
|
||||
- Add a corresponding `interventionAudit.ts` in the package root if the tool needs scope/path validation before approval (see `local-system/src/interventionAudit.ts`).
|
||||
|
||||
### Intervention registry — `client/Intervention/index.ts`
|
||||
|
||||
```ts
|
||||
import { LocalSystemApiName } from '../..';
|
||||
import EditLocalFile from './EditLocalFile';
|
||||
import RunCommand from './RunCommand';
|
||||
import WriteFile from './WriteFile';
|
||||
/* … */
|
||||
|
||||
export const LocalSystemInterventions = {
|
||||
[LocalSystemApiName.editLocalFile]: EditLocalFile,
|
||||
[LocalSystemApiName.runCommand]: RunCommand,
|
||||
[LocalSystemApiName.writeLocalFile]: WriteFile,
|
||||
/* one entry per API that needs approval */
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Portal — Full-Screen Detail View (optional)
|
||||
|
||||
**Lifecycle:** rendered when the user opens the tool message in a side panel or full-screen modal. One Portal per **tool**, not per API — the Portal switches on `apiName` internally.
|
||||
|
||||
**Add for** tools whose results deserve a deep-dive view: search results with editable filters, page content with reader mode, code interpreter sessions.
|
||||
|
||||
### Props (`BuiltinPortalProps<Args, State>`)
|
||||
|
||||
```ts
|
||||
interface BuiltinPortalProps<Arguments = Record<string, any>, State = any> {
|
||||
apiName?: string;
|
||||
arguments: Arguments;
|
||||
identifier: string;
|
||||
messageId: string;
|
||||
state: State;
|
||||
}
|
||||
```
|
||||
|
||||
### Canonical example — Web-Browsing Portal
|
||||
|
||||
`packages/builtin-tool-web-browsing/src/client/Portal/index.tsx`:
|
||||
|
||||
```tsx
|
||||
import type { BuiltinPortalProps, CrawlPluginState, SearchQuery } from '@lobechat/types';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { WebBrowsingApiName } from '../../types';
|
||||
import PageContent from './PageContent';
|
||||
import PageContents from './PageContents';
|
||||
import Search from './Search';
|
||||
|
||||
const Portal = memo<BuiltinPortalProps>(({ arguments: args, messageId, state, apiName }) => {
|
||||
switch (apiName) {
|
||||
case WebBrowsingApiName.search:
|
||||
return <Search messageId={messageId} query={args as SearchQuery} response={state} />;
|
||||
|
||||
case WebBrowsingApiName.crawlSinglePage: {
|
||||
const result = (state as CrawlPluginState).results.find((r) => r.originalUrl === args.url);
|
||||
return <PageContent messageId={messageId} result={result} />;
|
||||
}
|
||||
|
||||
case WebBrowsingApiName.crawlMultiPages:
|
||||
return (
|
||||
<PageContents
|
||||
messageId={messageId}
|
||||
results={(state as CrawlPluginState).results}
|
||||
urls={args.urls}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
export default Portal;
|
||||
```
|
||||
|
||||
### Portal rules
|
||||
|
||||
- One Portal per tool — the file is the routing layer, subcomponents implement each API's view.
|
||||
- Portals can read the chat store directly to detect "still streaming" and render a Skeleton internally (see `Search/index.tsx:20-46`).
|
||||
- Layout assumes more space than the Render — use `Flexbox` with `height={'100%'}` and structure for a side panel viewport.
|
||||
|
||||
### Portal registry — `packages/builtin-tools/src/portals.ts`
|
||||
|
||||
```ts
|
||||
import { WebBrowsingManifest, WebBrowsingPortal } from '@lobechat/builtin-tool-web-browsing/client';
|
||||
import { type BuiltinPortal } from '@lobechat/types';
|
||||
|
||||
export const BuiltinToolsPortals: Record<string, BuiltinPortal> = {
|
||||
[WebBrowsingManifest.identifier]: WebBrowsingPortal as BuiltinPortal,
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. `client/components/` — Shared Subcomponents
|
||||
|
||||
Cross-cutting building blocks used by multiple surfaces live here, not duplicated in each surface folder.
|
||||
|
||||
Examples from `web-browsing/src/client/components/`:
|
||||
|
||||
- `CategoryAvatar.tsx` — search category icon
|
||||
- `EngineAvatar.tsx` — search engine logo (used in Inspector chip + Render list + Portal header)
|
||||
- `SearchBar.tsx` — editable query bar (used in Render and Portal)
|
||||
|
||||
Examples from `local-system/src/client/components/`:
|
||||
|
||||
- `FileItem.tsx` — single file row (used in ListFiles Render, SearchFiles Render, MoveLocalFiles Render)
|
||||
- `FilePathDisplay.tsx` — path with truncation (used everywhere)
|
||||
|
||||
### Rules
|
||||
|
||||
- Live under `client/components/`, exported via `client/components/index.ts`.
|
||||
- Re-export from `client/index.ts` only if other packages need them; otherwise keep internal.
|
||||
- Keep them dumb — props in, JSX out, no store reads. The store reads belong in the surface that composes them.
|
||||
|
||||
---
|
||||
|
||||
## 8. `client/index.ts` — Package Public API
|
||||
|
||||
Re-exports everything the registries need plus useful types/manifest:
|
||||
|
||||
```ts
|
||||
// Inspector — required
|
||||
export { TaskInspectors } from './Inspector';
|
||||
|
||||
// Render — only if any API has one
|
||||
export { TaskRenders, CreateTaskRender, RunTasksRender } from './Render';
|
||||
|
||||
// Placeholder / Streaming / Intervention — only if used
|
||||
export { LocalSystemListFilesPlaceholder, LocalSystemSearchFilesPlaceholder } from './Placeholder';
|
||||
export { LocalSystemStreamings } from './Streaming';
|
||||
export { LocalSystemInterventions } from './Intervention';
|
||||
|
||||
// Portal — single export per tool
|
||||
export { default as WebBrowsingPortal } from './Portal';
|
||||
|
||||
// Reusable components if other packages need them
|
||||
export { CategoryAvatar, EngineAvatar, SearchBar } from './components';
|
||||
|
||||
// Re-export manifest, identifier, types for convenience
|
||||
export { TaskManifest, TaskIdentifier } from '../manifest';
|
||||
export * from '../types';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Diagnostic Quick-Lookup
|
||||
|
||||
| Symptom | Surface to check | | |
|
||||
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | --- | ------------------------- |
|
||||
| No header at all on the tool call | Inspector missing from `client/Inspector/index.ts` registry | | |
|
||||
| Header shows the API name but no chips | Inspector missing \`args?.X | | partialArgs?.X\` fallback |
|
||||
| Header doesn't pulse during loading | Missing `shinyTextStyles.shinyText` on `isArgumentsStreaming \|\| isLoading` | | |
|
||||
| Empty result card under header | Render returned `<div />` instead of `null` when no data | | |
|
||||
| Layout jump when result arrives | Placeholder dimensions don't match Render dimensions | | |
|
||||
| Approval dialog never appears | Manifest missing `humanIntervention`, or Intervention not in registry | | |
|
||||
| Approval click doesn't wait for inline edit | Missing `registerBeforeApprove(id, flushFn)` | | |
|
||||
| Portal opens but blank | Switch in `Portal/index.tsx` doesn't cover the apiName | | |
|
||||
| Strings show as `builtins.lobe-foo.apiName.bar` | Missing i18n key in `src/locales/default/plugin.ts` (or not seeded in dev locale files) | | |
|
||||
| Wrong color shade on `<Text type="secondary">` | `type='secondary'` is lighter than `colorTextSecondary` — pass via `style={{ color: cssVar.colorTextSecondary }}` | | |
|
||||
@@ -11,86 +11,167 @@
|
||||
# Environment variables:
|
||||
# CDP_PORT — Chrome DevTools Protocol port (default: 9222)
|
||||
# ELECTRON_LOG — Log file path (default: /tmp/electron-dev.log)
|
||||
# ELECTRON_WAIT_S — Max seconds to wait for Electron process (default: 60)
|
||||
# RENDERER_WAIT_S — Max seconds to wait for renderer/SPA (default: 60)
|
||||
# ELECTRON_WAIT_S — Max seconds to wait for CDP to become reachable (default: 90)
|
||||
# RENDERER_WAIT_S — Max seconds to wait for SPA after CDP is up (default: 60)
|
||||
# FORCE_KILL_USER — When set to 1, silently kill the user's `bun run dev`
|
||||
# Electron without confirmation (default: always confirm-by-action)
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
CDP_PORT="${CDP_PORT:-9222}"
|
||||
ELECTRON_LOG="${ELECTRON_LOG:-/tmp/electron-dev.log}"
|
||||
ELECTRON_WAIT_S="${ELECTRON_WAIT_S:-60}"
|
||||
ELECTRON_WAIT_S="${ELECTRON_WAIT_S:-90}"
|
||||
RENDERER_WAIT_S="${RENDERER_WAIT_S:-60}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
|
||||
PIDFILE="/tmp/electron-dev-cdp-${CDP_PORT}.pid"
|
||||
|
||||
# Project-scoped electron path prefix used for pgrep matching. Any Electron
|
||||
# binary from this project (main + helpers, with or without --remote-debugging-port)
|
||||
# starts with this string in its argv[0], so a single substring match catches all.
|
||||
PROJECT_ELECTRON_PATH="${PROJECT_ROOT}/apps/desktop/node_modules/.pnpm/electron@"
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
# Get the Electron binary path used by this project
|
||||
electron_bin_pattern() {
|
||||
echo "${PROJECT_ROOT}/apps/desktop/node_modules/.pnpm/electron@*/node_modules/electron/dist/Electron.app"
|
||||
# Print pid + every descendant pid (DFS via pgrep -P).
|
||||
expand_descendants() {
|
||||
local pid="$1"
|
||||
echo "$pid"
|
||||
local children
|
||||
children=$(pgrep -P "$pid" 2>/dev/null || true)
|
||||
for c in $children; do
|
||||
expand_descendants "$c"
|
||||
done
|
||||
}
|
||||
|
||||
# Find all PIDs related to the project's Electron dev session
|
||||
find_electron_pids() {
|
||||
# Find seed PIDs related to this project's Electron dev session.
|
||||
# Matches REGARDLESS of whether --remote-debugging-port was passed, so it also
|
||||
# catches a plain `bun run dev` session the user started outside this script.
|
||||
find_project_pids() {
|
||||
local pids=""
|
||||
|
||||
# 1. Main Electron process (launched with --remote-debugging-port)
|
||||
local main_pids
|
||||
main_pids=$(pgrep -f "Electron\.app.*--remote-debugging-port=${CDP_PORT}" 2>/dev/null || true)
|
||||
[ -n "$main_pids" ] && pids="$pids $main_pids"
|
||||
# 1. Any process whose command line mentions this project's electron path
|
||||
# (covers the main Electron binary AND every Helper subprocess)
|
||||
local electron_pids
|
||||
electron_pids=$(pgrep -f "$PROJECT_ELECTRON_PATH" 2>/dev/null || true)
|
||||
pids="$pids $electron_pids"
|
||||
|
||||
# 2. Electron Helper processes (gpu, renderer, utility) spawned from the project's electron binary
|
||||
local helper_pids
|
||||
helper_pids=$(pgrep -f "${PROJECT_ROOT}/apps/desktop/node_modules/.*Electron Helper" 2>/dev/null || true)
|
||||
[ -n "$helper_pids" ] && pids="$pids $helper_pids"
|
||||
|
||||
# 3. electron-vite dev server
|
||||
# 2. electron-vite dev server (narrow match to avoid catching unrelated Vite invocations)
|
||||
local vite_pids
|
||||
vite_pids=$(pgrep -f "electron-vite.*dev" 2>/dev/null || true)
|
||||
[ -n "$vite_pids" ] && pids="$pids $vite_pids"
|
||||
vite_pids=$(pgrep -f "electron-vite[/.].*\\bdev\\b" 2>/dev/null || true)
|
||||
pids="$pids $vite_pids"
|
||||
|
||||
# 4. PID from pidfile (fallback)
|
||||
# 3. The launcher subshell from a previous `start` (saved to pidfile)
|
||||
if [ -f "$PIDFILE" ]; then
|
||||
local saved_pid
|
||||
saved_pid=$(cat "$PIDFILE")
|
||||
if kill -0 "$saved_pid" 2>/dev/null; then
|
||||
saved_pid=$(cat "$PIDFILE" 2>/dev/null || true)
|
||||
if [ -n "$saved_pid" ] && kill -0 "$saved_pid" 2>/dev/null; then
|
||||
pids="$pids $saved_pid"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Deduplicate
|
||||
echo "$pids" | tr ' ' '\n' | sort -u | grep -v '^$' | tr '\n' ' ' || true
|
||||
# 4. Whatever is currently bound to the CDP port — catches strays whose
|
||||
# binary path doesn't match (e.g. orphaned from a crashed restart)
|
||||
local port_pid
|
||||
port_pid=$(lsof -ti tcp:"$CDP_PORT" -sTCP:LISTEN 2>/dev/null || true)
|
||||
pids="$pids $port_pid"
|
||||
|
||||
echo "$pids" | tr ' ' '\n' | sort -u | grep -v '^$' | tr '\n' ' '
|
||||
}
|
||||
|
||||
# Wait for the CDP HTTP endpoint to respond, with a deadline + early bail-out
|
||||
# if the launcher process died (no point waiting if Electron crashed).
|
||||
wait_for_cdp() {
|
||||
local deadline=$(( $(date +%s) + ELECTRON_WAIT_S ))
|
||||
echo "[electron-dev] Waiting for CDP on port ${CDP_PORT} (up to ${ELECTRON_WAIT_S}s)..."
|
||||
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
if curl -sf --max-time 2 "http://localhost:${CDP_PORT}/json/version" >/dev/null 2>&1; then
|
||||
echo "[electron-dev] CDP is reachable."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# If our launcher subshell died, abort early so we don't hang the full timeout
|
||||
if [ -f "$PIDFILE" ]; then
|
||||
local saved_pid
|
||||
saved_pid=$(cat "$PIDFILE" 2>/dev/null || true)
|
||||
if [ -n "$saved_pid" ] && ! kill -0 "$saved_pid" 2>/dev/null; then
|
||||
echo "[electron-dev] Launcher PID $saved_pid is gone before CDP came up."
|
||||
echo "[electron-dev] Last 30 lines of $ELECTRON_LOG:"
|
||||
tail -30 "$ELECTRON_LOG" 2>/dev/null || true
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "[electron-dev] ERROR: CDP did not respond within ${ELECTRON_WAIT_S}s"
|
||||
echo "[electron-dev] Last 30 lines of $ELECTRON_LOG:"
|
||||
tail -30 "$ELECTRON_LOG" 2>/dev/null || true
|
||||
return 1
|
||||
}
|
||||
|
||||
# After CDP is up, wait until the SPA renders interactive elements.
|
||||
wait_for_renderer() {
|
||||
local deadline=$(( $(date +%s) + RENDERER_WAIT_S ))
|
||||
echo "[electron-dev] Waiting for SPA to load (up to ${RENDERER_WAIT_S}s)..."
|
||||
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
local snap
|
||||
snap=$(agent-browser --cdp "$CDP_PORT" snapshot -i 2>&1 || true)
|
||||
if echo "$snap" | grep -qE '\b(link|button)\b'; then
|
||||
echo "[electron-dev] Renderer ready."
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "[electron-dev] WARNING: Renderer not interactive within ${RENDERER_WAIT_S}s — proceeding anyway."
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Commands ─────────────────────────────────────────────────────────
|
||||
|
||||
do_stop() {
|
||||
echo "[electron-dev] Stopping Electron dev environment..."
|
||||
|
||||
local pids
|
||||
pids=$(find_electron_pids)
|
||||
local seed_pids
|
||||
seed_pids=$(find_project_pids)
|
||||
|
||||
if [ -z "$pids" ]; then
|
||||
echo "[electron-dev] No Electron processes found."
|
||||
# Expand to include all descendants — catches helpers spawned by the main
|
||||
# process AFTER our pgrep snapshot, and the launcher's child node/electron-vite
|
||||
# process tree.
|
||||
local all_pids=""
|
||||
for pid in $seed_pids; do
|
||||
all_pids="$all_pids $(expand_descendants "$pid")"
|
||||
done
|
||||
all_pids=$(echo "$all_pids" | tr ' ' '\n' | sort -u | grep -v '^$' | tr '\n' ' ')
|
||||
|
||||
if [ -z "$all_pids" ]; then
|
||||
echo "[electron-dev] No project Electron/vite processes found."
|
||||
else
|
||||
echo "[electron-dev] Killing PIDs: $pids"
|
||||
for pid in $pids; do
|
||||
local count
|
||||
count=$(echo "$all_pids" | tr ' ' '\n' | grep -c .)
|
||||
echo "[electron-dev] Sending SIGTERM to $count process(es): $all_pids"
|
||||
for pid in $all_pids; do
|
||||
kill "$pid" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# Wait up to 5s for graceful exit, then force-kill survivors
|
||||
# Wait up to 5s for graceful exit
|
||||
local waited=0
|
||||
while [ $waited -lt 5 ]; do
|
||||
local alive=""
|
||||
for pid in $pids; do
|
||||
kill -0 "$pid" 2>/dev/null && alive="$alive $pid"
|
||||
local any_alive=0
|
||||
for pid in $all_pids; do
|
||||
if kill -0 "$pid" 2>/dev/null; then any_alive=1; break; fi
|
||||
done
|
||||
[ -z "$alive" ] && break
|
||||
[ "$any_alive" = "0" ] && break
|
||||
sleep 1
|
||||
waited=$((waited + 1))
|
||||
done
|
||||
|
||||
# Force-kill any remaining
|
||||
for pid in $pids; do
|
||||
# SIGKILL anyone still alive
|
||||
for pid in $all_pids; do
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
echo "[electron-dev] Force-killing PID $pid"
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
@@ -98,7 +179,27 @@ do_stop() {
|
||||
done
|
||||
fi
|
||||
|
||||
# Also close any agent-browser sessions connected to this port
|
||||
# Belt-and-suspenders: anything still bound to the CDP port goes away
|
||||
local port_pid
|
||||
port_pid=$(lsof -ti tcp:"$CDP_PORT" -sTCP:LISTEN 2>/dev/null || true)
|
||||
if [ -n "$port_pid" ]; then
|
||||
echo "[electron-dev] Port $CDP_PORT still bound by PID $port_pid; force-killing"
|
||||
# shellcheck disable=SC2086
|
||||
kill -9 $port_pid 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Also re-sweep the project's electron processes — sometimes the OS spawns
|
||||
# new helpers during shutdown that didn't exist when we first enumerated.
|
||||
local stragglers
|
||||
stragglers=$(pgrep -f "$PROJECT_ELECTRON_PATH" 2>/dev/null || true)
|
||||
if [ -n "$stragglers" ]; then
|
||||
echo "[electron-dev] Cleaning up stragglers: $stragglers"
|
||||
for pid in $stragglers; do
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
done
|
||||
fi
|
||||
|
||||
# Close any agent-browser sessions connected to this port
|
||||
agent-browser --cdp "$CDP_PORT" close --all 2>/dev/null || true
|
||||
|
||||
rm -f "$PIDFILE"
|
||||
@@ -107,113 +208,84 @@ do_stop() {
|
||||
|
||||
do_status() {
|
||||
local pids
|
||||
pids=$(find_electron_pids)
|
||||
pids=$(find_project_pids)
|
||||
|
||||
if [ -z "$pids" ]; then
|
||||
echo "[electron-dev] Electron is NOT running."
|
||||
echo "[electron-dev] No project Electron processes found."
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "[electron-dev] Electron is running (PIDs: $pids)"
|
||||
echo "[electron-dev] Project processes: $pids"
|
||||
|
||||
# Check CDP connectivity
|
||||
if agent-browser --cdp "$CDP_PORT" get url >/dev/null 2>&1; then
|
||||
if curl -sf --max-time 2 "http://localhost:${CDP_PORT}/json/version" >/dev/null 2>&1; then
|
||||
local url
|
||||
url=$(agent-browser --cdp "$CDP_PORT" get url 2>&1 | tail -1)
|
||||
url=$(agent-browser --cdp "$CDP_PORT" get url 2>&1 | tail -1 || echo "?")
|
||||
echo "[electron-dev] CDP port ${CDP_PORT} is reachable. URL: $url"
|
||||
return 0
|
||||
else
|
||||
echo "[electron-dev] CDP port ${CDP_PORT} is NOT reachable (Electron may still be loading)."
|
||||
echo "[electron-dev] CDP port ${CDP_PORT} is NOT reachable (no --remote-debugging-port, or still loading)."
|
||||
return 2
|
||||
fi
|
||||
}
|
||||
|
||||
wait_for_electron() {
|
||||
echo "[electron-dev] Waiting for Electron process (up to ${ELECTRON_WAIT_S}s)..."
|
||||
local elapsed=0
|
||||
local interval=3
|
||||
while [ $elapsed -lt "$ELECTRON_WAIT_S" ]; do
|
||||
if strings "$ELECTRON_LOG" 2>/dev/null | grep -q "starting electron"; then
|
||||
echo "[electron-dev] Electron process started."
|
||||
return 0
|
||||
fi
|
||||
sleep "$interval"
|
||||
elapsed=$((elapsed + interval))
|
||||
echo "[electron-dev] Still waiting... (${elapsed}/${ELECTRON_WAIT_S}s)"
|
||||
done
|
||||
echo "[electron-dev] ERROR: Electron did not start within ${ELECTRON_WAIT_S}s"
|
||||
echo "[electron-dev] Last 20 lines of log:"
|
||||
tail -20 "$ELECTRON_LOG" 2>/dev/null || true
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_renderer() {
|
||||
echo "[electron-dev] Waiting for renderer/SPA to load (up to ${RENDERER_WAIT_S}s)..."
|
||||
|
||||
# Initial delay — renderer needs time to bootstrap
|
||||
sleep 10
|
||||
|
||||
local elapsed=10
|
||||
local interval=5
|
||||
while [ $elapsed -lt "$RENDERER_WAIT_S" ]; do
|
||||
if agent-browser --cdp "$CDP_PORT" wait 2000 >/dev/null 2>&1; then
|
||||
# Check if interactive elements are present (SPA loaded)
|
||||
local snap
|
||||
snap=$(agent-browser --cdp "$CDP_PORT" snapshot -i 2>&1 || true)
|
||||
if echo "$snap" | grep -qE 'link |button '; then
|
||||
echo "[electron-dev] Renderer ready (interactive elements found)."
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
sleep "$interval"
|
||||
elapsed=$((elapsed + interval))
|
||||
echo "[electron-dev] SPA still loading... (${elapsed}/${RENDERER_WAIT_S}s)"
|
||||
done
|
||||
|
||||
echo "[electron-dev] WARNING: Timed out waiting for renderer, proceeding anyway."
|
||||
return 0
|
||||
}
|
||||
|
||||
do_start() {
|
||||
# If already running and healthy, skip
|
||||
local status_ok=0
|
||||
do_status >/dev/null 2>&1 || status_ok=$?
|
||||
if [ "$status_ok" -eq 0 ]; then
|
||||
echo "[electron-dev] Electron is already running and CDP is reachable. Skipping start."
|
||||
echo "[electron-dev] Use 'restart' to force a fresh session, or 'stop' to tear down."
|
||||
# Already up and CDP is reachable → nothing to do
|
||||
if curl -sf --max-time 2 "http://localhost:${CDP_PORT}/json/version" >/dev/null 2>&1; then
|
||||
echo "[electron-dev] CDP already reachable on port $CDP_PORT. Skipping start."
|
||||
echo "[electron-dev] Use 'restart' to force a fresh session."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Clean up any stale processes
|
||||
# Detect the user's existing dev session (or stale processes) BEFORE killing
|
||||
local existing
|
||||
existing=$(find_project_pids)
|
||||
if [ -n "$existing" ]; then
|
||||
echo "[electron-dev] Existing project Electron/vite processes detected:"
|
||||
echo "$existing" | tr ' ' '\n' | sed 's/^/[electron-dev] PID /'
|
||||
echo "[electron-dev] Tearing them down so we can start a CDP-enabled session..."
|
||||
fi
|
||||
|
||||
do_stop
|
||||
|
||||
# Start fresh
|
||||
# Wait for port + user-data-dir locks to release. Without this, the new
|
||||
# Electron may fail with "user data directory in use" or fail to bind CDP.
|
||||
local waited=0
|
||||
while [ $waited -lt 10 ]; do
|
||||
if ! lsof -i tcp:"$CDP_PORT" >/dev/null 2>&1 \
|
||||
&& ! pgrep -f "$PROJECT_ELECTRON_PATH" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
[ $waited -eq 0 ] && echo "[electron-dev] Waiting for port + Electron locks to release..."
|
||||
sleep 1
|
||||
waited=$((waited + 1))
|
||||
done
|
||||
|
||||
echo "[electron-dev] Starting Electron dev server..."
|
||||
echo "[electron-dev] Project: $PROJECT_ROOT"
|
||||
echo "[electron-dev] Project: $PROJECT_ROOT"
|
||||
echo "[electron-dev] CDP port: $CDP_PORT"
|
||||
echo "[electron-dev] Log: $ELECTRON_LOG"
|
||||
echo "[electron-dev] Log: $ELECTRON_LOG"
|
||||
|
||||
: > "$ELECTRON_LOG" # Truncate log
|
||||
|
||||
(
|
||||
cd "$PROJECT_ROOT/apps/desktop" && \
|
||||
ELECTRON_ENABLE_LOGGING=1 npx electron-vite dev -- --remote-debugging-port="$CDP_PORT" \
|
||||
>> "$ELECTRON_LOG" 2>&1
|
||||
) &
|
||||
local bg_pid=$!
|
||||
echo "$bg_pid" > "$PIDFILE"
|
||||
echo "[electron-dev] Background PID: $bg_pid"
|
||||
# Launch in a new session (setsid) so the whole process tree shares a PGID
|
||||
# we can later signal in one shot. `setsid bash -c '... exec ...' &` keeps
|
||||
# the bash shell as the session leader; its PID is what we save.
|
||||
setsid bash -c "
|
||||
cd '$PROJECT_ROOT/apps/desktop'
|
||||
exec npx electron-vite dev -- --remote-debugging-port=$CDP_PORT
|
||||
" >> "$ELECTRON_LOG" 2>&1 < /dev/null &
|
||||
local launcher_pid=$!
|
||||
echo "$launcher_pid" > "$PIDFILE"
|
||||
echo "[electron-dev] Launcher PID (session leader): $launcher_pid"
|
||||
|
||||
# Wait for Electron process to start
|
||||
if ! wait_for_electron; then
|
||||
echo "[electron-dev] Failed to start. Cleaning up..."
|
||||
if ! wait_for_cdp; then
|
||||
echo "[electron-dev] Failed to bring up CDP. Cleaning up..."
|
||||
do_stop
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Wait for renderer to be interactive
|
||||
if ! wait_for_renderer; then
|
||||
echo "[electron-dev] Renderer not ready, but Electron is running. You may need to wait more."
|
||||
echo "[electron-dev] Renderer not interactive — you may need to wait more."
|
||||
fi
|
||||
|
||||
echo "[electron-dev] Ready! Use: agent-browser --cdp $CDP_PORT snapshot -i"
|
||||
@@ -221,7 +293,7 @@ do_start() {
|
||||
|
||||
do_restart() {
|
||||
do_stop
|
||||
sleep 2
|
||||
sleep 1
|
||||
do_start
|
||||
}
|
||||
|
||||
@@ -235,10 +307,12 @@ case "${1:-help}" in
|
||||
*)
|
||||
echo "Usage: $0 {start|stop|status|restart}"
|
||||
echo ""
|
||||
echo " start — Start Electron dev with CDP (idempotent, skips if already running)"
|
||||
echo " stop — Kill all Electron dev processes (main + helpers + vite)"
|
||||
echo " status — Check if Electron is running and CDP is reachable"
|
||||
echo " restart — Stop then start"
|
||||
echo " start — Start Electron dev with CDP. Detects + tears down any"
|
||||
echo " existing project Electron (e.g. \`bun run dev\`) first."
|
||||
echo " stop — Kill all project Electron/vite processes (main + helpers"
|
||||
echo " + descendants), with SIGTERM → 5s wait → SIGKILL fallback."
|
||||
echo " status — Check if Electron is running and CDP is reachable."
|
||||
echo " restart — Stop then start."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: 'source-command-dedupe'
|
||||
description: 'Find duplicate GitHub issues'
|
||||
---
|
||||
|
||||
# source-command-dedupe
|
||||
|
||||
Use this skill when the user asks to run the migrated source command `dedupe`.
|
||||
|
||||
## Command Template
|
||||
|
||||
Find up to 3 likely duplicate issues for a given GitHub issue.
|
||||
|
||||
To do this, follow these steps precisely:
|
||||
|
||||
1. Use an agent to check if the Github issue (a) is closed, (b) does not need to be deduped (eg. because it is broad product feedback without a specific solution, or positive feedback), or (c) already has a duplicates comment that you made earlier. If so, do not proceed.
|
||||
2. Use an agent to view a Github issue, and ask the agent to return a summary of the issue
|
||||
3. Then, launch 5 parallel agents to search Github for duplicates of this issue, using diverse keywords and search approaches, using the summary from #1
|
||||
4. Next, feed the results from #1 and #2 into another agent, so that it can filter out false positives, that are likely not actually duplicates of the original issue. If there are no duplicates remaining, do not proceed.
|
||||
5. Finally, comment back on the issue with a list of up to three duplicate issues (or zero, if there are no likely duplicates)
|
||||
|
||||
Notes (be sure to tell this to your agents, too):
|
||||
|
||||
- Use `gh` to interact with Github, rather than web fetch
|
||||
- Do not use other tools, beyond `gh` (eg. don't use other MCP servers, file edit, etc.)
|
||||
- Make a todo list first
|
||||
- For your comment, follow the following format precisely (assuming for this example that you found 3 suspected duplicates):
|
||||
|
||||
---
|
||||
|
||||
Found 3 possible duplicate issues:
|
||||
|
||||
1. <link to issue>
|
||||
2. <link to issue>
|
||||
3. <link to issue>
|
||||
|
||||
This issue will be automatically closed as a duplicate in 3 days.
|
||||
|
||||
- If your issue is a duplicate, please close it and 👍 the existing issue instead
|
||||
- To prevent auto-closure, add a comment or 👎 this comment
|
||||
|
||||
> 🤖 Generated with Codex
|
||||
|
||||
---
|
||||
@@ -117,7 +117,7 @@ it('should handle tool calls', async () => {
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'call_123',
|
||||
name: 'lobe-web-browsing____search____builtin',
|
||||
name: 'lobe-web-browsing____search',
|
||||
arguments: JSON.stringify({ query: 'weather' }),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -5,6 +5,8 @@ description: "Version release workflow. Use when the user mentions 'release', 'h
|
||||
|
||||
# Version Release Workflow
|
||||
|
||||
This skill is a router. The detailed steps live in `reference/`.
|
||||
|
||||
## Scope Boundary (Important)
|
||||
|
||||
This skill is only for:
|
||||
@@ -28,68 +30,12 @@ The primary development branch is **canary**. All day-to-day development happens
|
||||
|
||||
Only two release types are used in practice (major releases are extremely rare and can be ignored):
|
||||
|
||||
| Type | Use Case | Frequency | Source Branch | PR Title Format | Version |
|
||||
| ----- | ---------------------------------------------- | --------------------- | -------------- | ------------------------------------ | ------------- |
|
||||
| Minor | Feature iteration release | \~Every 4 weeks | canary | `🚀 release: v{x.y.0}` | Manually set |
|
||||
| Patch | Weekly release / hotfix / model / DB migration | \~Weekly or as needed | canary or main | Custom (e.g. `🚀 release: 20260222`) | Auto patch +1 |
|
||||
| Type | Use Case | Frequency | Source Branch | PR Title Format | Version | Reference |
|
||||
| ----- | ---------------------------------------------- | --------------------- | -------------- | ------------------------------------ | ------------- | -------------------------------------- |
|
||||
| Minor | Feature iteration release | \~Every 4 weeks | canary | `🚀 release: v{x.y.0}` | Manually set | `reference/minor-release.md` |
|
||||
| Patch | Weekly release / hotfix / model / DB migration | \~Weekly or as needed | canary or main | Custom (e.g. `🚀 release: 20260222`) | Auto patch +1 | `reference/patch-release-scenarios.md` |
|
||||
|
||||
## Minor Release Workflow
|
||||
|
||||
Used to publish a new minor version (e.g. `v2.2.0`), roughly every 4 weeks.
|
||||
|
||||
### Steps
|
||||
|
||||
1. **Create a release branch from canary**
|
||||
|
||||
```bash
|
||||
git checkout canary
|
||||
git pull origin canary
|
||||
git checkout -b release/v{version}
|
||||
git push -u origin release/v{version}
|
||||
```
|
||||
|
||||
2. **Determine the version number** — Read the current version from `package.json` and compute the next minor version (e.g. 2.1.x -> 2.2.0)
|
||||
|
||||
3. **Create a PR to main**
|
||||
|
||||
```bash
|
||||
gh pr create \
|
||||
--title "🚀 release: v{version}" \
|
||||
--base main \
|
||||
--head release/v{version} \
|
||||
--body "## 📦 Release v{version} ..."
|
||||
```
|
||||
|
||||
> \[!IMPORTANT]
|
||||
> The PR title must strictly match the `🚀 release: v{x.y.z}` format. CI uses a regex on this title to determine the exact version number.
|
||||
|
||||
4. **Automatic trigger after merge**: `auto-tag-release` detects the title format and uses the version number from the title to complete the release.
|
||||
|
||||
### Scripts
|
||||
|
||||
```bash
|
||||
bun run release:branch # Interactive
|
||||
bun run release:branch --minor # Directly specify minor
|
||||
```
|
||||
|
||||
## Patch Release Workflow
|
||||
|
||||
Version number is automatically bumped by patch +1. There are 4 common scenarios:
|
||||
|
||||
| Scenario | Source Branch | Branch Naming | Description |
|
||||
| ------------------- | ------------- | ----------------------------- | ------------------------------------------------ |
|
||||
| Weekly Release | canary | `release/weekly-{YYYYMMDD}` | Weekly release train, canary -> main |
|
||||
| Bug Hotfix | main | `hotfix/v{version}-{hash}` | Emergency bug fix |
|
||||
| New Model Launch | canary | Community PR merged directly | New model launch, triggered by PR title prefix |
|
||||
| DB Schema Migration | main | `release/db-migration-{name}` | Database migration, requires dedicated changelog |
|
||||
|
||||
All scenarios auto-bump patch +1. Patch PR titles do not need a version number. See `reference/patch-release-scenarios.md` for detailed steps per scenario.
|
||||
|
||||
### Scripts
|
||||
|
||||
```bash
|
||||
bun run hotfix:branch # Hotfix scenario
|
||||
```
|
||||
For writing the release-note body (any release type), see `reference/release-notes-style.md`.
|
||||
|
||||
## Auto-Release Trigger Rules (`auto-tag-release.yml`)
|
||||
|
||||
@@ -127,7 +73,7 @@ PRs that don't match any conditions above (e.g. `docs`, `chore`, `ci`, `test`) w
|
||||
|
||||
When the user requests a release:
|
||||
|
||||
### Precheck
|
||||
### Precheck (applies to all release types)
|
||||
|
||||
Before creating the release branch, verify the source branch:
|
||||
|
||||
@@ -135,204 +81,18 @@ Before creating the release branch, verify the source branch:
|
||||
- **All other release/hotfix branches**: must branch from `main`; run `git merge-base --is-ancestor main <branch> && echo OK`
|
||||
- If the branch is based on the wrong source, recreate from the correct base
|
||||
|
||||
### Minor Release
|
||||
### Routing
|
||||
|
||||
1. Read `package.json` to get the current version and compute the next minor version
|
||||
2. Create a `release/v{version}` branch from canary
|
||||
3. Push and create PR — **title must be `🚀 release: v{version}`**
|
||||
4. Inform the user that merge will auto-trigger release
|
||||
Pick the right reference and follow it end-to-end:
|
||||
|
||||
### Patch Release
|
||||
- **Minor release** → `reference/minor-release.md`
|
||||
- **Patch release** (weekly / hotfix / model launch / DB migration) → `reference/patch-release-scenarios.md`
|
||||
- **Writing the PR body / release notes** (any release type) → `reference/release-notes-style.md`
|
||||
|
||||
Choose workflow by scenario (see `reference/patch-release-scenarios.md`):
|
||||
### Hard Rules (apply to every release type)
|
||||
|
||||
- **Weekly Release**: create `release/weekly-{YYYYMMDD}` from canary; use `git log main..canary` for release note inputs; title like `🚀 release: 20260222`
|
||||
- **Bug Hotfix**: create `hotfix/` from main; use gitmoji prefix title (e.g. `🐛 fix: ...`)
|
||||
- **New Model Launch**: community PRs trigger automatically via title prefix (`feat` / `style`)
|
||||
- **DB Migration**: create `release/db-migration-{name}` from main; cherry-pick migration commits; include dedicated migration notes
|
||||
|
||||
### Hard Rules
|
||||
|
||||
- **Do NOT** manually modify `package.json` version
|
||||
- **Do NOT** manually create tags
|
||||
- Minor PR title format is strict
|
||||
- Patch PRs do not need explicit version number
|
||||
- Keep release facts accurate; do not invent metrics or availability statements
|
||||
|
||||
## GitHub Release Changelog Standard (Long-Form Style)
|
||||
|
||||
Use this section for writing **GitHub Release notes** (or release PR body when the PR body is intended to become release notes).\
|
||||
Do not use this as `docs/changelog` page guidance.
|
||||
|
||||
### Positioning
|
||||
|
||||
This release-note style is:
|
||||
|
||||
1. **Data-backed at the top** (date, range, key metrics)
|
||||
2. **Narrative first, then structured detail**
|
||||
3. **Deep but scannable** (clear sectioning + compact bullets)
|
||||
4. **Contributor-forward** (credits are part of the release story)
|
||||
|
||||
### Required Inputs Before Writing
|
||||
|
||||
Collect these inputs first:
|
||||
|
||||
1. Compare range (`<prev_tag>...<current_tag>`)
|
||||
2. Release metrics (commits, merged PRs, resolved issues, contributors, optional files/insertions/deletions)
|
||||
3. High-impact changes by domain (core loop, platform/gateway, UX, tooling, security, reliability)
|
||||
4. Contributor list (with standout contributions if known)
|
||||
5. Known risks / migrations / rollout notes (if any)
|
||||
|
||||
If metrics cannot be reliably computed, omit unknown numbers instead of guessing.
|
||||
|
||||
### Canonical Structure
|
||||
|
||||
Follow this section order unless the user asks otherwise:
|
||||
|
||||
1. `# 🚀 LobeHub v<x.y.z> (<YYYYMMDD>)`
|
||||
2. Metadata lines:
|
||||
- `Release Date`
|
||||
- `Since <Previous Version>` metrics
|
||||
3. One quoted release thesis (single paragraph, 1-2 lines)
|
||||
4. `## ✨ Highlights` (6-12 bullets for major releases; 3-8 for weekly)
|
||||
5. Domain blocks with optional `###` subsections:
|
||||
- `## 🏗️ Core Agent & Architecture` (or equivalent product core)
|
||||
- `## 📱 Platforms / Integrations`
|
||||
- `## 🖥️ CLI & User Experience`
|
||||
- `## 🔧 Tooling`
|
||||
- `## 🔒 Security & Reliability`
|
||||
- `## 📚 Documentation` (optional if meaningful)
|
||||
6. `## 👥 Contributors`
|
||||
7. `**Full Changelog**: <prev>...<current>`
|
||||
|
||||
Use `---` separators between major blocks for long releases.
|
||||
|
||||
### Writing Rules (Hard)
|
||||
|
||||
1. **No fabricated metrics**: all numbers must be traceable.
|
||||
2. **No vague headline bullets**: each bullet must include capability + impact.
|
||||
3. **No internal-only framing**: phrase from user/operator perspective.
|
||||
4. **Security must be explicit** when security-sensitive fixes are present.
|
||||
5. **PR/issue linkage**: use `(#1234)` when IDs are available.
|
||||
6. **Terminology consistency**: same feature/provider name across sections.
|
||||
7. **Do not bury migration or breaking changes**: elevate to dedicated section or callout.
|
||||
|
||||
### Style Rules (Long-Form)
|
||||
|
||||
1. Start with an "everyday use" framing, not implementation internals.
|
||||
2. Mix narrative sentence + evidence bullets.
|
||||
3. Keep bullets compact but informative:
|
||||
- Good: `**Fast Mode (`/fast`)** — Priority routing for OpenAI and Anthropic, reducing latency on supported models. (#6875, #6960)`
|
||||
4. Use bold only for capability names, not for whole sentences.
|
||||
5. Keep heading depth <= 3 levels.
|
||||
|
||||
### Release Size Heuristics
|
||||
|
||||
- **Minor / major milestone release**
|
||||
- Include full structure with multiple domain blocks.
|
||||
- `Highlights` usually 8-12 bullets.
|
||||
- **Weekly patch release**
|
||||
- Keep full skeleton but reduce subsection count.
|
||||
- `Highlights` usually 4-8 bullets.
|
||||
- **DB migration release**
|
||||
- Keep concise.
|
||||
- Must include `Migration overview`, operator impact, and rollback/backup note.
|
||||
|
||||
### Contributor Ordering
|
||||
|
||||
Render contributors as a **single flat list** (no separate "Community" / "Core Team" subsections). Order: **community contributors first, team members after**. Within each group, sort by PR count desc. Bots (`@lobehubbot`, `renovate[bot]`) go on a separate "maintenance" line.
|
||||
|
||||
**LobeHub team roster** — anyone in this list is a team member; anyone not in this list is a community contributor:
|
||||
|
||||
- @arvinxx
|
||||
- @Innei
|
||||
- @tjx666 (commit author name: YuTengjing)
|
||||
- @LiJian
|
||||
- @Neko
|
||||
- @Rdmclin2
|
||||
- @AmAzing129
|
||||
- @sudongyuer
|
||||
- @rivertwilight
|
||||
- @CanisMinor
|
||||
|
||||
> **Resolving handles** — git author names (e.g. `YuTengjing`) are not always the GitHub handle. Verify via `gh pr view <PR> --json author` or `gh api search/users -f q='<email>'` before listing.
|
||||
|
||||
If a new contributor appears who is not on this list, treat them as community by default and ask the user whether to add them to the roster.
|
||||
|
||||
### GitHub Release Changelog Template
|
||||
|
||||
```md
|
||||
# 🚀 LobeHub v<x.y.z> (<YYYYMMDD>)
|
||||
|
||||
**Release Date:** <Month DD, YYYY>
|
||||
**Since <Previous Version>:** <N merged PRs> · <N resolved issues> · <N contributors>
|
||||
|
||||
> <One release thesis sentence: what this release unlocks in practice.>
|
||||
|
||||
---
|
||||
|
||||
## ✨ Highlights
|
||||
|
||||
- **<Capability A>** — <What changed and why it matters>. (#1234)
|
||||
- **<Capability B>** — <What changed and why it matters>. (#2345)
|
||||
- **<Capability C>** — <What changed and why it matters>. (#3456)
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Core Product & Architecture
|
||||
|
||||
### <Subdomain>
|
||||
|
||||
- <Concrete change + impact>. (#...)
|
||||
- <Concrete change + impact>. (#...)
|
||||
|
||||
---
|
||||
|
||||
## 📱 Platforms / Integrations
|
||||
|
||||
- <Platform update + impact>. (#...)
|
||||
- <Compatibility/reliability fix + impact>. (#...)
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ CLI & User Experience
|
||||
|
||||
- <User-facing workflow improvement>. (#...)
|
||||
- <Quality-of-life fix>. (#...)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Tooling
|
||||
|
||||
- <Tool/runtime improvement>. (#...)
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security & Reliability
|
||||
|
||||
- **Security:** <hardening or vulnerability fix>. (#...)
|
||||
- **Reliability:** <stability/performance behavior improvement>. (#...)
|
||||
|
||||
---
|
||||
|
||||
## 👥 Contributors
|
||||
|
||||
Huge thanks to **<N contributors>** who shipped **<N merged PRs>** this cycle.
|
||||
|
||||
@<community-handle> · @<community-handle> · @<team-handle> · @<team-handle>
|
||||
|
||||
Plus @lobehubbot and renovate[bot] for maintenance.
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: <previous_tag>...<current_tag>
|
||||
```
|
||||
|
||||
### Quick Checklist
|
||||
|
||||
- [ ] Uses top metadata and a clear release thesis
|
||||
- [ ] Includes `Highlights` plus domain-grouped sections
|
||||
- [ ] Every major bullet states both change and user/operator impact
|
||||
- [ ] Security and reliability updates are explicitly surfaced (when present)
|
||||
- [ ] Contributor credits and compare range are included
|
||||
- [ ] All numbers and claims are verifiable
|
||||
- **Do NOT** manually modify `package.json` version — CI handles it.
|
||||
- **Do NOT** manually create tags — CI handles them.
|
||||
- Minor PR title format is strict (`🚀 release: v{x.y.z}`).
|
||||
- Patch PRs do not need an explicit version number.
|
||||
- Keep release facts accurate; do not invent metrics or availability statements. Release-note inputs (compare base, PR refs, contributor list) **must be derived from `git`** per `reference/release-notes-style.md` § Computing Inputs — never from memory or descriptions.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 🚀 LobeHub v2.1.50 (20260416)
|
||||
# 🚀 LobeHub Release (20260416)
|
||||
|
||||
**Release Date:** April 20, 2026\
|
||||
**Migration Scope:** Agent benchmark data model bootstrap (5 new tables, 2 new indexes)
|
||||
@@ -7,14 +7,6 @@
|
||||
|
||||
---
|
||||
|
||||
## ✨ Highlights
|
||||
|
||||
- **Benchmark Lifecycle Schema** — Added a relational model that tracks benchmark setup, runs, per-topic execution, and record outputs end-to-end.
|
||||
- **Queryability Upgrade** — Added indexes for run status and benchmark-topic joins, improving operational queries in dashboard and debugging workflows.
|
||||
- **Safer Operator Rollout** — Migration is startup-driven and backward-compatible with existing non-benchmark chat workflows.
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ Migration Overview
|
||||
|
||||
Added tables:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 🚀 LobeHub v2.1.54 (20260427)
|
||||
# 🚀 LobeHub Release (20260427)
|
||||
|
||||
**Hotfix Scope:** Agent topic-switching regression — stale chat state on agent change
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 🚀 LobeHub v2.1.50 (20260420)
|
||||
# 🚀 LobeHub Release (20260420)
|
||||
|
||||
**Release Date:** April 20, 2026\
|
||||
**Since v2026.04.13:** 96 commits · 58 merged PRs · 31 resolved issues · 17 contributors
|
||||
**Since previous release:** 96 commits · 58 merged PRs · 31 resolved issues · 17 contributors
|
||||
|
||||
> This weekly release focuses on reducing friction in everyday agent work: faster model routing, smoother gateway behavior, stronger task continuity, and clearer operator diagnostics when something goes wrong.
|
||||
|
||||
@@ -77,4 +77,4 @@
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: v2026.04.13...v2026.04.20
|
||||
**Full Changelog**: <previous-tag>...<current-tag>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# Minor Release Workflow
|
||||
|
||||
Used to publish a new minor version (e.g. `v2.2.0`), roughly every 4 weeks. The PR title carries the exact version number; CI parses it to drive the rest of the release.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Create a release branch from canary**
|
||||
|
||||
```bash
|
||||
git checkout canary
|
||||
git pull origin canary
|
||||
git checkout -b release/v{version}
|
||||
git push -u origin release/v{version}
|
||||
```
|
||||
|
||||
2. **Determine the version number** — Read the current version from `package.json` and compute the next minor version (e.g. `2.1.x` → `2.2.0`).
|
||||
|
||||
3. **Create a PR to main**
|
||||
|
||||
```bash
|
||||
gh pr create \
|
||||
--title "🚀 release: v{version}" \
|
||||
--base main \
|
||||
--head release/v{version} \
|
||||
--body-file release_body.md
|
||||
```
|
||||
|
||||
> \[!IMPORTANT]
|
||||
> The PR title must strictly match the `🚀 release: v{x.y.z}` format. CI uses a regex on this title to determine the exact version number.
|
||||
|
||||
4. **Write the PR body as release notes** — Follow `release-notes-style.md`. Compare base is the latest semver tag on main (`git describe --tags --abbrev=0 origin/main`).
|
||||
|
||||
5. **Automatic trigger after merge** — `auto-tag-release` detects the title format, uses the version number from the title, bumps `package.json`, tags `v{x.y.z}`, creates the GitHub Release, and dispatches `sync-main-to-canary`.
|
||||
|
||||
## Scripts
|
||||
|
||||
```bash
|
||||
bun run release:branch # Interactive
|
||||
bun run release:branch --minor # Directly specify minor
|
||||
```
|
||||
|
||||
## Hard Rules (specific to Minor)
|
||||
|
||||
- PR title format is **strict**: `🚀 release: v{x.y.z}`. Any deviation falls through to patch detection.
|
||||
- Do **NOT** manually modify `package.json` version — CI will bump it.
|
||||
- Do **NOT** manually create the tag — CI will tag.
|
||||
- Highlights bullet count is usually 8–12 (see `release-notes-style.md` size heuristics).
|
||||
@@ -21,12 +21,16 @@ git push -u origin release/weekly-{YYYYMMDD}
|
||||
|
||||
2. **Scan changes and write changelog**
|
||||
|
||||
Compute the previous tag from main first — never reuse the last weekly's tag, since hotfixes published in between will be missed:
|
||||
|
||||
```bash
|
||||
git log main..canary --oneline
|
||||
git diff main...canary --stat
|
||||
git fetch origin main canary --tags
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 origin/main --match 'v*.*.*' --exclude '*-canary*' --exclude '*-nightly*')
|
||||
git log "$PREV_TAG..origin/release/weekly-{YYYYMMDD}" --oneline --no-merges
|
||||
git diff "$PREV_TAG...origin/release/weekly-{YYYYMMDD}" --stat
|
||||
```
|
||||
|
||||
Write a user-facing changelog following the format in `patch-release-changelog-example.md`.
|
||||
Then follow `./release-notes-style.md` § **Computing Inputs (Hard Rules)** to derive PR refs, metrics, and contributors. Every `(#XXXX)` in the body must come from actual commit subjects in this range — never inferred from descriptions.
|
||||
|
||||
3. **Create PR to main** with the changelog as the PR body
|
||||
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
# GitHub Release Changelog Standard (Long-Form Style)
|
||||
|
||||
Use this guide for **GitHub Release notes** — the body of a release PR that becomes the GitHub Release after merge. Do **not** use it for `docs/changelog/*.mdx` website pages (load `../../docs-changelog/SKILL.md` instead).
|
||||
|
||||
## Positioning
|
||||
|
||||
This release-note style is:
|
||||
|
||||
1. **Data-backed at the top** (date, range, key metrics)
|
||||
2. **Narrative first, then structured detail**
|
||||
3. **Deep but scannable** (clear sectioning + compact bullets)
|
||||
4. **Contributor-forward** (credits are part of the release story)
|
||||
|
||||
## Required Inputs Before Writing
|
||||
|
||||
Collect these inputs first:
|
||||
|
||||
1. Compare range (`<prev_tag>...<current_tag>`)
|
||||
2. Release metrics (commits, merged PRs, resolved issues, contributors, optional files/insertions/deletions)
|
||||
3. High-impact changes by domain (core loop, platform/gateway, UX, tooling, security, reliability)
|
||||
4. Contributor list (with standout contributions if known)
|
||||
5. Known risks / migrations / rollout notes (if any)
|
||||
|
||||
If metrics cannot be reliably computed, omit unknown numbers instead of guessing.
|
||||
|
||||
## Computing Inputs (Hard Rules — Verify, Never Guess)
|
||||
|
||||
> Hallucinated PR numbers and wrong "Since v..." bases are the #1 failure mode of this skill. Every number and every `(#XXXX)` must come from `git`, never from memory or inference.
|
||||
|
||||
### 1. Compare base = latest semver tag on `main`
|
||||
|
||||
Do **not** eyeball the tag list or pick the "last weekly" PR. Compute it:
|
||||
|
||||
```bash
|
||||
git fetch origin main canary --tags
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 origin/main --match 'v*.*.*' --exclude '*-canary*' --exclude '*-nightly*')
|
||||
echo "$PREV_TAG"
|
||||
```
|
||||
|
||||
Sanity check that the tag is reachable from the release branch:
|
||||
|
||||
```bash
|
||||
git merge-base --is-ancestor "$PREV_TAG" origin/release/weekly-{YYYYMMDD} && echo OK
|
||||
```
|
||||
|
||||
If the check fails, stop and ask the user — the release branch is based on the wrong source.
|
||||
|
||||
> **Why not "the last weekly release PR"?** Hotfixes (`v2.1.54`, `v2.1.55`, …) merge directly into main between weeklies. They get back-merged via `sync-main-to-canary`, so the latest semver tag on main _is_ the correct previous release for both weekly and minor flows. Picking the previous weekly's tag will silently undercount and put a stale version in "Since v…".
|
||||
|
||||
### 2. PR refs must come from commit subjects — never from descriptions
|
||||
|
||||
Compute the canonical set:
|
||||
|
||||
```bash
|
||||
git log "$PREV_TAG..origin/release/weekly-{YYYYMMDD}" \
|
||||
--pretty=format:'%s' --no-merges \
|
||||
| grep -oE '\(#[0-9]+\)$' \
|
||||
| sort -u > /tmp/release_prs.txt
|
||||
```
|
||||
|
||||
Hard rules:
|
||||
|
||||
- Every `(#XXXX)` you write in the body **must** appear in `/tmp/release_prs.txt`. No exceptions.
|
||||
- Never infer a PR number from a feature description. If you remember "the KB BM25 PR was around #14501", that memory is wrong about half the time. Look up the commit hash by feature keyword and read its actual subject.
|
||||
- If your terminal truncates long subjects (any wrapper that compresses output, e.g. `rtk`), bypass it. With `rtk` use `rtk proxy git log …`. Verify with `wc -l /tmp/release_prs.txt` — the count must match `git log $PREV_TAG..HEAD --no-merges --pretty=format:'%h' | wc -l` minus the few commits without a PR ref. A mismatch of >5% means subjects are being silently truncated.
|
||||
|
||||
### 3. Metrics must come from git counts
|
||||
|
||||
```bash
|
||||
PR_COUNT=$(wc -l < /tmp/release_prs.txt | tr -d ' ')
|
||||
|
||||
COMMIT_COUNT=$(git log "$PREV_TAG..origin/release/weekly-{YYYYMMDD}" --no-merges --pretty=format:'%h' | wc -l | tr -d ' ')
|
||||
|
||||
CONTRIBUTOR_COUNT=$(git log "$PREV_TAG..origin/release/weekly-{YYYYMMDD}" --no-merges --pretty=format:'%an' \
|
||||
| sort -u \
|
||||
| grep -viE '^(lobehubbot|LobeHub Bot|renovate\[bot\])$' \
|
||||
| wc -l | tr -d ' ')
|
||||
```
|
||||
|
||||
If a number cannot be confidently derived, omit it — never guess.
|
||||
|
||||
### 4. Author-to-handle resolution
|
||||
|
||||
Git `%an` is the commit author display name, not the GitHub handle. For each author you mention, confirm the handle:
|
||||
|
||||
```bash
|
||||
gh pr view "$PR_NUMBER" --repo lobehub/lobe-chat --json author --jq '.author.login'
|
||||
```
|
||||
|
||||
Use the result for `@handle`. Then classify each author per the `LobeHub team roster` below; community first, team after.
|
||||
|
||||
### 5. Pre-publish verification (mandatory)
|
||||
|
||||
Before `gh pr create` / `gh pr edit --body-file`, diff body PR refs against the canonical set:
|
||||
|
||||
```bash
|
||||
grep -oE '#[0-9]+' release_body.md | sort -u > /tmp/body_prs.txt
|
||||
sed 's/[()]//g' /tmp/release_prs.txt > /tmp/release_prs_clean.txt
|
||||
|
||||
echo "=== In body but NOT in actual range (must be EMPTY) ==="
|
||||
comm -23 /tmp/body_prs.txt /tmp/release_prs_clean.txt
|
||||
```
|
||||
|
||||
Empty diff = OK. Any output = the body cites a PR that wasn't merged in this range. Stop and fix before publishing.
|
||||
|
||||
Also verify the metrics line in the body matches the computed values (`PR_COUNT`, `CONTRIBUTOR_COUNT`) and that `**Full Changelog**` uses `$PREV_TAG`, not some older tag.
|
||||
|
||||
## Canonical Structure (Long-Form: Minor / Weekly)
|
||||
|
||||
Follow this section order for **Minor** and **Weekly** releases unless the user asks otherwise. For **Hotfix** and **DB Migration**, see § Variants for Shorter Releases below — the canonical structure does not apply.
|
||||
|
||||
1. `# 🚀 LobeHub Release (<YYYYMMDD>)`
|
||||
2. Metadata lines:
|
||||
- `Release Date`
|
||||
- `Since <Previous Version>` metrics
|
||||
3. One quoted release thesis (single paragraph, 1-2 lines)
|
||||
4. `## ✨ Highlights` (6-12 bullets for major releases; 3-8 for weekly)
|
||||
5. Domain blocks with optional `###` subsections:
|
||||
- `## 🏗️ Core Agent & Architecture` (or equivalent product core)
|
||||
- `## 📱 Platforms / Integrations`
|
||||
- `## 🖥️ CLI & User Experience`
|
||||
- `## 🔧 Tooling`
|
||||
- `## 🔒 Security & Reliability`
|
||||
- `## 📚 Documentation` (optional if meaningful)
|
||||
6. `## 👥 Contributors`
|
||||
7. `**Full Changelog**: <prev>...<current>`
|
||||
|
||||
Use `---` separators between major blocks for long releases.
|
||||
|
||||
## Variants for Shorter Releases
|
||||
|
||||
The Canonical Structure above is for **long-form** (Minor / Weekly). Two short-form variants override it.
|
||||
|
||||
### Hotfix Variant
|
||||
|
||||
A hotfix targets one regression and ships fast. The body is short and operator-focused — no Highlights, no domain blocks, no Contributors line.
|
||||
|
||||
Required sections, in order:
|
||||
|
||||
1. `# 🚀 LobeHub Release (<YYYYMMDD>)`
|
||||
2. `**Hotfix Scope:**` — one line summarizing the regression scope (e.g. `Agent topic-switching regression — stale chat state on agent change`). Replaces the long-form `Release Date` / `Since vX.Y.Z` metrics.
|
||||
3. One quoted thesis (single paragraph, 1-2 lines) describing what is now restored.
|
||||
4. `## 🐛 What's Fixed` — 1-3 bullets, each `**<symptom>** — <fix in one sentence>. (#PR)`. No root-cause prose; that lives in the commit message.
|
||||
5. `## ⚙️ Upgrade` — short notes for self-hosted (pull image / restart, schema or env changes) and cloud (usually "applied automatically").
|
||||
6. `## 👥 Owner` — single `@handle` for the PR author, resolved via `gh pr view "$PR" --json author --jq '.author.login'`. Never hardcoded.
|
||||
|
||||
Hard rules specific to hotfix:
|
||||
|
||||
- **No Highlights / domain blocks / Contributors / Full Changelog** — these add noise to a one-shot fix.
|
||||
- **No metric line** — `Since vX.Y.Z` doesn't apply; the body cites the single PR (or 1-3 PRs) directly.
|
||||
- **Owner ≠ Contributors** — one author, listed under § Owner. Not a flat handle list.
|
||||
- See `changelog-example/hotfix.md` for the canonical template.
|
||||
|
||||
### DB Migration Variant
|
||||
|
||||
Database schema changes that need to be released independently. Operator impact is the headline.
|
||||
|
||||
Required sections, in order:
|
||||
|
||||
1. `# 🚀 LobeHub Release (<YYYYMMDD>)` + scope line
|
||||
2. **Migration overview** — what tables / columns are added, modified, or removed
|
||||
3. **Operator impact** — backwards-compatible? required actions for self-hosted?
|
||||
4. **Rollback / backup note** — how to recover
|
||||
5. `## 👥 Owner` — single PR author, resolved via `gh pr view`
|
||||
|
||||
See `changelog-example/db-migration.md` for the canonical template.
|
||||
|
||||
## Writing Rules (Hard)
|
||||
|
||||
1. **No fabricated metrics**: all numbers must be traceable.
|
||||
2. **No vague headline bullets**: each bullet must include capability + impact.
|
||||
3. **No internal-only framing**: phrase from user/operator perspective.
|
||||
4. **Security must be explicit** when security-sensitive fixes are present.
|
||||
5. **PR/issue linkage**: use `(#1234)` when IDs are available.
|
||||
6. **Terminology consistency**: same feature/provider name across sections.
|
||||
7. **Do not bury migration or breaking changes**: elevate to dedicated section or callout.
|
||||
|
||||
## Style Rules (Long-Form)
|
||||
|
||||
1. Start with an "everyday use" framing, not implementation internals.
|
||||
2. Mix narrative sentence + evidence bullets.
|
||||
3. Keep bullets compact but informative:
|
||||
- Good: `**Fast Mode (`/fast`)** — Priority routing for OpenAI and Anthropic, reducing latency on supported models. (#6875, #6960)`
|
||||
4. Use bold only for capability names, not for whole sentences.
|
||||
5. Keep heading depth ≤ 3 levels.
|
||||
|
||||
## Release Size Heuristics
|
||||
|
||||
- **Minor / major milestone release**
|
||||
- Long-form structure with multiple domain blocks.
|
||||
- `Highlights` usually 8-12 bullets.
|
||||
- **Weekly patch release**
|
||||
- Long-form skeleton with reduced subsection count.
|
||||
- `Highlights` usually 4-8 bullets.
|
||||
- **Hotfix release**
|
||||
- Short-form (see § Variants → Hotfix). No Highlights, no domain blocks, no Contributors.
|
||||
- 1-3 fix bullets. Body should fit on one screen.
|
||||
- **DB migration release**
|
||||
- Short-form (see § Variants → DB Migration).
|
||||
- Must include `Migration overview`, operator impact, and rollback/backup note.
|
||||
|
||||
## Contributor Ordering
|
||||
|
||||
Render contributors as a **single flat list** (no separate "Community" / "Core Team" subsections). Order: **community contributors first, team members after**. Within each group, sort by PR count desc. Bots (`@lobehubbot`, `renovate[bot]`) go on a separate "maintenance" line.
|
||||
|
||||
**LobeHub team roster** — anyone in this list is a team member; anyone not in this list is a community contributor:
|
||||
|
||||
- @arvinxx
|
||||
- @Innei
|
||||
- @tjx666 (commit author name: YuTengjing)
|
||||
- @LiJian
|
||||
- @Neko
|
||||
- @Rdmclin2
|
||||
- @AmAzing129
|
||||
- @sudongyuer (commit author name: Tsuki)
|
||||
- @rivertwilight (commit author name: René Wang)
|
||||
- @CanisMinor
|
||||
- @cy948 (commit author name: Rylan Cai)
|
||||
|
||||
> **Resolving handles** — git author names (e.g. `YuTengjing`) are not always the GitHub handle. Verify via `gh pr view "$PR" --json author` or `gh api search/users -f q='<email>'` before listing.
|
||||
|
||||
If a new contributor appears who is not on this list, treat them as community by default and ask the user whether to add them to the roster.
|
||||
|
||||
## Template
|
||||
|
||||
```md
|
||||
# 🚀 LobeHub Release (<YYYYMMDD>)
|
||||
|
||||
**Release Date:** <Month DD, YYYY>
|
||||
**Since <Previous Version>:** <N merged PRs> · <N resolved issues> · <N contributors>
|
||||
|
||||
> <One release thesis sentence: what this release unlocks in practice.>
|
||||
|
||||
---
|
||||
|
||||
## ✨ Highlights
|
||||
|
||||
- **<Capability A>** — <What changed and why it matters>. (#1234)
|
||||
- **<Capability B>** — <What changed and why it matters>. (#2345)
|
||||
- **<Capability C>** — <What changed and why it matters>. (#3456)
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Core Product & Architecture
|
||||
|
||||
### <Subdomain>
|
||||
|
||||
- <Concrete change + impact>. (#...)
|
||||
- <Concrete change + impact>. (#...)
|
||||
|
||||
---
|
||||
|
||||
## 📱 Platforms / Integrations
|
||||
|
||||
- <Platform update + impact>. (#...)
|
||||
- <Compatibility/reliability fix + impact>. (#...)
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ CLI & User Experience
|
||||
|
||||
- <User-facing workflow improvement>. (#...)
|
||||
- <Quality-of-life fix>. (#...)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Tooling
|
||||
|
||||
- <Tool/runtime improvement>. (#...)
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security & Reliability
|
||||
|
||||
- **Security:** <hardening or vulnerability fix>. (#...)
|
||||
- **Reliability:** <stability/performance behavior improvement>. (#...)
|
||||
|
||||
---
|
||||
|
||||
## 👥 Contributors
|
||||
|
||||
Huge thanks to **<N contributors>** who shipped **<N merged PRs>** this cycle.
|
||||
|
||||
@<community-handle> · @<community-handle> · @<team-handle> · @<team-handle>
|
||||
|
||||
Plus @lobehubbot and renovate[bot] for maintenance.
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: <previous_tag>...<current_tag>
|
||||
```
|
||||
|
||||
## Quick Checklist
|
||||
|
||||
### Long-Form (Minor / Weekly)
|
||||
|
||||
- [ ] `PREV_TAG` is `git describe --tags --abbrev=0 origin/main` (latest semver), not the last weekly's tag
|
||||
- [ ] Every `(#XXXX)` in the body appears in `/tmp/release_prs.txt` (verified via `comm -23`)
|
||||
- [ ] `Since v…` line uses `$PREV_TAG`; PR / contributor counts match `wc -l` on the computed sets
|
||||
- [ ] `**Full Changelog**` uses `$PREV_TAG...release/weekly-<YYYYMMDD>` (or `…v{x.y.z}` for minor)
|
||||
- [ ] Author handles resolved via `gh pr view --json author`, not assumed from `%an`
|
||||
- [ ] Uses top metadata and a clear release thesis
|
||||
- [ ] Includes `Highlights` plus domain-grouped sections
|
||||
- [ ] Every major bullet states both change and user/operator impact
|
||||
- [ ] Security and reliability updates are explicitly surfaced (when present)
|
||||
- [ ] Contributor credits and compare range are included
|
||||
- [ ] All numbers and claims are verifiable
|
||||
|
||||
### Hotfix
|
||||
|
||||
- [ ] `**Hotfix Scope:**` line replaces metrics line
|
||||
- [ ] Single quoted thesis describes what is restored (operator-facing, not internal)
|
||||
- [ ] `## 🐛 What's Fixed` has 1-3 bullets, each `**<symptom>** — <fix>. (#PR)` with PR ref verified to exist and be merged
|
||||
- [ ] `## ⚙️ Upgrade` notes self-hosted action and cloud auto-apply
|
||||
- [ ] `## 👥 Owner` is a single `@handle` resolved via `gh pr view "$PR" --json author`
|
||||
- [ ] No Highlights / domain blocks / Contributors / Full Changelog included
|
||||
@@ -174,9 +174,64 @@ export const chatGroupAction: StateCreator<
|
||||
- `ChatGroupStoreWithRefresh` for member refresh
|
||||
- `ChatGroupStoreWithInternal` for curd `internal_dispatchChatGroup`
|
||||
|
||||
### Slices That Don't Currently Need `set`
|
||||
|
||||
When a slice doesn't write local state at the moment — e.g. it reads context
|
||||
from `#get()` and forwards calls to another store, or just runs hooks — drop
|
||||
the `#set` field. Otherwise ESLint's `no-unused-vars` flags the unused private
|
||||
field.
|
||||
|
||||
Mark the constructor's `set` param as `_set` and `void _set` it to keep the
|
||||
`(set, get, api)` shape aligned with `StateCreator`. This is **a snapshot of
|
||||
the current need, not a permanent contract** — if a later change needs `set`,
|
||||
restore the `#set` field and use it; do not invent a workaround to keep the
|
||||
"unused" form.
|
||||
|
||||
```ts
|
||||
type Setter = StoreSetter<ConversationStore>;
|
||||
|
||||
export const toolSlice = (set: Setter, get: () => ConversationStore, _api?: unknown) =>
|
||||
new ToolActionImpl(set, get, _api);
|
||||
|
||||
export class ToolActionImpl {
|
||||
readonly #get: () => ConversationStore;
|
||||
|
||||
// Mark unused params with `_` prefix and `void _x` so the constructor still
|
||||
// matches StateCreator's `(set, get, api)` shape without triggering unused
|
||||
// diagnostics.
|
||||
constructor(_set: Setter, get: () => ConversationStore, _api?: unknown) {
|
||||
void _set;
|
||||
void _api;
|
||||
this.#get = get;
|
||||
}
|
||||
|
||||
approveToolCall = async (id: string) => {
|
||||
const { context, hooks } = this.#get();
|
||||
await useChatStore.getState().approveToolCalling(id, '', context);
|
||||
hooks.onToolCallComplete?.(id, undefined);
|
||||
};
|
||||
}
|
||||
|
||||
export type ToolAction = Pick<ToolActionImpl, keyof ToolActionImpl>;
|
||||
```
|
||||
|
||||
Rules of thumb:
|
||||
|
||||
- If a slice doesn't currently call `set`, drop `#set` (use `_set` + `void _set`
|
||||
in the constructor). When a later edit needs `set`, restore `#set` and use it.
|
||||
- Don't add `setNamespace` for slices that don't write state. Add it when the
|
||||
slice starts writing state.
|
||||
- Never leave `#set` declared but unused "for future use" — lint will fail and
|
||||
re-adding it later costs nothing.
|
||||
|
||||
### Do / Don't
|
||||
|
||||
- **Do**: keep constructor signature aligned with `StateCreator` params `(set, get, api)`.
|
||||
- **Do**: use `#private` to avoid `set/get` being exposed.
|
||||
- **Do**: use `flattenActions` instead of spreading class instances.
|
||||
- **Do**: drop `#set` (and use `_set` + `void _set` in the constructor) for
|
||||
delegate-only slices that never write state — keeps lint green without
|
||||
breaking the `(set, get, api)` shape.
|
||||
- **Don't**: keep both old slice objects and class actions active at the same time.
|
||||
- **Don't**: keep an unused `#set` field "for future use" — it fails ESLint and
|
||||
re-adding it later costs nothing.
|
||||
|
||||
+20
-11
@@ -56,7 +56,6 @@ OPENAI_API_KEY=sk-xxxxxxxxx
|
||||
# add your custom model name, multi model separate by comma. for example gpt-3.5-1106,gpt-4-1106
|
||||
# OPENAI_MODEL_LIST=gpt-3.5-turbo
|
||||
|
||||
|
||||
# ## Azure OpenAI ###
|
||||
|
||||
# you can learn azure OpenAI Service on https://learn.microsoft.com/en-us/azure/ai-services/openai/overview
|
||||
@@ -71,7 +70,6 @@ OPENAI_API_KEY=sk-xxxxxxxxx
|
||||
# Azure's API version, follows the YYYY-MM-DD format
|
||||
# AZURE_API_VERSION=2024-10-21
|
||||
|
||||
|
||||
# ## Anthropic Service ####
|
||||
|
||||
# ANTHROPIC_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
@@ -79,19 +77,16 @@ OPENAI_API_KEY=sk-xxxxxxxxx
|
||||
# use a proxy to connect to the Anthropic API
|
||||
# ANTHROPIC_PROXY_URL=https://api.anthropic.com
|
||||
|
||||
|
||||
# ## Google AI ####
|
||||
|
||||
# GOOGLE_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
|
||||
# ## AWS Bedrock ###
|
||||
|
||||
# AWS_REGION=us-east-1
|
||||
# AWS_ACCESS_KEY_ID=xxxxxxxxxxxxxxxxxxx
|
||||
# AWS_SECRET_ACCESS_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
|
||||
# ## Ollama AI ####
|
||||
|
||||
# You can use ollama to get and run LLM locally, learn more about it via https://github.com/ollama/ollama
|
||||
@@ -101,13 +96,11 @@ OPENAI_API_KEY=sk-xxxxxxxxx
|
||||
|
||||
# OLLAMA_MODEL_LIST=your_ollama_model_names
|
||||
|
||||
|
||||
# ## OpenRouter Service ###
|
||||
|
||||
# OPENROUTER_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
# OPENROUTER_MODEL_LIST=model1,model2,model3
|
||||
|
||||
|
||||
# ## Mistral AI ###
|
||||
|
||||
# MISTRAL_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
@@ -168,7 +161,6 @@ OPENAI_API_KEY=sk-xxxxxxxxx
|
||||
|
||||
# SILICONCLOUD_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
|
||||
# ## TencentCloud AI ####
|
||||
|
||||
# TENCENT_CLOUD_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
@@ -181,7 +173,6 @@ OPENAI_API_KEY=sk-xxxxxxxxx
|
||||
|
||||
# INFINIAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
|
||||
# ## 302.AI ###
|
||||
|
||||
# AI302_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
@@ -222,7 +213,6 @@ OPENAI_API_KEY=sk-xxxxxxxxx
|
||||
|
||||
# VERCELAIGATEWAY_API_KEY=your_vercel_ai_gateway_api_key
|
||||
|
||||
|
||||
# #######################################
|
||||
# ########### Market Service ############
|
||||
# #######################################
|
||||
@@ -283,7 +273,6 @@ OPENAI_API_KEY=sk-xxxxxxxxx
|
||||
# but some service providers may require configuration
|
||||
# S3_REGION=us-west-1
|
||||
|
||||
|
||||
# #######################################
|
||||
# ########### Auth Service ##############
|
||||
# #######################################
|
||||
@@ -424,3 +413,23 @@ OPENAI_API_KEY=sk-xxxxxxxxx
|
||||
# MESSAGE_GATEWAY_ENABLED=1
|
||||
# MESSAGE_GATEWAY_URL=https://message-gateway.lobehub.com
|
||||
# MESSAGE_GATEWAY_SERVICE_TOKEN=your_service_token_here
|
||||
|
||||
# #######################################
|
||||
# ########### Messenger Bot #############
|
||||
# #######################################
|
||||
|
||||
# LobeHub-operated bots that users link their account to once and then chat
|
||||
# with any of their agents from. Credentials (Telegram / Slack / Discord) are
|
||||
# now managed in dc-center → Agent → System Bots and stored in the
|
||||
# `system_bot_providers` table. See docs/development/messenger/managed-by-dc-center.md.
|
||||
#
|
||||
# Webhook URLs are registered against APP_URL:
|
||||
# Telegram: <APP_URL>/api/agent/messenger/webhooks/telegram
|
||||
# Slack: <APP_URL>/api/agent/messenger/webhooks/slack
|
||||
# Discord: <APP_URL>/api/agent/messenger/webhooks/discord
|
||||
#
|
||||
# For local dev with bot platforms, point APP_URL at your tunnel
|
||||
# (ngrok / cloudflared) so platforms can reach your machine.
|
||||
|
||||
# Verify-im link token TTL in seconds (default 1800 = 30 min)
|
||||
# LOBE_LINK_TOKEN_TTL_SECONDS=1800
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Test Packages
|
||||
env:
|
||||
PACKAGES: '@lobechat/file-loaders @lobechat/prompts @lobechat/model-runtime @lobechat/web-crawler @lobechat/electron-server-ipc @lobechat/utils @lobechat/python-interpreter @lobechat/context-engine @lobechat/agent-runtime @lobechat/conversation-flow @lobechat/ssrf-safe-fetch @lobechat/memory-user-memory model-bank'
|
||||
PACKAGES: '@lobechat/file-loaders @lobechat/prompts @lobechat/model-runtime @lobechat/web-crawler @lobechat/electron-server-ipc @lobechat/utils @lobechat/python-interpreter @lobechat/context-engine @lobechat/agent-runtime @lobechat/conversation-flow @lobechat/ssrf-safe-fetch @lobechat/memory-user-memory @lobechat/types @lobechat/builtin-tool-lobe-agent model-bank'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -148,3 +148,6 @@ apps/desktop/resources/cli-package.json
|
||||
.superpowers/
|
||||
docs/superpowers/
|
||||
.heerogeneous-tracing
|
||||
|
||||
# Kagura agent runtime
|
||||
.kagura/
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ module.exports = defineConfig({
|
||||
],
|
||||
temperature: 0,
|
||||
saveImmediately: true,
|
||||
modelName: 'gpt-5.1-chat-latest',
|
||||
modelName: 'gpt-4o',
|
||||
experimental: {
|
||||
jsonMode: true,
|
||||
},
|
||||
|
||||
@@ -2,6 +2,81 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
### [Version 2.1.56](https://github.com/lobehub/lobe-chat/compare/v2.1.55...v2.1.56)
|
||||
|
||||
<sup>Released on **2026-05-01**</sup>
|
||||
|
||||
#### 👷 Build System
|
||||
|
||||
- **database**: add `metadata` and `trigger` to `briefs` table.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### Build System
|
||||
|
||||
- **database**: add `metadata` and `trigger` to `briefs` table, closes [#14354](https://github.com/lobehub/lobe-chat/issues/14354) ([86a23b5](https://github.com/lobehub/lobe-chat/commit/86a23b5))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 2.1.55](https://github.com/lobehub/lobe-chat/compare/v2.1.54...v2.1.55)
|
||||
|
||||
<sup>Released on **2026-04-29**</sup>
|
||||
|
||||
#### 🐛 Bug Fixes
|
||||
|
||||
- **chat**: preserve topics across cold route sends.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's fixed
|
||||
|
||||
- **chat**: preserve topics across cold route sends, closes [#14284](https://github.com/lobehub/lobe-chat/issues/14284) ([b8fe675](https://github.com/lobehub/lobe-chat/commit/b8fe675))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 2.1.54](https://github.com/lobehub/lobe-chat/compare/v2.1.53...v2.1.54)
|
||||
|
||||
<sup>Released on **2026-04-27**</sup>
|
||||
|
||||
#### 🐛 Bug Fixes
|
||||
|
||||
- **misc**: clear stale topic when switching agents from a topic route.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's fixed
|
||||
|
||||
- **misc**: clear stale topic when switching agents from a topic route, closes [#14231](https://github.com/lobehub/lobe-chat/issues/14231) ([deeb97a](https://github.com/lobehub/lobe-chat/commit/deeb97a))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 2.1.52](https://github.com/lobehub/lobe-chat/compare/v2.1.51...v2.1.52)
|
||||
|
||||
<sup>Released on **2026-04-20**</sup>
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ RUN set -e && \
|
||||
pnpm i && \
|
||||
mkdir -p /deps && \
|
||||
cd /deps && \
|
||||
pnpm init && \
|
||||
echo '{"name":"deps","private":true}' > package.json && \
|
||||
pnpm add pg drizzle-orm
|
||||
|
||||
COPY . .
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
|
||||
.\" Manual command details come from the Commander command tree.
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.8" "User Commands"
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.14" "User Commands"
|
||||
.SH NAME
|
||||
lh \- LobeHub CLI \- manage and connect to LobeHub services
|
||||
.SH SYNOPSIS
|
||||
@@ -77,6 +77,9 @@ Generate content (text, image, video, speech) Alias: gen.
|
||||
.B file
|
||||
Manage files
|
||||
.TP
|
||||
.B hetero
|
||||
Run heterogeneous agent CLIs (Claude Code / Codex) and stream their output
|
||||
.TP
|
||||
.B skill
|
||||
Manage agent skills
|
||||
.TP
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lobehub/cli",
|
||||
"version": "0.0.8",
|
||||
"version": "0.0.14",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"lh": "./dist/index.js",
|
||||
@@ -30,6 +30,7 @@
|
||||
"devDependencies": {
|
||||
"@lobechat/agent-gateway-client": "workspace:*",
|
||||
"@lobechat/device-gateway-client": "workspace:*",
|
||||
"@lobechat/heterogeneous-agents": "workspace:*",
|
||||
"@lobechat/local-file-shell": "workspace:*",
|
||||
"@trpc/client": "^11.8.1",
|
||||
"@types/node": "^22.13.5",
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
packages:
|
||||
- '../../packages/agent-gateway-client'
|
||||
- '../../packages/device-gateway-client'
|
||||
- '../../packages/heterogeneous-agents'
|
||||
- '../../packages/local-file-shell'
|
||||
- '../../packages/types'
|
||||
- '../../packages/model-bank'
|
||||
- '../../packages/business/const'
|
||||
- '../../packages/file-loaders'
|
||||
- '.'
|
||||
|
||||
@@ -39,7 +39,6 @@ const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
agentSkills: {
|
||||
createSkill: { mutate: vi.fn() },
|
||||
deleteSkill: { mutate: vi.fn() },
|
||||
promoteSkill: { mutate: vi.fn() },
|
||||
updateSkill: { mutate: vi.fn() },
|
||||
},
|
||||
aiAgent: {
|
||||
@@ -1036,12 +1035,12 @@ describe('agent command', () => {
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
mode: 8,
|
||||
name: 'agent-topic',
|
||||
path: './lobe/skills/agent-topic',
|
||||
name: 'builtin',
|
||||
path: './lobe/skills/builtin',
|
||||
type: 'directory',
|
||||
},
|
||||
])
|
||||
.mockRejectedValueOnce(new Error('Topic ID is required for the agent-topic namespace'));
|
||||
.mockRejectedValueOnce(new Error('Failed to list builtin skills'));
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
@@ -1063,12 +1062,10 @@ describe('agent command', () => {
|
||||
});
|
||||
expect(mockTrpcClient.agentDocument.listDocumentsByPath.query).toHaveBeenNthCalledWith(2, {
|
||||
agentId: 'a1',
|
||||
path: './lobe/skills/agent-topic',
|
||||
path: './lobe/skills/builtin',
|
||||
topicId: undefined,
|
||||
});
|
||||
expect(log.warn).toHaveBeenCalledWith(
|
||||
'./lobe/skills/agent-topic: Topic ID is required for the agent-topic namespace',
|
||||
);
|
||||
expect(log.warn).toHaveBeenCalledWith('./lobe/skills/builtin: Failed to list builtin skills');
|
||||
});
|
||||
|
||||
it('should read SKILL.md when cat targets a skill directory alias', async () => {
|
||||
|
||||
@@ -14,7 +14,6 @@ const SKILL_FILE_NAME = 'SKILL.md';
|
||||
|
||||
const SKILL_NAMESPACE_PREFIXES = {
|
||||
'agent': './lobe/skills/agent/skills',
|
||||
'agent-topic': './lobe/skills/agent-topic/skills',
|
||||
'builtin': './lobe/skills/builtin/skills',
|
||||
'installed-active': './lobe/skills/installed/active/skills',
|
||||
'installed-all': './lobe/skills/installed/all/skills',
|
||||
@@ -26,8 +25,6 @@ const FS_PATH_ALIASES = {
|
||||
'skills': 'agent',
|
||||
'installed-active': 'installed-active',
|
||||
'installed-all': 'installed-all',
|
||||
'topic-skills': 'agent-topic',
|
||||
'topic': 'agent-topic',
|
||||
} as const;
|
||||
|
||||
type SkillFsNamespace = keyof typeof SKILL_NAMESPACE_PREFIXES;
|
||||
@@ -94,7 +91,7 @@ function resolveAgentFsPath(input = 'agent:/'): AgentFsResolvedPath {
|
||||
|
||||
if (!target) {
|
||||
exitWithError(
|
||||
`Unknown fs namespace "${aliasMatch[1]}". Use agent, skills, topic-skills, builtin, installed-all, or installed-active.`,
|
||||
`Unknown fs namespace "${aliasMatch[1]}". Use agent, skills, builtin, installed-all, or installed-active.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -156,12 +153,6 @@ function resolveAgentFsPath(input = 'agent:/'): AgentFsResolvedPath {
|
||||
};
|
||||
}
|
||||
|
||||
function requireTopicId(namespace: SkillFsNamespace | undefined, topicId?: string) {
|
||||
if (namespace === 'agent-topic' && !topicId) {
|
||||
exitWithError('--topic-id is required for agent-topic fs paths.');
|
||||
}
|
||||
}
|
||||
|
||||
function requireSkillNamespace(resolved: AgentFsResolvedPath): SkillFsNamespace {
|
||||
if (!resolved.namespace) {
|
||||
exitWithError(`Expected a skill namespace path, but received "${resolved.path}".`);
|
||||
@@ -191,8 +182,7 @@ function toDisplayPath(path: string) {
|
||||
for (const [namespace, prefix] of Object.entries(SKILL_NAMESPACE_PREFIXES) as Array<
|
||||
[SkillFsNamespace, string]
|
||||
>) {
|
||||
const alias =
|
||||
namespace === 'agent' ? 'skills' : namespace === 'agent-topic' ? 'topic-skills' : namespace;
|
||||
const alias = namespace === 'agent' ? 'skills' : namespace;
|
||||
if (path === prefix) return `${alias}:/`;
|
||||
if (path.startsWith(`${prefix}/`)) return `${alias}:/${path.slice(prefix.length + 1)}`;
|
||||
}
|
||||
@@ -316,7 +306,6 @@ async function getFsNode(client: AgentFsClient, context: AgentFsContext, path: s
|
||||
|
||||
async function readFsFile(client: AgentFsClient, context: AgentFsContext, inputPath: string) {
|
||||
const resolved = resolveAgentFsPath(inputPath);
|
||||
requireTopicId(resolved.namespace, context.topicId);
|
||||
|
||||
const readPath =
|
||||
resolved.skillName && !resolved.filePath
|
||||
@@ -349,7 +338,6 @@ async function writeFsFile(
|
||||
content: string,
|
||||
) {
|
||||
const resolved = resolveAgentFsPath(inputPath);
|
||||
requireTopicId(resolved.namespace, context.topicId);
|
||||
const existing = await getFsNode(
|
||||
client,
|
||||
context,
|
||||
@@ -376,7 +364,6 @@ async function mkdirFsPath(
|
||||
options?: { recursive?: boolean },
|
||||
) {
|
||||
const resolved = resolveAgentFsPath(inputPath);
|
||||
requireTopicId(resolved.namespace, context.topicId);
|
||||
|
||||
return client.agentDocument.mkdirDocumentByPath.mutate({
|
||||
agentId: context.agentId,
|
||||
@@ -393,7 +380,6 @@ async function deleteFsPath(
|
||||
options?: { force?: boolean; recursive?: boolean },
|
||||
) {
|
||||
const resolved = resolveAgentFsPath(inputPath);
|
||||
requireTopicId(resolved.namespace, context.topicId);
|
||||
|
||||
return client.agentDocument.deleteDocumentByPath.mutate({
|
||||
agentId: context.agentId,
|
||||
@@ -414,9 +400,6 @@ async function copyFsPath(
|
||||
const sourceResolved = resolveAgentFsPath(source);
|
||||
const destinationResolved = resolveAgentFsPath(destination);
|
||||
|
||||
requireTopicId(sourceResolved.namespace, context.topicId);
|
||||
requireTopicId(destinationResolved.namespace, context.topicId);
|
||||
|
||||
return client.agentDocument.copyDocumentByPath.mutate({
|
||||
agentId: context.agentId,
|
||||
force,
|
||||
@@ -436,9 +419,6 @@ async function renameFsPath(
|
||||
const sourceResolved = resolveAgentFsPath(source);
|
||||
const destinationResolved = resolveAgentFsPath(destination);
|
||||
|
||||
requireTopicId(sourceResolved.namespace, context.topicId);
|
||||
requireTopicId(destinationResolved.namespace, context.topicId);
|
||||
|
||||
return client.agentDocument.renameDocumentByPath.mutate({
|
||||
agentId: context.agentId,
|
||||
force,
|
||||
@@ -450,7 +430,6 @@ async function renameFsPath(
|
||||
|
||||
async function listTrashFsPath(client: AgentFsClient, context: AgentFsContext, inputPath?: string) {
|
||||
const resolved = resolveAgentFsPath(inputPath || 'agent:/');
|
||||
requireTopicId(resolved.namespace, context.topicId);
|
||||
|
||||
return (await client.agentDocument.listTrashDocumentsByPath.query({
|
||||
agentId: context.agentId,
|
||||
@@ -465,7 +444,6 @@ async function restoreTrashFsPath(
|
||||
inputPath: string,
|
||||
) {
|
||||
const resolved = resolveAgentFsPath(inputPath);
|
||||
requireTopicId(resolved.namespace, context.topicId);
|
||||
|
||||
return client.agentDocument.restoreDocumentFromTrashByPath.mutate({
|
||||
agentId: context.agentId,
|
||||
@@ -481,7 +459,6 @@ async function deleteTrashFsPath(
|
||||
options?: { force?: boolean; recursive?: boolean },
|
||||
) {
|
||||
const resolved = resolveAgentFsPath(inputPath);
|
||||
requireTopicId(resolved.namespace, context.topicId);
|
||||
|
||||
return client.agentDocument.deleteDocumentPermanentlyByPath.mutate({
|
||||
agentId: context.agentId,
|
||||
@@ -531,7 +508,6 @@ function registerFsCommands(fsCommand: Command) {
|
||||
.option('-l, --long', 'Use long listing format')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.option('-t, --topic-id <id>', 'Topic ID for agent-topic paths')
|
||||
.option('--cursor <cursor>', 'Directory pagination cursor')
|
||||
.option('-L, --limit <n>', 'Maximum number of entries')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
@@ -552,7 +528,6 @@ function registerFsCommands(fsCommand: Command) {
|
||||
const client = await getTrpcClient();
|
||||
const context = await resolveAgentFsContext(client, options);
|
||||
const resolved = resolveAgentFsPath(inputPath || 'agent:/');
|
||||
requireTopicId(resolved.namespace, context.topicId);
|
||||
|
||||
const nodes = ((await client.agentDocument.listDocumentsByPath.query({
|
||||
agentId: context.agentId,
|
||||
@@ -590,7 +565,6 @@ function registerFsCommands(fsCommand: Command) {
|
||||
.description('Print a tree view of the VFS')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.option('-t, --topic-id <id>', 'Topic ID for agent-topic paths')
|
||||
.action(
|
||||
async (
|
||||
inputPath: string | undefined,
|
||||
@@ -599,7 +573,6 @@ function registerFsCommands(fsCommand: Command) {
|
||||
const client = await getTrpcClient();
|
||||
const context = await resolveAgentFsContext(client, options);
|
||||
const resolved = resolveAgentFsPath(inputPath || 'agent:/');
|
||||
requireTopicId(resolved.namespace, context.topicId);
|
||||
|
||||
console.log(pc.bold(toDisplayPath(resolved.path)));
|
||||
const warnings: string[] = [];
|
||||
@@ -616,7 +589,6 @@ function registerFsCommands(fsCommand: Command) {
|
||||
.description('Read a VFS file')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.option('-t, --topic-id <id>', 'Topic ID for agent-topic paths')
|
||||
.action(
|
||||
async (inputPath: string, options: { agentId?: string; slug?: string; topicId?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
@@ -631,7 +603,6 @@ function registerFsCommands(fsCommand: Command) {
|
||||
.description('Show VFS node metadata')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.option('-t, --topic-id <id>', 'Topic ID for agent-topic paths')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(
|
||||
async (
|
||||
@@ -646,7 +617,6 @@ function registerFsCommands(fsCommand: Command) {
|
||||
const client = await getTrpcClient();
|
||||
const context = await resolveAgentFsContext(client, options);
|
||||
const resolved = resolveAgentFsPath(inputPath);
|
||||
requireTopicId(resolved.namespace, context.topicId);
|
||||
|
||||
const node = await getFsNode(client, context, resolved.path);
|
||||
|
||||
@@ -669,7 +639,6 @@ function registerFsCommands(fsCommand: Command) {
|
||||
.description('Create or update a VFS file')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.option('-t, --topic-id <id>', 'Topic ID for agent-topic paths')
|
||||
.option('-c, --content <content>', 'File content')
|
||||
.option('-F, --content-file <path>', 'Read content from a local file')
|
||||
.action(
|
||||
@@ -696,7 +665,6 @@ function registerFsCommands(fsCommand: Command) {
|
||||
.description('Write content to a VFS file')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.option('-t, --topic-id <id>', 'Topic ID for agent-topic paths')
|
||||
.option('-c, --content <content>', 'File content')
|
||||
.option('-F, --content-file <path>', 'Read content from a local file')
|
||||
.action(
|
||||
@@ -723,7 +691,6 @@ function registerFsCommands(fsCommand: Command) {
|
||||
.description('Create a VFS directory')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.option('-t, --topic-id <id>', 'Topic ID for agent-topic paths')
|
||||
.option('-p, --parents', 'Create parent directories as needed')
|
||||
.action(
|
||||
async (
|
||||
@@ -746,7 +713,6 @@ function registerFsCommands(fsCommand: Command) {
|
||||
.description('Delete a VFS node into trash')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.option('-t, --topic-id <id>', 'Topic ID for agent-topic paths')
|
||||
.option('-r, --recursive', 'Recursively delete a directory subtree')
|
||||
.option('-f, --force', 'Forward force semantics to the VFS delete primitive')
|
||||
.option('--yes', 'Skip confirmation prompt')
|
||||
@@ -785,7 +751,6 @@ function registerFsCommands(fsCommand: Command) {
|
||||
.description('Copy a VFS node')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.option('-t, --topic-id <id>', 'Topic ID for agent-topic source or destination paths')
|
||||
.option('-f, --force', 'Overwrite the destination if it exists')
|
||||
.action(
|
||||
async (
|
||||
@@ -807,7 +772,6 @@ function registerFsCommands(fsCommand: Command) {
|
||||
.description('Move or rename a VFS node')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.option('-t, --topic-id <id>', 'Topic ID for agent-topic source or destination paths')
|
||||
.option('-f, --force', 'Overwrite the destination if it exists')
|
||||
.action(
|
||||
async (
|
||||
@@ -839,7 +803,6 @@ function registerFsCommands(fsCommand: Command) {
|
||||
.description('List trashed VFS nodes')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.option('-t, --topic-id <id>', 'Topic ID for agent-topic paths')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(
|
||||
async (
|
||||
@@ -875,7 +838,6 @@ function registerFsCommands(fsCommand: Command) {
|
||||
.description('Restore a soft-deleted VFS node')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.option('-t, --topic-id <id>', 'Topic ID for agent-topic paths')
|
||||
.action(
|
||||
async (inputPath: string, options: { agentId?: string; slug?: string; topicId?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
@@ -892,7 +854,6 @@ function registerFsCommands(fsCommand: Command) {
|
||||
.description('Permanently delete a trashed VFS node')
|
||||
.option('-A, --agent-id <id>', 'Agent ID')
|
||||
.option('-s, --slug <slug>', 'Agent slug')
|
||||
.option('-t, --topic-id <id>', 'Topic ID for agent-topic paths')
|
||||
.option('-r, --recursive', 'Recursively delete a directory subtree')
|
||||
.option('-f, --force', 'Forward force semantics to the permanent delete primitive')
|
||||
.option('--yes', 'Skip confirmation prompt')
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { registerHeteroCommand } from './hetero';
|
||||
|
||||
const { mockSpawnAgent } = vi.hoisted(() => ({
|
||||
mockSpawnAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@lobechat/heterogeneous-agents/spawn', () => ({
|
||||
spawnAgent: mockSpawnAgent,
|
||||
}));
|
||||
|
||||
vi.mock('../utils/logger', () => ({
|
||||
log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() },
|
||||
setVerbose: vi.fn(),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Build a Promise resolving to a fake `SpawnAgentHandle`. `spawnAgent` itself
|
||||
* is async, so test mocks return the handle wrapped — same iterable contract,
|
||||
* just behind one microtask. The async iterable yields `events` synchronously
|
||||
* and ends, so the command's `for await (const event of ...)` loop terminates
|
||||
* without hanging the test.
|
||||
*/
|
||||
const createFakeHandle = ({
|
||||
events = [] as any[],
|
||||
exitCode = 0,
|
||||
signal = null as NodeJS.Signals | null,
|
||||
stderrChunks = [] as string[],
|
||||
}: {
|
||||
events?: any[];
|
||||
exitCode?: number | null;
|
||||
signal?: NodeJS.Signals | null;
|
||||
stderrChunks?: string[];
|
||||
} = {}) => {
|
||||
const stderr = new PassThrough();
|
||||
setImmediate(() => {
|
||||
for (const c of stderrChunks) stderr.write(c);
|
||||
stderr.end();
|
||||
});
|
||||
|
||||
const eventsIter: AsyncIterable<any> = {
|
||||
[Symbol.asyncIterator]() {
|
||||
let i = 0;
|
||||
return {
|
||||
async next() {
|
||||
if (i < events.length) return { done: false, value: events[i++] };
|
||||
return { done: true, value: undefined };
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return Promise.resolve({
|
||||
events: eventsIter,
|
||||
exit: Promise.resolve({ code: exitCode, signal }),
|
||||
kill: vi.fn(),
|
||||
pid: 12_345,
|
||||
stderr,
|
||||
});
|
||||
};
|
||||
|
||||
describe('hetero exec command', () => {
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
let stdoutSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Stub `process.exit` so the test runner doesn't tear down — but THROW a
|
||||
// sentinel rather than return, mirroring `process.exit`'s `never` return
|
||||
// type in production. Without throwing, the command's code after an
|
||||
// `exit(2)` keeps running and crashes on `handle.stderr` (no spawn mock).
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
||||
throw new Error(`__exit__${code}`);
|
||||
}) as any);
|
||||
stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
||||
mockSpawnAgent.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
stdoutSpy.mockRestore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/** Build a fresh program with the hetero command registered. */
|
||||
const buildProgram = () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerHeteroCommand(program);
|
||||
return program;
|
||||
};
|
||||
|
||||
/**
|
||||
* Run the parsed command. Swallows our `__exit__<code>` sentinel so tests
|
||||
* can inspect `exitSpy.mock.calls` afterwards instead of having to wrap
|
||||
* every `parseAsync` in `expect(...).rejects`. Real production exits stay
|
||||
* `process.exit` so this only affects the test path.
|
||||
*/
|
||||
const runCmd = async (argv: string[]) => {
|
||||
try {
|
||||
await buildProgram().parseAsync(argv, { from: 'user' });
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.startsWith('__exit__')) return;
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
it('rejects unsupported agent types via process.exit(2)', async () => {
|
||||
await runCmd(['hetero', 'exec', '--type', 'kimi-cli', '--prompt', 'hi']);
|
||||
expect(exitSpy).toHaveBeenCalledWith(2);
|
||||
expect(mockSpawnAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects empty prompts via process.exit(2)', async () => {
|
||||
await runCmd(['hetero', 'exec', '--type', 'claude-code', '--prompt', ' ']);
|
||||
expect(exitSpy).toHaveBeenCalledWith(2);
|
||||
expect(mockSpawnAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes --type / --prompt / --resume / --cwd / --command through to spawnAgent', async () => {
|
||||
mockSpawnAgent.mockReturnValue(createFakeHandle());
|
||||
|
||||
await runCmd([
|
||||
'hetero',
|
||||
'exec',
|
||||
'--type',
|
||||
'codex',
|
||||
'--prompt',
|
||||
'do thing',
|
||||
'--resume',
|
||||
'thread_abc',
|
||||
'--cwd',
|
||||
'/tmp/work',
|
||||
'--command',
|
||||
'/usr/local/bin/codex',
|
||||
]);
|
||||
|
||||
expect(mockSpawnAgent).toHaveBeenCalledTimes(1);
|
||||
const call = mockSpawnAgent.mock.calls[0][0];
|
||||
expect(call).toMatchObject({
|
||||
agentType: 'codex',
|
||||
command: '/usr/local/bin/codex',
|
||||
cwd: '/tmp/work',
|
||||
prompt: 'do thing',
|
||||
resumeSessionId: 'thread_abc',
|
||||
});
|
||||
// operationId auto-generated when omitted (uuid v4 shape)
|
||||
expect(call.operationId).toMatch(/^[0-9a-f-]{36}$/i);
|
||||
});
|
||||
|
||||
it('uses the provided --operation-id verbatim', async () => {
|
||||
mockSpawnAgent.mockReturnValue(createFakeHandle());
|
||||
|
||||
await runCmd([
|
||||
'hetero',
|
||||
'exec',
|
||||
'--type',
|
||||
'claude-code',
|
||||
'--prompt',
|
||||
'hi',
|
||||
'--operation-id',
|
||||
'op-server-allocated',
|
||||
]);
|
||||
|
||||
const call = mockSpawnAgent.mock.calls[0][0];
|
||||
expect(call.operationId).toBe('op-server-allocated');
|
||||
});
|
||||
|
||||
it('streams events to stdout as JSONL, one line per event', async () => {
|
||||
const events = [
|
||||
{ data: { foo: 1 }, operationId: 'op-1', stepIndex: 0, timestamp: 1, type: 'stream_start' },
|
||||
{
|
||||
data: { chunkType: 'text', content: 'hi' },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 2,
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
];
|
||||
mockSpawnAgent.mockReturnValue(createFakeHandle({ events }));
|
||||
|
||||
await runCmd([
|
||||
'hetero',
|
||||
'exec',
|
||||
'--type',
|
||||
'claude-code',
|
||||
'--prompt',
|
||||
'hi',
|
||||
'--operation-id',
|
||||
'op-1',
|
||||
]);
|
||||
|
||||
// Each event is one JSON line with a trailing \n.
|
||||
const lines = stdoutSpy.mock.calls.map((c) => c[0]).filter((s) => typeof s === 'string');
|
||||
expect(lines).toHaveLength(2);
|
||||
for (const line of lines as string[]) {
|
||||
expect(line.endsWith('\n')).toBe(true);
|
||||
const parsed = JSON.parse(line);
|
||||
expect(parsed.operationId).toBe('op-1');
|
||||
}
|
||||
});
|
||||
|
||||
it('passes the child exit code straight through', async () => {
|
||||
mockSpawnAgent.mockReturnValue(createFakeHandle({ exitCode: 7 }));
|
||||
|
||||
await runCmd(['hetero', 'exec', '--type', 'claude-code', '--prompt', 'hi']);
|
||||
expect(exitSpy).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
it('maps SIGINT (code === null) to POSIX exit code 130', async () => {
|
||||
mockSpawnAgent.mockReturnValue(createFakeHandle({ exitCode: null, signal: 'SIGINT' }));
|
||||
|
||||
await runCmd(['hetero', 'exec', '--type', 'claude-code', '--prompt', 'hi']);
|
||||
expect(exitSpy).toHaveBeenCalledWith(130);
|
||||
});
|
||||
|
||||
it('combines --prompt + --image into mixed content blocks', async () => {
|
||||
mockSpawnAgent.mockReturnValue(createFakeHandle());
|
||||
|
||||
await runCmd([
|
||||
'hetero',
|
||||
'exec',
|
||||
'--type',
|
||||
'claude-code',
|
||||
'--prompt',
|
||||
'describe',
|
||||
'--image',
|
||||
'./fixture-a.png',
|
||||
'--image',
|
||||
'https://cdn.example/fixture-b.png',
|
||||
]);
|
||||
|
||||
const call = mockSpawnAgent.mock.calls[0][0];
|
||||
expect(Array.isArray(call.prompt)).toBe(true);
|
||||
expect(call.prompt).toEqual([
|
||||
{ text: 'describe', type: 'text' },
|
||||
// Path is resolved against process.cwd() — match by suffix to be CI-portable.
|
||||
{
|
||||
source: expect.objectContaining({ type: 'path' }),
|
||||
type: 'image',
|
||||
},
|
||||
{
|
||||
source: { type: 'url', url: 'https://cdn.example/fixture-b.png' },
|
||||
type: 'image',
|
||||
},
|
||||
]);
|
||||
expect(call.prompt[1].source.path).toMatch(/fixture-a\.png$/);
|
||||
});
|
||||
|
||||
it('parses a data: URL --image into a base64 source', async () => {
|
||||
mockSpawnAgent.mockReturnValue(createFakeHandle());
|
||||
|
||||
const dataUrl = `data:image/png;base64,${Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString('base64')}`;
|
||||
await runCmd([
|
||||
'hetero',
|
||||
'exec',
|
||||
'--type',
|
||||
'claude-code',
|
||||
'--prompt',
|
||||
'see',
|
||||
'--image',
|
||||
dataUrl,
|
||||
]);
|
||||
|
||||
const call = mockSpawnAgent.mock.calls[0][0];
|
||||
expect(call.prompt[1]).toEqual({
|
||||
source: {
|
||||
data: Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString('base64'),
|
||||
mediaType: 'image/png',
|
||||
type: 'base64',
|
||||
},
|
||||
type: 'image',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads multimodal content from --input-json <file>', async () => {
|
||||
const { mkdtemp, writeFile, rm } = await import('node:fs/promises');
|
||||
const { tmpdir } = await import('node:os');
|
||||
const path = await import('node:path');
|
||||
const dir = await mkdtemp(`${tmpdir()}/hetero-input-json-`);
|
||||
const file = path.join(dir, 'input.json');
|
||||
await writeFile(
|
||||
file,
|
||||
JSON.stringify([
|
||||
{ text: 'analyze', type: 'text' },
|
||||
{ source: { type: 'url', url: 'https://x/y.png' }, type: 'image' },
|
||||
]),
|
||||
);
|
||||
|
||||
mockSpawnAgent.mockReturnValue(createFakeHandle());
|
||||
try {
|
||||
await runCmd(['hetero', 'exec', '--type', 'claude-code', '--input-json', file]);
|
||||
} finally {
|
||||
await rm(dir, { force: true, recursive: true });
|
||||
}
|
||||
|
||||
const call = mockSpawnAgent.mock.calls[0][0];
|
||||
expect(call.prompt).toEqual([
|
||||
{ text: 'analyze', type: 'text' },
|
||||
{ source: { type: 'url', url: 'https://x/y.png' }, type: 'image' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports spawnAgent rejections (e.g. missing --image path) as a clean error + exit(1)', async () => {
|
||||
// spawnAgent is now async and can reject during image normalization —
|
||||
// missing local --image paths, fetch failures, etc. The CLI must catch
|
||||
// these and exit with a friendly message instead of crashing on an
|
||||
// unhandled rejection.
|
||||
mockSpawnAgent.mockReturnValue(
|
||||
Promise.reject(new Error('ENOENT: no such file or directory, open /missing.png')),
|
||||
);
|
||||
|
||||
await runCmd([
|
||||
'hetero',
|
||||
'exec',
|
||||
'--type',
|
||||
'claude-code',
|
||||
'--prompt',
|
||||
'see',
|
||||
'--image',
|
||||
'/missing.png',
|
||||
]);
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('rejects --prompt + --input-json (mutually exclusive)', async () => {
|
||||
await runCmd([
|
||||
'hetero',
|
||||
'exec',
|
||||
'--type',
|
||||
'claude-code',
|
||||
'--prompt',
|
||||
'hi',
|
||||
'--input-json',
|
||||
'/tmp/bogus.json',
|
||||
]);
|
||||
expect(exitSpy).toHaveBeenCalledWith(2);
|
||||
expect(mockSpawnAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,380 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import type {
|
||||
AgentContentBlock,
|
||||
AgentImageSource,
|
||||
AgentPromptInput,
|
||||
} from '@lobechat/heterogeneous-agents/spawn';
|
||||
import { spawnAgent } from '@lobechat/heterogeneous-agents/spawn';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { BatchIngester, NoopIngestSink } from '../utils/BatchIngester';
|
||||
import { log } from '../utils/logger';
|
||||
import { TrpcIngestSink } from '../utils/TrpcIngestSink';
|
||||
|
||||
const SUPPORTED_AGENT_TYPES = new Set(['claude-code', 'codex']);
|
||||
|
||||
interface ExecOptions {
|
||||
command?: string;
|
||||
cwd?: string;
|
||||
image?: string[];
|
||||
inputJson?: string;
|
||||
operationId?: string;
|
||||
prompt?: string;
|
||||
/**
|
||||
* Output rendering mode.
|
||||
* jsonl — emit each `AgentStreamEvent` as a JSONL line on stdout (default
|
||||
* when no --topic is set, or when explicitly requested).
|
||||
* none — suppress JSONL stdout; only server-ingest mode is active.
|
||||
* Default when --topic is set and running non-interactively.
|
||||
*/
|
||||
render?: 'jsonl' | 'none';
|
||||
resume?: string;
|
||||
/**
|
||||
* Server topic id. When set, enables server-ingest mode: events are
|
||||
* batch-POSTed to `aiAgent.heteroIngest` in addition to (or instead of)
|
||||
* being written to stdout. Requires `--operation-id` to be a valid
|
||||
* server-allocated operation id.
|
||||
*/
|
||||
topic?: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
const collectImage = (value: string, previous: string[] = []): string[] => [...previous, value];
|
||||
|
||||
const readStdin = async (): Promise<string> => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : (chunk as Buffer));
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a raw `--input-json` argument: `'-'` (or empty) reads stdin, anything
|
||||
* else is treated as a filesystem path.
|
||||
*/
|
||||
const readInputJson = async (location: string): Promise<string> => {
|
||||
if (location === '-' || location === '') return readStdin();
|
||||
return readFile(location, 'utf8');
|
||||
};
|
||||
|
||||
const looksLikeJsonInput = (value: string): boolean => {
|
||||
const trimmed = value.trimStart();
|
||||
return trimmed.startsWith('{') || trimmed.startsWith('[');
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert an `--image <value>` argument into an image source. Recognized
|
||||
* shapes: `https?://...` URL, `data:` URL, otherwise a filesystem path
|
||||
* resolved relative to the CLI's cwd.
|
||||
*/
|
||||
const parseImageArg = (value: string): AgentImageSource => {
|
||||
if (/^https?:\/\//i.test(value)) return { type: 'url', url: value };
|
||||
if (value.startsWith('data:')) {
|
||||
const match = value.match(/^data:([^;,]+);base64,(.+)$/);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid data URL for --image: ${value.slice(0, 40)}…`);
|
||||
}
|
||||
return { data: match[2]!, mediaType: match[1]!, type: 'base64' };
|
||||
}
|
||||
return { path: path.resolve(process.cwd(), value), type: 'path' };
|
||||
};
|
||||
|
||||
/**
|
||||
* Best-effort coercion of a JSON-decoded value into an `AgentPromptInput`.
|
||||
* Accepts:
|
||||
* - `'plain text'` → single text block
|
||||
* - `[{ type: 'text', text }, { type: 'image', source }]` → content blocks
|
||||
* - `{ content: [...] }` (Anthropic message shape) → unwraps `content`
|
||||
* - `{ type: 'text', ... } | { type: 'image', ... }` → single block
|
||||
*/
|
||||
const coerceJsonPrompt = (parsed: unknown): AgentPromptInput => {
|
||||
if (typeof parsed === 'string') return parsed;
|
||||
if (Array.isArray(parsed)) return parsed as AgentContentBlock[];
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (Array.isArray(obj.content)) return obj.content as AgentContentBlock[];
|
||||
if (obj.type === 'text' || obj.type === 'image') return [obj as AgentContentBlock];
|
||||
}
|
||||
throw new Error(
|
||||
'Invalid --input-json shape: expected a string, array of content blocks, ' +
|
||||
'or `{ content: [...] }` envelope.',
|
||||
);
|
||||
};
|
||||
|
||||
interface ResolvedPrompt {
|
||||
/** Human-readable description for the empty-input check. */
|
||||
describe: () => string;
|
||||
prompt: AgentPromptInput;
|
||||
}
|
||||
|
||||
const buildPromptFromText = (text: string, images: string[]): ResolvedPrompt => {
|
||||
if (images.length === 0) {
|
||||
return { describe: () => text.trim(), prompt: text };
|
||||
}
|
||||
const blocks: AgentContentBlock[] = [];
|
||||
if (text.length > 0) blocks.push({ text, type: 'text' });
|
||||
for (const image of images) {
|
||||
blocks.push({ source: parseImageArg(image), type: 'image' });
|
||||
}
|
||||
return {
|
||||
describe: () =>
|
||||
blocks
|
||||
.map((b) => (b.type === 'text' ? b.text.trim() : '[image]'))
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.trim(),
|
||||
prompt: blocks,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Decide which input mode the user requested and produce a unified prompt.
|
||||
*
|
||||
* Mode resolution (mutually exclusive):
|
||||
* 1. `--input-json` → read JSON file or stdin, parse to content blocks
|
||||
* 2. `--prompt` (with optional `--image` flags) → text + images
|
||||
* 3. (default) read stdin: auto-detect JSON vs plain text by first char
|
||||
*/
|
||||
const resolvePrompt = async (options: ExecOptions): Promise<ResolvedPrompt> => {
|
||||
const images = options.image ?? [];
|
||||
|
||||
if (options.inputJson !== undefined) {
|
||||
if (options.prompt !== undefined) {
|
||||
throw new Error('--prompt and --input-json are mutually exclusive.');
|
||||
}
|
||||
if (images.length > 0) {
|
||||
throw new Error('--image cannot be combined with --input-json (put images in the JSON).');
|
||||
}
|
||||
const raw = await readInputJson(options.inputJson);
|
||||
return { describe: () => raw.trim(), prompt: coerceJsonPrompt(JSON.parse(raw)) };
|
||||
}
|
||||
|
||||
if (options.prompt !== undefined && options.prompt !== '-') {
|
||||
return buildPromptFromText(options.prompt, images);
|
||||
}
|
||||
|
||||
// No --prompt or --prompt -: read stdin and auto-detect.
|
||||
const raw = await readStdin();
|
||||
if (looksLikeJsonInput(raw)) {
|
||||
return { describe: () => raw.trim(), prompt: coerceJsonPrompt(JSON.parse(raw)) };
|
||||
}
|
||||
return buildPromptFromText(raw, images);
|
||||
};
|
||||
|
||||
const exec = async (options: ExecOptions): Promise<void> => {
|
||||
if (!SUPPORTED_AGENT_TYPES.has(options.type)) {
|
||||
log.error(
|
||||
`Unsupported --type "${options.type}". Supported: ${[...SUPPORTED_AGENT_TYPES].join(', ')}`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let resolved: ResolvedPrompt;
|
||||
try {
|
||||
resolved = await resolvePrompt(options);
|
||||
} catch (err) {
|
||||
log.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if (!resolved.describe()) {
|
||||
log.error(
|
||||
'Empty prompt. Pass --prompt <text>, --image <path>, --input-json <file|->, or pipe content via stdin.',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Server-ingest mode is active when --topic is provided.
|
||||
// --operation-id must be a server-allocated id in this mode (the server
|
||||
// generates it before spawning the process and passes it via CLI args).
|
||||
const serverIngest = !!options.topic;
|
||||
if (serverIngest && !options.operationId) {
|
||||
log.error('--operation-id is required when --topic is set (server-ingest mode).');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const operationId = options.operationId || randomUUID();
|
||||
|
||||
// Determine JSONL output mode.
|
||||
// Explicit --render flag always wins. Otherwise: emit JSONL in standalone
|
||||
// mode; suppress in server-ingest mode (sink handles the data path).
|
||||
const emitJsonl = options.render === 'jsonl' || (options.render === undefined && !serverIngest);
|
||||
|
||||
// Build the ingest sink — no-op for standalone mode, real tRPC sink for
|
||||
// server-ingest mode. The tRPC client reads LOBEHUB_JWT (operation-scoped
|
||||
// JWT injected by the server) for authentication.
|
||||
const agentType = options.type as 'claude-code' | 'codex';
|
||||
let sink: InstanceType<typeof TrpcIngestSink> | InstanceType<typeof NoopIngestSink>;
|
||||
if (serverIngest) {
|
||||
const client = await getTrpcClient();
|
||||
sink = new TrpcIngestSink(client, agentType, operationId, options.topic!);
|
||||
} else {
|
||||
sink = new NoopIngestSink();
|
||||
}
|
||||
const ingester = new BatchIngester(sink);
|
||||
|
||||
// `spawnAgent` is async and can reject DURING image normalization — fetch
|
||||
// failures, missing local --image paths, decode errors. Surface those as a
|
||||
// clean error + exit code instead of an unhandled promise rejection / stack
|
||||
// trace, mirroring the validation try/catch above.
|
||||
let handle: Awaited<ReturnType<typeof spawnAgent>>;
|
||||
try {
|
||||
handle = await spawnAgent({
|
||||
agentType: options.type,
|
||||
command: options.command,
|
||||
cwd: options.cwd || process.cwd(),
|
||||
operationId,
|
||||
prompt: resolved.prompt,
|
||||
resumeSessionId: options.resume,
|
||||
});
|
||||
} catch (err) {
|
||||
log.error('Failed to start agent:', err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Forward the child's stderr to ours so users see CLI errors / warnings
|
||||
// (auth prompts, missing-binary errors, etc.) in the terminal.
|
||||
handle.stderr.pipe(process.stderr);
|
||||
|
||||
// Ctrl-C → SIGINT to the child's process group so the spawned CLI gets a
|
||||
// chance to clean up. Repeated Ctrl-C escalates to SIGKILL via the
|
||||
// standard "double-tap" pattern most CLIs implement themselves.
|
||||
// In server-ingest mode, drain the ingester and call heteroFinish before
|
||||
// exiting so the server knows the operation was cancelled.
|
||||
let interrupted = false;
|
||||
const onSigint = async () => {
|
||||
if (interrupted) {
|
||||
handle.kill('SIGKILL');
|
||||
return;
|
||||
}
|
||||
interrupted = true;
|
||||
handle.kill('SIGINT');
|
||||
if (serverIngest) {
|
||||
try {
|
||||
await ingester.drain();
|
||||
await sink.finish({ result: 'cancelled' });
|
||||
} catch {
|
||||
// best-effort; process is exiting anyway
|
||||
}
|
||||
}
|
||||
};
|
||||
process.on('SIGINT', onSigint);
|
||||
process.on('SIGTERM', async () => {
|
||||
handle.kill('SIGTERM');
|
||||
if (serverIngest) {
|
||||
try {
|
||||
await ingester.drain();
|
||||
await sink.finish({ result: 'cancelled' });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Stream events. Each event is optionally written as JSONL and always
|
||||
// pushed into the ingester (which batches and sends to the server).
|
||||
let ingestError = false;
|
||||
try {
|
||||
for await (const event of handle.events) {
|
||||
if (emitJsonl) {
|
||||
process.stdout.write(`${JSON.stringify(event)}\n`);
|
||||
}
|
||||
ingester.push(event);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('Stream error from agent process:', err instanceof Error ? err.message : String(err));
|
||||
if (serverIngest) {
|
||||
try {
|
||||
await ingester.drain();
|
||||
await sink.finish({
|
||||
result: 'error',
|
||||
error: { message: String(err), type: 'stream_error' },
|
||||
});
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
process.exit(1);
|
||||
} finally {
|
||||
process.off('SIGINT', onSigint);
|
||||
}
|
||||
|
||||
// Pass the child's exit code through. In server-ingest mode, drain the
|
||||
// ingester and call heteroFinish before exiting.
|
||||
const { code, signal } = await handle.exit;
|
||||
|
||||
if (serverIngest) {
|
||||
try {
|
||||
await ingester.drain();
|
||||
} catch (err) {
|
||||
log.error(
|
||||
'Failed to flush events to server:',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
ingestError = true;
|
||||
}
|
||||
|
||||
const exitedClean = !ingestError && (code === 0 || signal === 'SIGTERM');
|
||||
try {
|
||||
await sink.finish({
|
||||
result: exitedClean ? 'success' : 'error',
|
||||
sessionId: handle.sessionId,
|
||||
});
|
||||
} catch (err) {
|
||||
log.error('Failed to send heteroFinish:', err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
if (code !== null) process.exit(ingestError ? 1 : code);
|
||||
if (signal === 'SIGINT') process.exit(130);
|
||||
if (signal === 'SIGTERM') process.exit(143);
|
||||
if (signal === 'SIGKILL') process.exit(137);
|
||||
process.exit(1);
|
||||
};
|
||||
|
||||
export function registerHeteroCommand(program: Command) {
|
||||
const hetero = program
|
||||
.command('hetero')
|
||||
.description('Run heterogeneous agent CLIs (Claude Code / Codex) and stream their output');
|
||||
|
||||
hetero
|
||||
.command('exec')
|
||||
.description(
|
||||
'Spawn a heterogeneous agent CLI and stream its events as JSONL on stdout. Standalone mode (no server ingest).',
|
||||
)
|
||||
.requiredOption('-t, --type <type>', `Agent type: ${[...SUPPORTED_AGENT_TYPES].join(' | ')}`)
|
||||
.option('-p, --prompt [text]', 'Prompt text. Pass `-` (or omit the value) to read from stdin.')
|
||||
.option(
|
||||
'-i, --image <path|url>',
|
||||
'Attach an image (repeatable). Accepts a local path, http(s) URL, or data: URL.',
|
||||
collectImage,
|
||||
)
|
||||
.option(
|
||||
'--input-json <path>',
|
||||
'Read full multimodal prompt as JSON content blocks from a file. Use `-` for stdin.',
|
||||
)
|
||||
.option('-r, --resume <sessionId>', 'Resume an existing agent session by its native id')
|
||||
.option('-d, --cwd <path>', 'Working directory for the spawned agent (default: process.cwd())')
|
||||
.option(
|
||||
'-c, --command <bin>',
|
||||
'Override the agent CLI binary name (default: `claude` or `codex`)',
|
||||
)
|
||||
.option(
|
||||
'--operation-id <id>',
|
||||
'Operation id stamped onto every emitted event. Required in server-ingest mode (--topic). Generated as a UUID if omitted (standalone).',
|
||||
)
|
||||
.option(
|
||||
'--topic <topicId>',
|
||||
'Server topic id. Enables server-ingest mode: events are batch-POSTed to aiAgent.heteroIngest. Requires --operation-id.',
|
||||
)
|
||||
.option(
|
||||
'--render <mode>',
|
||||
'Output mode: jsonl (emit events as JSONL on stdout) | none (suppress stdout). Defaults to jsonl in standalone, none in server-ingest mode.',
|
||||
)
|
||||
.action(exec);
|
||||
}
|
||||
@@ -83,6 +83,23 @@ describe('model command', () => {
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(models, null, 2));
|
||||
});
|
||||
|
||||
it('should filter hidden runtime-only models from JSON output', async () => {
|
||||
const visibleModels = [{ displayName: 'DeepSeek V4 Pro', id: 'deepseek-v4-pro' }];
|
||||
mockTrpcClient.aiModel.getAiProviderModelList.query.mockResolvedValue([
|
||||
...visibleModels,
|
||||
{
|
||||
displayName: 'LobeHub Onboarding',
|
||||
id: 'lobehub-onboarding-v1',
|
||||
visible: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'model', 'list', 'lobehub', '--json']);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(visibleModels, null, 2));
|
||||
});
|
||||
});
|
||||
|
||||
describe('view', () => {
|
||||
|
||||
@@ -5,6 +5,8 @@ import { getTrpcClient } from '../api/client';
|
||||
import { confirm, outputJson, printTable, truncate } from '../utils/format';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
const isVisibleModel = (model: { visible?: boolean }) => model.visible !== false;
|
||||
|
||||
export function registerModelCommand(program: Command) {
|
||||
const model = program.command('model').description('Manage AI models');
|
||||
|
||||
@@ -33,7 +35,9 @@ export function registerModelCommand(program: Command) {
|
||||
if (options.type) input.type = options.type;
|
||||
|
||||
const result = await client.aiModel.getAiProviderModelList.query(input as any);
|
||||
let items = Array.isArray(result) ? result : ((result as any).items ?? []);
|
||||
let items = (Array.isArray(result) ? result : ((result as any).items ?? [])).filter(
|
||||
isVisibleModel,
|
||||
);
|
||||
|
||||
if (options.type) {
|
||||
items = items.filter((m: any) => m.type === options.type);
|
||||
|
||||
@@ -14,6 +14,7 @@ import { registerDocCommand } from './commands/doc';
|
||||
import { registerEvalCommand } from './commands/eval';
|
||||
import { registerFileCommand } from './commands/file';
|
||||
import { registerGenerateCommand } from './commands/generate';
|
||||
import { registerHeteroCommand } from './commands/hetero';
|
||||
import { registerKbCommand } from './commands/kb';
|
||||
import { registerLoginCommand } from './commands/login';
|
||||
import { registerLogoutCommand } from './commands/logout';
|
||||
@@ -62,6 +63,7 @@ export function createProgram() {
|
||||
registerCronCommand(program);
|
||||
registerGenerateCommand(program);
|
||||
registerFileCommand(program);
|
||||
registerHeteroCommand(program);
|
||||
registerSkillCommand(program);
|
||||
registerSessionGroupCommand(program);
|
||||
registerTaskCommand(program);
|
||||
|
||||
@@ -27,22 +27,22 @@ describe('executeToolCall', () => {
|
||||
fs.rmSync(tmpDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
it('should dispatch readLocalFile', async () => {
|
||||
it('should dispatch readFile', async () => {
|
||||
const filePath = path.join(tmpDir, 'test.txt');
|
||||
await writeFile(filePath, 'hello world');
|
||||
|
||||
const result = await executeToolCall('readLocalFile', JSON.stringify({ path: filePath }));
|
||||
const result = await executeToolCall('readFile', JSON.stringify({ path: filePath }));
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const parsed = JSON.parse(result.content);
|
||||
expect(parsed.content).toContain('hello world');
|
||||
});
|
||||
|
||||
it('should dispatch writeLocalFile', async () => {
|
||||
it('should dispatch writeFile', async () => {
|
||||
const filePath = path.join(tmpDir, 'new.txt');
|
||||
|
||||
const result = await executeToolCall(
|
||||
'writeLocalFile',
|
||||
'writeFile',
|
||||
JSON.stringify({ content: 'written', path: filePath }),
|
||||
);
|
||||
|
||||
@@ -50,6 +50,17 @@ describe('executeToolCall', () => {
|
||||
expect(fs.readFileSync(filePath, 'utf8')).toBe('written');
|
||||
});
|
||||
|
||||
it('should dispatch legacy alias readLocalFile', async () => {
|
||||
const filePath = path.join(tmpDir, 'legacy.txt');
|
||||
await writeFile(filePath, 'legacy hello');
|
||||
|
||||
const result = await executeToolCall('readLocalFile', JSON.stringify({ path: filePath }));
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const parsed = JSON.parse(result.content);
|
||||
expect(parsed.content).toContain('legacy hello');
|
||||
});
|
||||
|
||||
it('should dispatch runCommand', async () => {
|
||||
const result = await executeToolCall(
|
||||
'runCommand',
|
||||
@@ -61,21 +72,21 @@ describe('executeToolCall', () => {
|
||||
expect(parsed.stdout).toContain('dispatched');
|
||||
});
|
||||
|
||||
it('should dispatch listLocalFiles', async () => {
|
||||
it('should dispatch listFiles', async () => {
|
||||
await writeFile(path.join(tmpDir, 'a.txt'), 'a');
|
||||
|
||||
const result = await executeToolCall('listLocalFiles', JSON.stringify({ path: tmpDir }));
|
||||
const result = await executeToolCall('listFiles', JSON.stringify({ path: tmpDir }));
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const parsed = JSON.parse(result.content);
|
||||
expect(parsed.totalCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should dispatch globLocalFiles', async () => {
|
||||
it('should dispatch globFiles', async () => {
|
||||
await writeFile(path.join(tmpDir, 'test.ts'), 'code');
|
||||
|
||||
const result = await executeToolCall(
|
||||
'globLocalFiles',
|
||||
'globFiles',
|
||||
JSON.stringify({ cwd: tmpDir, pattern: '*.ts' }),
|
||||
);
|
||||
|
||||
@@ -84,12 +95,12 @@ describe('executeToolCall', () => {
|
||||
expect(parsed.files).toContain('test.ts');
|
||||
});
|
||||
|
||||
it('should dispatch editLocalFile', async () => {
|
||||
it('should dispatch editFile', async () => {
|
||||
const filePath = path.join(tmpDir, 'edit.txt');
|
||||
await writeFile(filePath, 'old content');
|
||||
|
||||
const result = await executeToolCall(
|
||||
'editLocalFile',
|
||||
'editFile',
|
||||
JSON.stringify({
|
||||
file_path: filePath,
|
||||
new_string: 'new content',
|
||||
@@ -116,7 +127,7 @@ describe('executeToolCall', () => {
|
||||
const filePath = path.join(tmpDir, 'str.txt');
|
||||
await writeFile(filePath, 'content');
|
||||
|
||||
const result = await executeToolCall('readLocalFile', JSON.stringify({ path: filePath }));
|
||||
const result = await executeToolCall('readFile', JSON.stringify({ path: filePath }));
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Result should be valid JSON
|
||||
@@ -124,7 +135,7 @@ describe('executeToolCall', () => {
|
||||
});
|
||||
|
||||
it('should return error for invalid JSON arguments', async () => {
|
||||
const result = await executeToolCall('readLocalFile', 'not-json');
|
||||
const result = await executeToolCall('readFile', 'not-json');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBeDefined();
|
||||
@@ -141,11 +152,11 @@ describe('executeToolCall', () => {
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should dispatch searchLocalFiles', async () => {
|
||||
it('should dispatch searchFiles', async () => {
|
||||
await writeFile(path.join(tmpDir, 'search_target.txt'), 'found');
|
||||
|
||||
const result = await executeToolCall(
|
||||
'searchLocalFiles',
|
||||
'searchFiles',
|
||||
JSON.stringify({ directory: tmpDir, keywords: 'search_target' }),
|
||||
);
|
||||
|
||||
|
||||
@@ -11,14 +11,22 @@ import {
|
||||
import { getCommandOutput, killCommand, runCommand } from './shell';
|
||||
|
||||
const methodMap: Record<string, (args: any) => Promise<unknown>> = {
|
||||
editLocalFile,
|
||||
editFile: editLocalFile,
|
||||
getCommandOutput,
|
||||
globLocalFiles,
|
||||
globFiles: globLocalFiles,
|
||||
grepContent,
|
||||
killCommand,
|
||||
listFiles: listLocalFiles,
|
||||
readFile: readLocalFile,
|
||||
runCommand,
|
||||
searchFiles: searchLocalFiles,
|
||||
writeFile: writeLocalFile,
|
||||
|
||||
// Legacy aliases — older Gateway versions may still send the long form
|
||||
editLocalFile,
|
||||
globLocalFiles,
|
||||
listLocalFiles,
|
||||
readLocalFile,
|
||||
runCommand,
|
||||
searchLocalFiles,
|
||||
writeLocalFile,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { AgentStreamEvent } from '@lobechat/heterogeneous-agents/spawn';
|
||||
|
||||
export interface IngestSink {
|
||||
finish: (params: {
|
||||
error?: { message: string; type: string };
|
||||
result: 'cancelled' | 'error' | 'success';
|
||||
sessionId?: string;
|
||||
}) => Promise<void>;
|
||||
ingest: (events: AgentStreamEvent[]) => Promise<void>;
|
||||
}
|
||||
|
||||
export class NoopIngestSink implements IngestSink {
|
||||
async finish(_params: Parameters<IngestSink['finish']>[0]): Promise<void> {}
|
||||
async ingest(_events: AgentStreamEvent[]): Promise<void> {}
|
||||
}
|
||||
|
||||
const MAX_BATCH = 50;
|
||||
const FLUSH_INTERVAL_MS = 250;
|
||||
const MAX_RETRIES = 5;
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
/**
|
||||
* Buffers `AgentStreamEvent`s and flushes them in batches to an `IngestSink`.
|
||||
*
|
||||
* Flush triggers:
|
||||
* - Buffer reaches MAX_BATCH (50) → immediate flush
|
||||
* - FLUSH_INTERVAL_MS (250ms) timer fires → flush whatever is buffered
|
||||
*
|
||||
* Each batch is retried up to MAX_RETRIES (5) times with exponential back-off
|
||||
* starting at 500ms, doubling up to 8s. After the final retry the error is
|
||||
* stored and re-thrown by `drain()`, allowing the caller to call
|
||||
* `sink.finish({ result: 'error' })` and exit(1).
|
||||
*
|
||||
* Call order: push() repeatedly → drain() once (before finish()).
|
||||
*/
|
||||
export class BatchIngester {
|
||||
private buffer: AgentStreamEvent[] = [];
|
||||
private fatalError: Error | null = null;
|
||||
private inflightFlush: Promise<void> = Promise.resolve();
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(private readonly sink: IngestSink) {}
|
||||
|
||||
push(event: AgentStreamEvent): void {
|
||||
if (this.fatalError) return;
|
||||
this.buffer.push(event);
|
||||
if (this.buffer.length >= MAX_BATCH) {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
this.triggerFlush();
|
||||
} else if (!this.timer) {
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
this.triggerFlush();
|
||||
}, FLUSH_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
/** Flush remaining buffer and wait for all in-flight sends to settle. */
|
||||
async drain(): Promise<void> {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
this.triggerFlush();
|
||||
await this.inflightFlush;
|
||||
if (this.fatalError) throw this.fatalError;
|
||||
}
|
||||
|
||||
private async sendWithRetry(batch: AgentStreamEvent[]): Promise<void> {
|
||||
let delay = 500;
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
await this.sink.ingest(batch);
|
||||
return;
|
||||
} catch (err) {
|
||||
if (attempt === MAX_RETRIES) {
|
||||
this.fatalError = err instanceof Error ? err : new Error(String(err));
|
||||
throw this.fatalError;
|
||||
}
|
||||
await sleep(delay);
|
||||
delay = Math.min(delay * 2, 8_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private triggerFlush(): void {
|
||||
if (this.fatalError || this.buffer.length === 0) return;
|
||||
const batch = this.buffer.splice(0);
|
||||
this.inflightFlush = this.inflightFlush
|
||||
.then(() => this.sendWithRetry(batch))
|
||||
.catch(() => {
|
||||
// fatalError is already set; drain() re-throws it
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { AgentStreamEvent } from '@lobechat/heterogeneous-agents/spawn';
|
||||
|
||||
import type { TrpcClient } from '../api/client';
|
||||
import type { IngestSink } from './BatchIngester';
|
||||
|
||||
/**
|
||||
* `IngestSink` implementation that forwards batches to the server via tRPC
|
||||
* (`aiAgent.heteroIngest` / `aiAgent.heteroFinish`).
|
||||
*
|
||||
* The CLI authenticates using the `LOBEHUB_JWT` env var (operation-scoped JWT
|
||||
* injected by the server before spawning the sandbox / desktop process).
|
||||
*/
|
||||
export class TrpcIngestSink implements IngestSink {
|
||||
constructor(
|
||||
private readonly client: TrpcClient,
|
||||
private readonly agentType: 'claude-code' | 'codex',
|
||||
private readonly operationId: string,
|
||||
private readonly topicId: string,
|
||||
) {}
|
||||
|
||||
async finish(params: Parameters<IngestSink['finish']>[0]): Promise<void> {
|
||||
await this.client.aiAgent.heteroFinish.mutate({
|
||||
agentType: this.agentType,
|
||||
operationId: this.operationId,
|
||||
topicId: this.topicId,
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
async ingest(events: AgentStreamEvent[]): Promise<void> {
|
||||
await this.client.aiAgent.heteroIngest.mutate({
|
||||
agentType: this.agentType,
|
||||
events: events as any,
|
||||
operationId: this.operationId,
|
||||
topicId: this.topicId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,7 @@
|
||||
"@lobechat/electron-client-ipc": "workspace:*",
|
||||
"@lobechat/electron-server-ipc": "workspace:*",
|
||||
"@lobechat/file-loaders": "workspace:*",
|
||||
"@lobechat/heterogeneous-agents": "workspace:*",
|
||||
"@lobechat/local-file-shell": "workspace:*",
|
||||
"@lobehub/i18n-cli": "^1.25.1",
|
||||
"@modelcontextprotocol/sdk": "^1.24.3",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
packages:
|
||||
- '../cli'
|
||||
- '../../packages/agent-gateway-client'
|
||||
- '../../packages/heterogeneous-agents'
|
||||
- '../../packages/const'
|
||||
- '../../packages/electron-server-ipc'
|
||||
- '../../packages/electron-client-ipc'
|
||||
|
||||
@@ -26,6 +26,7 @@ export const defaultProxySettings: NetworkProxySettings = {
|
||||
* Storage default values
|
||||
*/
|
||||
export const STORE_DEFAULTS: ElectronMainStore = {
|
||||
appTrayVisible: true,
|
||||
dataSyncConfig: { storageMode: 'cloud' },
|
||||
encryptedTokens: {},
|
||||
gatewayDeviceDescription: '',
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { AgentRunRequestMessage } from '@lobechat/device-gateway-client';
|
||||
import type { GatewayConnectionStatus } from '@lobechat/electron-client-ipc';
|
||||
|
||||
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
|
||||
|
||||
import HeterogeneousAgentCtr from './HeterogeneousAgentCtr';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
import LocalFileCtr from './LocalFileCtr';
|
||||
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
|
||||
@@ -33,6 +35,10 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
return this.app.getController(ShellCommandCtr);
|
||||
}
|
||||
|
||||
private get heterogeneousAgentCtr() {
|
||||
return this.app.getController(HeterogeneousAgentCtr);
|
||||
}
|
||||
|
||||
// ─── Lifecycle ───
|
||||
|
||||
afterAppReady() {
|
||||
@@ -47,6 +53,9 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
// Wire up tool call handler
|
||||
srv.setToolCallHandler((apiName, args) => this.executeToolCall(apiName, args));
|
||||
|
||||
// Wire up agent run handler
|
||||
srv.setAgentRunHandler((request) => this.executeAgentRun(request));
|
||||
|
||||
// Auto-connect if already logged in
|
||||
this.tryAutoConnect();
|
||||
}
|
||||
@@ -108,23 +117,81 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
await this.service.connect();
|
||||
}
|
||||
|
||||
// ─── Agent Run Routing ───
|
||||
|
||||
private async executeAgentRun(
|
||||
request: AgentRunRequestMessage,
|
||||
): Promise<{ reason?: string; status: 'accepted' | 'rejected' }> {
|
||||
try {
|
||||
const ctr = this.heterogeneousAgentCtr;
|
||||
|
||||
// Create a session for the hetero agent.
|
||||
const { sessionId } = await ctr.startSession({
|
||||
agentType: request.agentType,
|
||||
args: [],
|
||||
command: request.agentType === 'codex' ? 'codex' : 'claude',
|
||||
cwd: request.cwd,
|
||||
// Inject LOBEHUB_JWT so the CLI authenticates against heteroIngest.
|
||||
env: { LOBEHUB_JWT: request.jwt },
|
||||
resumeSessionId: request.resumeSessionId,
|
||||
});
|
||||
|
||||
// Fire-and-forget: sendPrompt runs the CLI until completion.
|
||||
ctr
|
||||
.sendPrompt({
|
||||
operationId: request.operationId,
|
||||
prompt: request.prompt,
|
||||
sessionId,
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
// Errors are surfaced via heteroFinish on the server side.
|
||||
// Log locally for desktop debugging only.
|
||||
console.error('[GatewayConnectionCtr] agent run failed:', err.message);
|
||||
});
|
||||
|
||||
return { status: 'accepted' };
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
return { reason, status: 'rejected' };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tool Call Routing ───
|
||||
|
||||
private async executeToolCall(apiName: string, args: any): Promise<unknown> {
|
||||
const editFile = () => this.localFileCtr.handleEditFile(args);
|
||||
const globFiles = () => this.localFileCtr.handleGlobFiles(args);
|
||||
const listFiles = () => this.localFileCtr.listLocalFiles(args);
|
||||
const moveFiles = () => this.localFileCtr.handleMoveFiles(args);
|
||||
const readFile = () => this.localFileCtr.readFile(args);
|
||||
const searchFiles = () => this.localFileCtr.handleLocalFilesSearch(args);
|
||||
const writeFile = () => this.localFileCtr.handleWriteFile(args);
|
||||
|
||||
const methodMap: Record<string, () => Promise<unknown>> = {
|
||||
editLocalFile: () => this.localFileCtr.handleEditFile(args),
|
||||
globLocalFiles: () => this.localFileCtr.handleGlobFiles(args),
|
||||
editFile,
|
||||
globFiles,
|
||||
grepContent: () => this.localFileCtr.handleGrepContent(args),
|
||||
listLocalFiles: () => this.localFileCtr.listLocalFiles(args),
|
||||
moveLocalFiles: () => this.localFileCtr.handleMoveFiles(args),
|
||||
readLocalFile: () => this.localFileCtr.readFile(args),
|
||||
renameLocalFile: () => this.localFileCtr.handleRenameFile(args),
|
||||
searchLocalFiles: () => this.localFileCtr.handleLocalFilesSearch(args),
|
||||
writeLocalFile: () => this.localFileCtr.handleWriteFile(args),
|
||||
listFiles,
|
||||
moveFiles,
|
||||
readFile,
|
||||
searchFiles,
|
||||
writeFile,
|
||||
|
||||
getCommandOutput: () => this.shellCommandCtr.handleGetCommandOutput(args),
|
||||
killCommand: () => this.shellCommandCtr.handleKillCommand(args),
|
||||
runCommand: () => this.shellCommandCtr.handleRunCommand(args),
|
||||
|
||||
// Legacy aliases — keep these so older Gateway versions sending the long
|
||||
// names continue to route correctly. `renameLocalFile` is also kept even
|
||||
// though the new surface drops rename (it's now handled by `moveFiles`).
|
||||
editLocalFile: editFile,
|
||||
globLocalFiles: globFiles,
|
||||
listLocalFiles: listFiles,
|
||||
moveLocalFiles: moveFiles,
|
||||
readLocalFile: readFile,
|
||||
renameLocalFile: () => this.localFileCtr.handleRenameFile(args),
|
||||
searchLocalFiles: searchFiles,
|
||||
writeLocalFile: writeFile,
|
||||
};
|
||||
|
||||
const handler = methodMap[apiName];
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { execFile, spawn } from 'node:child_process';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import type {
|
||||
GetGitBranchDiffPayload,
|
||||
GitAheadBehind,
|
||||
GitBranchDiffPatches,
|
||||
GitBranchInfo,
|
||||
GitBranchListItem,
|
||||
GitCheckoutResult,
|
||||
GitFileDiffStatus,
|
||||
GitLinkedPullRequestResult,
|
||||
GitPullResult,
|
||||
GitPushResult,
|
||||
GitRemoteBranchListItem,
|
||||
GitWorkingTreeFiles,
|
||||
GitWorkingTreePatch,
|
||||
GitWorkingTreePatches,
|
||||
GitWorkingTreeStatus,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
|
||||
@@ -22,6 +28,412 @@ import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
const logger = createLogger('controllers:GitCtr');
|
||||
|
||||
interface DirtyEntry {
|
||||
filePath: string;
|
||||
status: GitFileDiffStatus;
|
||||
}
|
||||
|
||||
interface DiffBlock {
|
||||
isBinary: boolean;
|
||||
patch: string;
|
||||
/** Destination path (or source path for deleted files). */
|
||||
path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the output of `git diff HEAD --` into one block per file. Each block
|
||||
* starts at a `^diff --git ` line and runs to just before the next one (or
|
||||
* EOF). Path comes from the `+++ b/<path>` line, falling back to `--- a/<path>`
|
||||
* when the destination is `/dev/null` (deletion). Quoted paths (spaces /
|
||||
* non-ASCII when `core.quotepath` is on) are minimally de-escaped.
|
||||
*/
|
||||
const splitBulkDiff = (diffText: string): DiffBlock[] => {
|
||||
if (!diffText) return [];
|
||||
const blocks: DiffBlock[] = [];
|
||||
const headerRe = /^diff --git /gm;
|
||||
const starts: number[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = headerRe.exec(diffText)) !== null) starts.push(m.index);
|
||||
for (let i = 0; i < starts.length; i++) {
|
||||
const start = starts[i];
|
||||
const end = i + 1 < starts.length ? starts[i + 1] : diffText.length;
|
||||
const block = diffText.slice(start, end);
|
||||
const filePath = extractPathFromDiffBlock(block);
|
||||
if (!filePath) continue;
|
||||
blocks.push({
|
||||
isBinary: /^Binary files .* differ$/m.test(block),
|
||||
path: filePath,
|
||||
patch: block,
|
||||
});
|
||||
}
|
||||
return blocks;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pull the file path out of a per-file diff block. Looks at the `+++ b/<path>`
|
||||
* line first (covers add/modify); falls back to `--- a/<path>` for deletes
|
||||
* where `+++` is `/dev/null`; final fallback is the `diff --git a/x b/y`
|
||||
* header line.
|
||||
*/
|
||||
const extractPathFromDiffBlock = (block: string): string | null => {
|
||||
let plusPath: string | null = null;
|
||||
let minusPath: string | null = null;
|
||||
for (const line of block.split('\n')) {
|
||||
if (line.startsWith('+++ ')) {
|
||||
plusPath = parseDiffPathLine(line.slice(4), 'b/');
|
||||
} else if (line.startsWith('--- ')) {
|
||||
minusPath = parseDiffPathLine(line.slice(4), 'a/');
|
||||
}
|
||||
// The file headers always come before the first hunk / binary marker;
|
||||
// bail once we hit either to avoid scanning huge diff bodies.
|
||||
if (line.startsWith('@@') || line.startsWith('Binary files ')) break;
|
||||
}
|
||||
if (plusPath) return plusPath;
|
||||
if (minusPath) return minusPath;
|
||||
// Last-resort: parse the `diff --git a/x b/y` header itself.
|
||||
const header = block.split('\n', 1)[0];
|
||||
const match = /^diff --git a\/.+? b\/(.+)$/.exec(header);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Strip the `a/` or `b/` prefix off a `+++` / `---` line, drop the optional
|
||||
* trailing tab+timestamp, and de-quote git's C-style escaping. Returns null
|
||||
* for `/dev/null` (which means the other side of the diff is the real path).
|
||||
*/
|
||||
const parseDiffPathLine = (raw: string, prefix: 'a/' | 'b/'): string | null => {
|
||||
const tabIdx = raw.indexOf('\t');
|
||||
let p = tabIdx >= 0 ? raw.slice(0, tabIdx) : raw;
|
||||
if (p === '/dev/null') return null;
|
||||
// Quoted form: "b/path with spaces"
|
||||
if (p.startsWith('"') && p.endsWith('"')) {
|
||||
p = dequoteGitPath(p.slice(1, -1));
|
||||
}
|
||||
return p.startsWith(prefix) ? p.slice(prefix.length) : p;
|
||||
};
|
||||
|
||||
export const dequoteGitPath = (s: string): string =>
|
||||
s.replaceAll(/\\(["\\trn]|[0-7]{3})/g, (_, esc: string) => {
|
||||
if (esc === '"') return '"';
|
||||
if (esc === '\\') return '\\';
|
||||
if (esc === 't') return '\t';
|
||||
if (esc === 'r') return '\r';
|
||||
if (esc === 'n') return '\n';
|
||||
return String.fromCodePoint(Number.parseInt(esc, 8));
|
||||
});
|
||||
|
||||
/**
|
||||
* Inverse of {@link dequoteGitPath} — returns either `<prefix><path>` (when
|
||||
* no escaping is needed) or git's C-style quoted form `"<prefix><escaped>"`
|
||||
* (when the path contains TAB / LF / CR / quote / backslash / control bytes).
|
||||
* The prefix lives *inside* the quotes so the output matches what real `git
|
||||
* diff` would emit, e.g. `"a/file\twith tab.txt"` rather than `a/"file\twith
|
||||
* tab.txt"`. Plain spaces are not quoted (git tolerates them; the trailing
|
||||
* ` b/<path>` marker on the diff header is enough to delimit the source).
|
||||
*/
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const NEEDS_QUOTING = /["\\\x00-\x1F\x7F]/;
|
||||
export const quoteGitPath = (prefix: 'a/' | 'b/', filePath: string): string => {
|
||||
const combined = prefix + filePath;
|
||||
if (!NEEDS_QUOTING.test(combined)) return combined;
|
||||
let out = '"';
|
||||
for (const ch of combined) {
|
||||
if (ch === '\\') out += '\\\\';
|
||||
else if (ch === '"') out += '\\"';
|
||||
else if (ch === '\t') out += '\\t';
|
||||
else if (ch === '\n') out += '\\n';
|
||||
else if (ch === '\r') out += '\\r';
|
||||
else {
|
||||
const code = ch.codePointAt(0)!;
|
||||
if (code < 0x20 || code === 0x7f) {
|
||||
out += '\\' + code.toString(8).padStart(3, '0');
|
||||
} else {
|
||||
out += ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out + '"';
|
||||
};
|
||||
|
||||
/**
|
||||
* Status from a single diff block's preamble: `new file mode` → added,
|
||||
* `deleted file mode` → deleted, otherwise modified. Used by branch-diff mode
|
||||
* where there's no `git status` to consult — the diff itself is the source.
|
||||
*/
|
||||
const detectDiffBlockStatus = (block: string): GitFileDiffStatus => {
|
||||
// Only scan up to the first hunk / binary marker so huge bodies aren't walked.
|
||||
for (const line of block.split('\n')) {
|
||||
if (line.startsWith('new file mode ')) return 'added';
|
||||
if (line.startsWith('deleted file mode ')) return 'deleted';
|
||||
if (line.startsWith('@@') || line.startsWith('Binary files ')) break;
|
||||
}
|
||||
return 'modified';
|
||||
};
|
||||
|
||||
/** Walk a patch counting `+`/`-` lines while skipping `+++`/`---` headers. */
|
||||
const countAddDel = (patch: string): { additions: number; deletions: number } => {
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
for (const line of patch.split('\n')) {
|
||||
if (line.startsWith('+++') || line.startsWith('---')) continue;
|
||||
if (line.startsWith('+')) additions++;
|
||||
else if (line.startsWith('-')) deletions++;
|
||||
}
|
||||
return { additions, deletions };
|
||||
};
|
||||
|
||||
const emptyPatch = (entry: DirtyEntry): GitWorkingTreePatch => ({
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
filePath: entry.filePath,
|
||||
isBinary: false,
|
||||
patch: '',
|
||||
status: entry.status,
|
||||
truncated: false,
|
||||
});
|
||||
|
||||
const buildTrackedPatch = (
|
||||
entry: DirtyEntry,
|
||||
block: DiffBlock,
|
||||
maxBytes: number,
|
||||
): GitWorkingTreePatch => {
|
||||
if (block.isBinary) {
|
||||
return { ...emptyPatch(entry), isBinary: true };
|
||||
}
|
||||
if (block.patch.length > maxBytes) {
|
||||
return { ...emptyPatch(entry), truncated: true };
|
||||
}
|
||||
const { additions, deletions } = countAddDel(block.patch);
|
||||
return {
|
||||
additions,
|
||||
deletions,
|
||||
filePath: entry.filePath,
|
||||
isBinary: false,
|
||||
patch: block.patch,
|
||||
status: entry.status,
|
||||
truncated: false,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a synthetic add-only patch for an untracked file by reading it from
|
||||
* disk — replaces the per-file `git diff --no-index /dev/null <file>` fork.
|
||||
* Binary detection uses a NUL-byte sniff over the first 8 KB (matches what
|
||||
* git itself does internally).
|
||||
*/
|
||||
const readUntrackedAsPatch = async (
|
||||
cwd: string,
|
||||
entry: DirtyEntry,
|
||||
maxBytes: number,
|
||||
): Promise<GitWorkingTreePatch> => {
|
||||
const absolute = path.resolve(cwd, entry.filePath);
|
||||
let size: number;
|
||||
try {
|
||||
const s = await stat(absolute);
|
||||
if (!s.isFile()) return emptyPatch(entry);
|
||||
size = s.size;
|
||||
} catch (error: any) {
|
||||
logger.debug('[readUntrackedAsPatch] stat failed', {
|
||||
filePath: entry.filePath,
|
||||
message: error?.message,
|
||||
});
|
||||
return emptyPatch(entry);
|
||||
}
|
||||
// Pre-quote so the path is C-style escaped wherever it lands in the synthetic
|
||||
// patch — raw `entry.filePath` interpolation would emit malformed `diff --git`
|
||||
// / `+++` lines for filenames containing TAB / LF / quote / backslash.
|
||||
const aPath = quoteGitPath('a/', entry.filePath);
|
||||
const bPath = quoteGitPath('b/', entry.filePath);
|
||||
if (size === 0) {
|
||||
return {
|
||||
...emptyPatch(entry),
|
||||
patch:
|
||||
[
|
||||
`diff --git ${aPath} ${bPath}`,
|
||||
'new file mode 100644',
|
||||
'--- /dev/null',
|
||||
`+++ ${bPath}`,
|
||||
].join('\n') + '\n',
|
||||
};
|
||||
}
|
||||
// Cap the synthesized patch by *file* size, not patch size — a 200 KB file
|
||||
// produces a ~200 KB patch (one `+` per line). Close enough.
|
||||
if (size > maxBytes) {
|
||||
return { ...emptyPatch(entry), truncated: true };
|
||||
}
|
||||
let buf: Buffer;
|
||||
try {
|
||||
buf = await readFile(absolute);
|
||||
} catch (error: any) {
|
||||
logger.debug('[readUntrackedAsPatch] read failed', {
|
||||
filePath: entry.filePath,
|
||||
message: error?.message,
|
||||
});
|
||||
return emptyPatch(entry);
|
||||
}
|
||||
const sniffEnd = Math.min(buf.length, 8192);
|
||||
for (let i = 0; i < sniffEnd; i++) {
|
||||
if (buf[i] === 0) return { ...emptyPatch(entry), isBinary: true };
|
||||
}
|
||||
const text = buf.toString('utf8');
|
||||
// text.split('\n') leaves a trailing '' when the file ends with '\n';
|
||||
// exclude it so the hunk header line count matches git's own output.
|
||||
const rawLines = text.split('\n');
|
||||
const trailingEmpty = rawLines.length > 0 && rawLines.at(-1) === '';
|
||||
const lineCount = trailingEmpty ? rawLines.length - 1 : rawLines.length;
|
||||
if (lineCount === 0) {
|
||||
return { ...emptyPatch(entry), patch: '' };
|
||||
}
|
||||
const body = rawLines
|
||||
.slice(0, lineCount)
|
||||
.map((line) => '+' + line)
|
||||
.join('\n');
|
||||
// Mirror `git diff --no-index`'s "no newline at end of file" footer when the
|
||||
// source had no trailing newline — keeps PatchDiff's hunk parser happy.
|
||||
const noNewlineFooter = trailingEmpty ? '' : '\n\\ No newline at end of file';
|
||||
const patch =
|
||||
[
|
||||
`diff --git ${aPath} ${bPath}`,
|
||||
'new file mode 100644',
|
||||
'--- /dev/null',
|
||||
`+++ ${bPath}`,
|
||||
`@@ -0,0 +1,${lineCount} @@`,
|
||||
body,
|
||||
].join('\n') +
|
||||
noNewlineFooter +
|
||||
'\n';
|
||||
return {
|
||||
additions: lineCount,
|
||||
deletions: 0,
|
||||
filePath: entry.filePath,
|
||||
isBinary: false,
|
||||
patch,
|
||||
status: entry.status,
|
||||
truncated: false,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Stream a git invocation's stdout via `spawn` instead of `execFile`'s
|
||||
* fixed-size buffer. Replaces the bulk-diff caller's old 64 MB `maxBuffer`
|
||||
* cap — pipe-buffer-sized chunks accumulate in memory until the process
|
||||
* exits, with no hard ceiling. SIGTERM on timeout. Resolves with the full
|
||||
* stdout string; rejects with an Error carrying `stderr` and `partialStdout`
|
||||
* fields so callers can salvage partial output (or fall back) on failure.
|
||||
*/
|
||||
const runGitCaptureStream = (cwd: string, args: string[], timeoutMs: number): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const child = spawn('git', args, { cwd });
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
let stderrBuf = '';
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill('SIGTERM');
|
||||
}, timeoutMs);
|
||||
child.stdout.on('data', (chunk: Buffer) => stdoutChunks.push(chunk));
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
stderrBuf += chunk.toString('utf8');
|
||||
});
|
||||
child.on('error', (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(Object.assign(err, { stderr: stderrBuf }));
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
const stdout = Buffer.concat(stdoutChunks).toString('utf8');
|
||||
if (timedOut) {
|
||||
const err: any = new Error('git command timed out');
|
||||
err.stderr = stderrBuf;
|
||||
err.partialStdout = stdout;
|
||||
return reject(err);
|
||||
}
|
||||
// `git diff HEAD` (without --exit-code) exits 0 even when there are
|
||||
// diffs; non-zero is therefore a real error.
|
||||
if (code !== 0) {
|
||||
const err: any = new Error(`git exited with code ${code}`);
|
||||
err.code = code;
|
||||
err.stderr = stderrBuf;
|
||||
err.partialStdout = stdout;
|
||||
return reject(err);
|
||||
}
|
||||
resolve(stdout);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Last-resort per-file diff for tracked entries the bulk diff didn't cover —
|
||||
* either because the bulk command failed entirely or because git emitted no
|
||||
* patch for a path the status step listed (rare race with concurrent writes).
|
||||
* Mirrors the original per-file behavior so individual files keep their
|
||||
* patches even when the bulk fast-path is unavailable.
|
||||
*/
|
||||
const fetchTrackedPatchPerFile = async (
|
||||
cwd: string,
|
||||
entry: DirtyEntry,
|
||||
maxBytes: number,
|
||||
): Promise<GitWorkingTreePatch> => {
|
||||
const execFileAsync = promisify(execFile);
|
||||
let text: string;
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'git',
|
||||
['-c', 'core.quotepath=off', 'diff', '--no-color', 'HEAD', '--', entry.filePath],
|
||||
{
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: maxBytes * 4,
|
||||
timeout: 10_000,
|
||||
},
|
||||
);
|
||||
text = stdout as string;
|
||||
} catch (error: any) {
|
||||
logger.debug('[fetchTrackedPatchPerFile] diff failed', {
|
||||
filePath: entry.filePath,
|
||||
stderr: error?.stderr?.toString?.() ?? error?.stderr,
|
||||
});
|
||||
return emptyPatch(entry);
|
||||
}
|
||||
if (text.length > maxBytes) return { ...emptyPatch(entry), truncated: true };
|
||||
if (/^Binary files .* differ$/m.test(text)) return { ...emptyPatch(entry), isBinary: true };
|
||||
if (!text) return emptyPatch(entry);
|
||||
const { additions, deletions } = countAddDel(text);
|
||||
return {
|
||||
additions,
|
||||
deletions,
|
||||
filePath: entry.filePath,
|
||||
isBinary: false,
|
||||
patch: text,
|
||||
status: entry.status,
|
||||
truncated: false,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Bounded `Promise.all` — runs at most `limit` async tasks at a time. Used
|
||||
* for the per-file fallback so we cap fork pressure at a small constant
|
||||
* instead of replaying the original 200-parallel `git diff` storm.
|
||||
*/
|
||||
const mapWithConcurrency = async <T, R>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
fn: (item: T) => Promise<R>,
|
||||
): Promise<R[]> => {
|
||||
const results: R[] = Array.from({ length: items.length });
|
||||
let cursor = 0;
|
||||
const workerCount = Math.min(limit, items.length);
|
||||
await Promise.all(
|
||||
Array.from({ length: workerCount }, async () => {
|
||||
while (true) {
|
||||
const idx = cursor++;
|
||||
if (idx >= items.length) return;
|
||||
results[idx] = await fn(items[idx]);
|
||||
}
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
};
|
||||
|
||||
export default class GitController extends ControllerModule {
|
||||
static override readonly groupName = 'git';
|
||||
|
||||
@@ -162,6 +574,54 @@ export default class GitController extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List remote branches under `refs/remotes/origin/*`, ordered by most
|
||||
* recent commit. The `HEAD` symref is filtered out and the resolved
|
||||
* default branch is flagged via `isDefault` so the UI can render it
|
||||
* with a marker. Used by the Review panel's branch-compare picker.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async listGitRemoteBranches(dirPath: string): Promise<GitRemoteBranchListItem[]> {
|
||||
const execFileAsync = promisify(execFile);
|
||||
let defaultRef: string | undefined;
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'git',
|
||||
['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'],
|
||||
{ cwd: dirPath, timeout: 5000 },
|
||||
);
|
||||
defaultRef = stdout.trim() || undefined;
|
||||
} catch {
|
||||
defaultRef = undefined;
|
||||
}
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'git',
|
||||
[
|
||||
'for-each-ref',
|
||||
'--sort=-committerdate',
|
||||
'--format=%(refname:short)',
|
||||
'refs/remotes/origin',
|
||||
],
|
||||
{ cwd: dirPath, timeout: 5000 },
|
||||
);
|
||||
return stdout
|
||||
.replaceAll('\r', '')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((name) => name.length > 0 && name !== 'origin/HEAD' && !name.endsWith('/HEAD'))
|
||||
.map((name) => ({ isDefault: name === defaultRef, name }));
|
||||
} catch (error: any) {
|
||||
logger.warn('[listGitRemoteBranches] git command failed', {
|
||||
code: error?.code,
|
||||
cwd: dirPath,
|
||||
message: error?.message,
|
||||
stderr: error?.stderr?.toString?.() ?? error?.stderr,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket dirty files into added / modified / deleted via `git status --porcelain -z`.
|
||||
* Each file is counted once: untracked (`??`) and staged-add (`A`) → added,
|
||||
@@ -261,6 +721,235 @@ export default class GitController extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull every dirty file's unified diff in one shot — one IPC call returns
|
||||
* the patches the renderer needs to render `<PatchDiff />` per file.
|
||||
*
|
||||
* Tracked changes (modified / deleted / staged-A) all come from a *single*
|
||||
* `git diff HEAD --` invocation that we split per-file in JS — fork-bombing
|
||||
* the main process with N parallel `git diff` subprocesses was costing us
|
||||
* ~5–10ms × N in fork overhead plus `.git/index` lock contention, and the
|
||||
* libuv worker pool stayed busy while other IPC handlers queued. One
|
||||
* subprocess instead of N keeps the freeze invisible.
|
||||
*
|
||||
* Untracked files are read directly with `fs.readFile` and a synthetic
|
||||
* `--- /dev/null / +++ b/<path>` patch is built in Node — no `git diff`
|
||||
* subprocess at all.
|
||||
*
|
||||
* Per-file patches are capped at 256 KB; oversized or binary entries get an
|
||||
* empty `patch` string and a flag the renderer can use for a placeholder.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async getGitWorkingTreePatches(dirPath: string): Promise<GitWorkingTreePatches> {
|
||||
const MAX_PATCH_BYTES = 256 * 1024;
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
interface Entry {
|
||||
filePath: string;
|
||||
isUntracked: boolean;
|
||||
status: GitFileDiffStatus;
|
||||
}
|
||||
|
||||
// Step 1 — classify every dirty path. Mirrors getGitWorkingTreeFiles but
|
||||
// also distinguishes untracked (`??`) from staged-add (`A`) so we can pick
|
||||
// the right path (git diff vs raw read) per entry.
|
||||
const entries: Entry[] = [];
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['status', '--porcelain', '-z'], {
|
||||
cwd: dirPath,
|
||||
timeout: 5000,
|
||||
});
|
||||
const tokens = stdout.split('\0');
|
||||
let i = 0;
|
||||
while (i < tokens.length) {
|
||||
const entry = tokens[i];
|
||||
i++;
|
||||
if (entry.length < 3) continue;
|
||||
const x = entry[0];
|
||||
const y = entry[1];
|
||||
const filePath = entry.slice(3);
|
||||
// R/C entries carry an extra source-path token we must consume.
|
||||
if (x === 'R' || x === 'C') i++;
|
||||
if (!filePath) continue;
|
||||
if (x === '?' && y === '?') {
|
||||
entries.push({ filePath, isUntracked: true, status: 'added' });
|
||||
} else if (x === '!' && y === '!') {
|
||||
// ignored
|
||||
} else if (x === 'D' || y === 'D') {
|
||||
entries.push({ filePath, isUntracked: false, status: 'deleted' });
|
||||
} else if (x === 'A' || y === 'A') {
|
||||
entries.push({ filePath, isUntracked: false, status: 'added' });
|
||||
} else {
|
||||
entries.push({ filePath, isUntracked: false, status: 'modified' });
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.warn('[getGitWorkingTreePatches] status failed', {
|
||||
cwd: dirPath,
|
||||
stderr: error?.stderr?.toString?.() ?? error?.stderr,
|
||||
});
|
||||
return { patches: [] };
|
||||
}
|
||||
|
||||
// Step 2a — single bulk `git diff HEAD` for every tracked dirty path,
|
||||
// then split per-file in JS. We pass paths explicitly (not all) so a
|
||||
// huge unrelated working tree doesn't pull extra patches into the
|
||||
// stream. Output is streamed via spawn so there's no maxBuffer ceiling
|
||||
// — even a multi-hundred-MB combined diff lands intact, and any partial
|
||||
// output recovered from a failed run still feeds the per-file fallback.
|
||||
const trackedEntries = entries.filter((e) => !e.isUntracked);
|
||||
const trackedByPath = new Map(trackedEntries.map((e) => [e.filePath, e]));
|
||||
const trackedPatches = new Map<string, GitWorkingTreePatch>();
|
||||
if (trackedEntries.length > 0) {
|
||||
let bulkDiff = '';
|
||||
try {
|
||||
bulkDiff = await runGitCaptureStream(
|
||||
dirPath,
|
||||
[
|
||||
'-c',
|
||||
'core.quotepath=off',
|
||||
'diff',
|
||||
'--no-color',
|
||||
'HEAD',
|
||||
'--',
|
||||
...trackedEntries.map((e) => e.filePath),
|
||||
],
|
||||
30_000,
|
||||
);
|
||||
} catch (error: any) {
|
||||
logger.warn('[getGitWorkingTreePatches] bulk diff failed; per-file fallback', {
|
||||
cwd: dirPath,
|
||||
stderr: error?.stderr?.toString?.() ?? error?.stderr,
|
||||
});
|
||||
// Salvage any patches that did stream through before the failure —
|
||||
// the per-file fallback below only retries the stragglers.
|
||||
if (typeof error?.partialStdout === 'string') bulkDiff = error.partialStdout;
|
||||
}
|
||||
for (const block of splitBulkDiff(bulkDiff)) {
|
||||
const entry = trackedByPath.get(block.path);
|
||||
if (!entry) continue;
|
||||
trackedPatches.set(entry.filePath, buildTrackedPatch(entry, block, MAX_PATCH_BYTES));
|
||||
}
|
||||
// Anything the bulk diff didn't cover (bulk crashed, race-with-write,
|
||||
// or git emitted no patch for a path status flagged dirty) gets a
|
||||
// per-file retry. Concurrency-capped to avoid the original fork storm.
|
||||
const stragglers = trackedEntries.filter((e) => !trackedPatches.has(e.filePath));
|
||||
if (stragglers.length > 0) {
|
||||
const recovered = await mapWithConcurrency(stragglers, 8, (entry) =>
|
||||
fetchTrackedPatchPerFile(dirPath, entry, MAX_PATCH_BYTES),
|
||||
);
|
||||
for (const patch of recovered) trackedPatches.set(patch.filePath, patch);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2b — read untracked files directly in Node. fs.readFile is bounded
|
||||
// by libuv's thread pool (4 by default) so unbounded Promise.all is fine.
|
||||
const untrackedEntries = entries.filter((e) => e.isUntracked);
|
||||
const untrackedPatches = await Promise.all(
|
||||
untrackedEntries.map((entry) => readUntrackedAsPatch(dirPath, entry, MAX_PATCH_BYTES)),
|
||||
);
|
||||
|
||||
// Step 3 — combine + sort to match the working-tree popover order.
|
||||
const order: Record<GitFileDiffStatus, number> = { added: 0, modified: 1, deleted: 2 };
|
||||
const allPatches: GitWorkingTreePatch[] = [...trackedPatches.values(), ...untrackedPatches];
|
||||
allPatches.sort((a, b) => order[a.status] - order[b.status]);
|
||||
|
||||
return { patches: allPatches };
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff every changed file between the current HEAD and the remote default
|
||||
* branch (resolved via `refs/remotes/origin/HEAD` — typically `origin/main`
|
||||
* or `origin/canary`). Uses `<base>...HEAD` so the result is "what this
|
||||
* branch added since it forked", ignoring upstream-only commits.
|
||||
*
|
||||
* Best-effort `git fetch` first so the comparison reflects the latest
|
||||
* remote state; fetch failures (offline / no creds / no `origin`) are
|
||||
* swallowed and we fall back to whatever cached refs exist. Returns
|
||||
* `baseRef: undefined` + empty patches when no remote default is set —
|
||||
* the renderer surfaces a "noBaseRef" hint in that case.
|
||||
*
|
||||
* Patch parsing reuses the same bulk-split + size-cap path as the working
|
||||
* tree variant; status comes from each diff block's preamble (no `git
|
||||
* status` cross-reference needed since every block is from history).
|
||||
*/
|
||||
@IpcMethod()
|
||||
async getGitBranchDiff(payload: GetGitBranchDiffPayload): Promise<GitBranchDiffPatches> {
|
||||
const { path: dirPath, baseRef: baseRefOverride } = payload;
|
||||
const MAX_PATCH_BYTES = 256 * 1024;
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// Step 1 — best-effort fetch so origin/<default> reflects remote HEAD.
|
||||
try {
|
||||
await execFileAsync('git', ['fetch', '--no-tags', '--quiet', 'origin'], {
|
||||
cwd: dirPath,
|
||||
timeout: 10_000,
|
||||
});
|
||||
} catch {
|
||||
// swallow — fall through to cached refs
|
||||
}
|
||||
|
||||
// Step 2 — pick the comparison base. When the caller passes an explicit
|
||||
// override (e.g. user picked a non-default branch in the UI) we trust it;
|
||||
// otherwise we resolve `refs/remotes/origin/HEAD`. The default may be
|
||||
// missing on repos cloned with --no-checkout or after a remote rename —
|
||||
// surface a "noBaseRef" empty state in that case so the user can run
|
||||
// `git remote set-head origin --auto` themselves.
|
||||
let baseRef: string | undefined = baseRefOverride;
|
||||
if (!baseRef) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'git',
|
||||
['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'],
|
||||
{ cwd: dirPath, timeout: 5000 },
|
||||
);
|
||||
baseRef = stdout.trim() || undefined;
|
||||
} catch {
|
||||
baseRef = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// headRef populated even when baseRef is missing so the UI can still
|
||||
// surface "fix/foo ← ?" instead of going completely blank.
|
||||
const headRef = (await this.getGitBranch(dirPath)).branch;
|
||||
|
||||
if (!baseRef) {
|
||||
return { headRef, patches: [] };
|
||||
}
|
||||
|
||||
// Step 3 — single bulk diff against the merge base. Three-dot semantics
|
||||
// (`base...HEAD`) ignore commits added to base after the branch forked,
|
||||
// matching what users expect from "compare branch" UI on GitHub. Stream
|
||||
// capture mirrors the working-tree path so multi-MB diffs land intact.
|
||||
let bulkDiff = '';
|
||||
try {
|
||||
bulkDiff = await runGitCaptureStream(
|
||||
dirPath,
|
||||
['-c', 'core.quotepath=off', 'diff', '--no-color', `${baseRef}...HEAD`],
|
||||
30_000,
|
||||
);
|
||||
} catch (error: any) {
|
||||
logger.warn('[getGitBranchDiff] diff failed', {
|
||||
baseRef,
|
||||
cwd: dirPath,
|
||||
stderr: error?.stderr?.toString?.() ?? error?.stderr,
|
||||
});
|
||||
if (typeof error?.partialStdout === 'string') bulkDiff = error.partialStdout;
|
||||
}
|
||||
|
||||
// Step 4 — split + classify per-file from the diff preamble alone.
|
||||
const patches: GitWorkingTreePatch[] = [];
|
||||
for (const block of splitBulkDiff(bulkDiff)) {
|
||||
const status = detectDiffBlockStatus(block.patch);
|
||||
patches.push(buildTrackedPatch({ filePath: block.path, status }, block, MAX_PATCH_BYTES));
|
||||
}
|
||||
|
||||
const order: Record<GitFileDiffStatus, number> = { added: 0, modified: 1, deleted: 2 };
|
||||
patches.sort((a, b) => order[a.status] - order[b.status]);
|
||||
|
||||
return { baseRef, headRef, patches };
|
||||
}
|
||||
|
||||
/**
|
||||
* Count commits HEAD is ahead/behind its upstream tracking ref.
|
||||
* Returns `hasUpstream: false` when the branch has no upstream configured
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { access, appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { access, appendFile, mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { Readable, Writable } from 'node:stream';
|
||||
import { finished as streamFinished } from 'node:stream/promises';
|
||||
|
||||
import type { HeterogeneousAgentSessionError } from '@lobechat/electron-client-ipc';
|
||||
import {
|
||||
@@ -13,14 +14,17 @@ import {
|
||||
CODEX_CLI_INSTALL_DOCS_URL,
|
||||
HeterogeneousAgentSessionErrorCode,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import type { AgentContentBlock } from '@lobechat/heterogeneous-agents/spawn';
|
||||
import {
|
||||
AgentStreamPipeline,
|
||||
buildAgentInput,
|
||||
materializeImageToPath,
|
||||
normalizeImage,
|
||||
} from '@lobechat/heterogeneous-agents/spawn';
|
||||
import { app as electronApp, BrowserWindow } from 'electron';
|
||||
|
||||
import { getHeterogeneousAgentDriver } from '@/modules/heterogeneousAgent';
|
||||
import { CodexFileChangeTracker } from '@/modules/heterogeneousAgent/codexFileChangeTracker';
|
||||
import type {
|
||||
HeterogeneousAgentImageAttachment,
|
||||
HeterogeneousAgentParsedOutput,
|
||||
} from '@/modules/heterogeneousAgent/types';
|
||||
import type { HeterogeneousAgentImageAttachment } from '@/modules/heterogeneousAgent/types';
|
||||
import { buildProxyEnv } from '@/modules/networkProxy/envBuilder';
|
||||
import { detectHeterogeneousCliCommand } from '@/modules/toolDetectors';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
@@ -52,16 +56,6 @@ const CODEX_RESUME_CWD_MISMATCH_PATTERNS = [
|
||||
/** Directory under appStoragePath for caching downloaded files */
|
||||
const FILE_CACHE_DIR = 'heteroAgent/files';
|
||||
const CLI_TRACE_DIR = '.heerogeneous-tracing';
|
||||
const IMAGE_EXTENSIONS_BY_MIME = {
|
||||
'image/gif': '.gif',
|
||||
'image/jpg': '.jpg',
|
||||
'image/jpeg': '.jpg',
|
||||
'image/pjpeg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/webp': '.webp',
|
||||
'image/x-png': '.png',
|
||||
} as const satisfies Record<string, string>;
|
||||
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const CODEX_STDERR_STATUS_LINE = 'Reading prompt from stdin...';
|
||||
const CODEX_WARN_LOG_PATTERN = /^\d{4}-\d{2}-\d{2}T\S+\s+WARN\s+/;
|
||||
const CODEX_LOG_PATTERN = /^\d{4}-\d{2}-\d{2}T\S+\s+(?:DEBUG|ERROR|INFO|TRACE|WARN)\s+/;
|
||||
@@ -91,6 +85,12 @@ interface StartSessionResult {
|
||||
interface SendPromptParams {
|
||||
/** Image attachments to include in the prompt (downloaded from url, cached by id) */
|
||||
imageList?: HeterogeneousAgentImageAttachment[];
|
||||
/**
|
||||
* Renderer-side operation id stamped onto every emitted `AgentStreamEvent`.
|
||||
* Required: producer-side conversion is the V3 contract — by the time events
|
||||
* reach the renderer they must already carry the operation they belong to.
|
||||
*/
|
||||
operationId: string;
|
||||
prompt: string;
|
||||
sessionId: string;
|
||||
}
|
||||
@@ -148,7 +148,7 @@ interface CliTraceSession {
|
||||
* prompt transport, resume semantics, and raw stream shape without turning
|
||||
* this controller into a giant `switch`.
|
||||
*
|
||||
* Lifecycle: startSession → sendPrompt → (heteroAgentRawLine broadcasts) → stopSession
|
||||
* Lifecycle: startSession → sendPrompt → (heteroAgentEvent broadcasts) → stopSession
|
||||
*/
|
||||
export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
static override readonly groupName = 'heterogeneousAgent';
|
||||
@@ -574,125 +574,56 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a filesystem-safe cache key for attachments.
|
||||
*
|
||||
* Never use the raw image id as a path segment — upstream callers can persist
|
||||
* arbitrary ids and path.join would treat traversal sequences as real
|
||||
* directories. A stable hash preserves cache hits without trusting the id as a
|
||||
* filename.
|
||||
* Convert a desktop image attachment list into shared content blocks. Each
|
||||
* attachment's id is preserved as the cache key so repeated prompts hit the
|
||||
* same on-disk entries.
|
||||
*/
|
||||
private getImageCacheKey(imageId: string): string {
|
||||
return createHash('sha256').update(imageId).digest('hex');
|
||||
private toImageContentBlocks(
|
||||
imageList: HeterogeneousAgentImageAttachment[],
|
||||
): AgentContentBlock[] {
|
||||
return imageList.map((image) => ({
|
||||
source: { id: image.id, type: 'url', url: image.url },
|
||||
type: 'image',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Download an image by URL, with local disk cache keyed by id.
|
||||
* Build a Claude Code stream-json user message with text + base64 images.
|
||||
* Delegates to the shared `buildAgentInput`; the desktop wrapper exists only
|
||||
* to preserve the helper signature consumed by existing drivers.
|
||||
*/
|
||||
private async resolveImage(
|
||||
image: HeterogeneousAgentImageAttachment,
|
||||
): Promise<{ buffer: Buffer; mimeType: string }> {
|
||||
const cacheDir = this.fileCacheDir;
|
||||
const cacheKey = this.getImageCacheKey(image.id);
|
||||
const metaPath = path.join(cacheDir, `${cacheKey}.meta`);
|
||||
const dataPath = path.join(cacheDir, cacheKey);
|
||||
private async buildStreamJsonInput(
|
||||
prompt: string,
|
||||
imageList: HeterogeneousAgentImageAttachment[] = [],
|
||||
): Promise<string> {
|
||||
const blocks: AgentContentBlock[] = [];
|
||||
if (prompt && prompt.length > 0) blocks.push({ text: prompt, type: 'text' });
|
||||
blocks.push(...this.toImageContentBlocks(imageList));
|
||||
|
||||
// Check cache first
|
||||
try {
|
||||
const metaRaw = await readFile(metaPath, 'utf8');
|
||||
const meta = JSON.parse(metaRaw);
|
||||
const buffer = await readFile(dataPath);
|
||||
logger.debug('Image cache hit:', image.id);
|
||||
return { buffer, mimeType: meta.mimeType || 'image/png' };
|
||||
} catch {
|
||||
// Cache miss — download
|
||||
}
|
||||
|
||||
logger.info('Downloading image:', image.id);
|
||||
|
||||
const res = await fetch(image.url);
|
||||
if (!res.ok)
|
||||
throw new Error(`Failed to download image ${image.id}: ${res.status} ${res.statusText}`);
|
||||
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
const mimeType = res.headers.get('content-type') || 'image/png';
|
||||
|
||||
// Write to cache
|
||||
await mkdir(cacheDir, { recursive: true });
|
||||
await writeFile(dataPath, buffer);
|
||||
await writeFile(metaPath, JSON.stringify({ id: image.id, mimeType }));
|
||||
logger.debug('Image cached:', image.id, `${buffer.length} bytes`);
|
||||
|
||||
return { buffer, mimeType };
|
||||
}
|
||||
|
||||
private normalizeMimeType(mimeType: string): string {
|
||||
return mimeType.split(';')[0]?.trim().toLowerCase() || '';
|
||||
}
|
||||
|
||||
private guessImageExtensionByBuffer(buffer: Buffer): string | undefined {
|
||||
if (buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) return '.png';
|
||||
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return '.jpg';
|
||||
|
||||
const gifSignature = buffer.subarray(0, 6).toString('ascii');
|
||||
if (gifSignature === 'GIF87a' || gifSignature === 'GIF89a') return '.gif';
|
||||
|
||||
if (
|
||||
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||
) {
|
||||
return '.webp';
|
||||
}
|
||||
}
|
||||
|
||||
private guessImageExtension(
|
||||
mimeType: string,
|
||||
image: HeterogeneousAgentImageAttachment,
|
||||
buffer: Buffer,
|
||||
): string | undefined {
|
||||
const knownByMime = IMAGE_EXTENSIONS_BY_MIME[this.normalizeMimeType(mimeType)];
|
||||
if (knownByMime) return knownByMime;
|
||||
|
||||
try {
|
||||
const pathname = new URL(image.url).pathname;
|
||||
const ext = path.extname(pathname).toLowerCase();
|
||||
if (ext) return ext === '.jpeg' ? '.jpg' : ext;
|
||||
} catch {
|
||||
// Fall through to byte sniffing below.
|
||||
}
|
||||
|
||||
return this.guessImageExtensionByBuffer(buffer);
|
||||
const plan = await buildAgentInput('claude-code', blocks, { cacheDir: this.fileCacheDir });
|
||||
return plan.stdin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize an image attachment into a stable local file path so CLIs like
|
||||
* Codex can consume it through `--image <file>`.
|
||||
* Materialize image attachments into stable filesystem paths for path-mode
|
||||
* agents (Codex `--image <file>`). Fails the prompt if any image cannot be
|
||||
* fetched / decoded — partially-attached prompts confuse the agent more
|
||||
* than they help.
|
||||
*/
|
||||
private async resolveCliImagePath(image: HeterogeneousAgentImageAttachment): Promise<string> {
|
||||
const { buffer, mimeType } = await this.resolveImage(image);
|
||||
const cacheKey = this.getImageCacheKey(image.id);
|
||||
const ext = this.guessImageExtension(mimeType, image, buffer);
|
||||
if (!ext) {
|
||||
throw new Error(`Unsupported image type for ${image.id}`);
|
||||
}
|
||||
|
||||
const filePath = path.join(this.fileCacheDir, `${cacheKey}${ext}`);
|
||||
|
||||
try {
|
||||
await access(filePath);
|
||||
} catch {
|
||||
await mkdir(this.fileCacheDir, { recursive: true });
|
||||
await writeFile(filePath, buffer);
|
||||
}
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
private async resolveCliImagePaths(
|
||||
imageList: HeterogeneousAgentImageAttachment[] = [],
|
||||
): Promise<string[]> {
|
||||
if (imageList.length === 0) return [];
|
||||
|
||||
const cacheDir = this.fileCacheDir;
|
||||
const results = await Promise.allSettled(
|
||||
imageList.map((image) => this.resolveCliImagePath(image)),
|
||||
imageList.map(async (image) => {
|
||||
const normalized = await normalizeImage(
|
||||
{ id: image.id, type: 'url', url: image.url },
|
||||
{ cacheDir },
|
||||
);
|
||||
return materializeImageToPath(normalized, cacheDir);
|
||||
}),
|
||||
);
|
||||
|
||||
const imagePaths: string[] = [];
|
||||
@@ -718,37 +649,6 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
return imagePaths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stream-json user message with text + optional image content blocks.
|
||||
*/
|
||||
private async buildStreamJsonInput(
|
||||
prompt: string,
|
||||
imageList: HeterogeneousAgentImageAttachment[] = [],
|
||||
): Promise<string> {
|
||||
const content: any[] = [{ text: prompt, type: 'text' }];
|
||||
|
||||
for (const image of imageList) {
|
||||
try {
|
||||
const { buffer, mimeType } = await this.resolveImage(image);
|
||||
content.push({
|
||||
source: {
|
||||
data: buffer.toString('base64'),
|
||||
media_type: mimeType,
|
||||
type: 'base64',
|
||||
},
|
||||
type: 'image',
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`Failed to resolve image ${image.id}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return `${JSON.stringify({
|
||||
message: { content, role: 'user' },
|
||||
type: 'user',
|
||||
})}\n`;
|
||||
}
|
||||
|
||||
// ─── IPC methods ───
|
||||
|
||||
/**
|
||||
@@ -779,8 +679,9 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
/**
|
||||
* Send a prompt to an agent session.
|
||||
*
|
||||
* Spawns the CLI process with preset flags. Broadcasts each stdout line
|
||||
* as an `heteroAgentRawLine` event — Renderer side parses and adapts.
|
||||
* Spawns the CLI process with preset flags. Pipes each stdout chunk through
|
||||
* the shared `AgentStreamPipeline` (JSONL → adapter → toStreamEvent) and
|
||||
* broadcasts the resulting `AgentStreamEvent`s on `heteroAgentEvent`.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async sendPrompt(params: SendPromptParams): Promise<void> {
|
||||
@@ -852,42 +753,49 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
session.process = proc;
|
||||
const streamProcessor = driver.createStreamProcessor();
|
||||
const codexFileChangeTracker =
|
||||
session.agentType === 'codex' ? new CodexFileChangeTracker() : undefined;
|
||||
|
||||
// Producer-side conversion (V3 contract): JSONL framing + adapter +
|
||||
// toStreamEvent all run inside the shared pipeline, so renderer + future
|
||||
// server `heteroIngest` see the same `AgentStreamEvent` wire shape with
|
||||
// no per-consumer adapter. The pipeline auto-wires the Codex
|
||||
// file-change line-stat tracker when `agentType === 'codex'`, so this
|
||||
// controller stays agent-agnostic.
|
||||
const pipeline = new AgentStreamPipeline({
|
||||
agentType: session.agentType,
|
||||
operationId: params.operationId,
|
||||
});
|
||||
let stdoutBroadcastQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
const broadcastParsedOutputs = (parsedOutputs: HeterogeneousAgentParsedOutput[]) => {
|
||||
const broadcastPipelineBatch = (produce: () => ReturnType<AgentStreamPipeline['push']>) => {
|
||||
stdoutBroadcastQueue = stdoutBroadcastQueue
|
||||
.then(async () => {
|
||||
for (const parsedOutput of parsedOutputs) {
|
||||
if (parsedOutput.agentSessionId) {
|
||||
session.agentSessionId = parsedOutput.agentSessionId;
|
||||
}
|
||||
|
||||
const line = codexFileChangeTracker
|
||||
? await codexFileChangeTracker.track(parsedOutput.payload)
|
||||
: parsedOutput.payload;
|
||||
|
||||
this.broadcast('heteroAgentRawLine', {
|
||||
line,
|
||||
const events = await produce();
|
||||
// Adapter-extracted CC/Codex session id powers `--resume` on the
|
||||
// next prompt; surface it through the existing `getSessionInfo`
|
||||
// IPC by mirroring the freshest value onto the session record.
|
||||
if (pipeline.sessionId && pipeline.sessionId !== session.agentSessionId) {
|
||||
session.agentSessionId = pipeline.sessionId;
|
||||
}
|
||||
for (const event of events) {
|
||||
this.broadcast('heteroAgentEvent', {
|
||||
event,
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Failed to broadcast parsed agent output:', error);
|
||||
logger.error('Failed to broadcast agent stream batch:', error);
|
||||
});
|
||||
};
|
||||
|
||||
// Stream stdout events as raw provider payloads to Renderer.
|
||||
// Stream stdout events through the producer pipeline.
|
||||
const stdout = proc.stdout as Readable;
|
||||
stdout.on('data', (chunk: Buffer) => {
|
||||
void this.appendCliTraceFile(traceSession, 'stdout.jsonl', chunk);
|
||||
broadcastParsedOutputs(streamProcessor.push(chunk));
|
||||
broadcastPipelineBatch(() => pipeline.push(chunk));
|
||||
});
|
||||
stdout.on('end', () => {
|
||||
broadcastParsedOutputs(streamProcessor.flush());
|
||||
broadcastPipelineBatch(() => pipeline.flush());
|
||||
});
|
||||
|
||||
// Capture stderr
|
||||
@@ -914,44 +822,59 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
});
|
||||
|
||||
proc.on('exit', (code, signal) => {
|
||||
void stdoutBroadcastQueue.finally(async () => {
|
||||
void this.writeCliTraceJson(traceSession, 'exit.json', {
|
||||
code,
|
||||
finishedAt: new Date().toISOString(),
|
||||
signal,
|
||||
});
|
||||
await this.flushCliTrace(traceSession);
|
||||
|
||||
logger.info('Agent process exited:', { code, sessionId: session.sessionId, signal });
|
||||
session.process = undefined;
|
||||
|
||||
// If *we* killed it (cancel / stop / before-quit), treat the non-zero
|
||||
// exit as a clean shutdown — surfacing it as an error would make a
|
||||
// user-initiated cancel look like an agent failure, and an Electron
|
||||
// shutdown affecting OTHER running CC sessions would pollute their
|
||||
// topics with a misleading "Agent exited with code 143" message.
|
||||
if (session.cancelledByUs) {
|
||||
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
|
||||
resolve();
|
||||
} else {
|
||||
const stderrOutput = stderrChunks.join('').trim();
|
||||
const errorMsg = this.getExitErrorMessage(code, session, stderrOutput);
|
||||
const sessionError = this.getSessionErrorPayload(errorMsg, session);
|
||||
this.broadcast('heteroAgentSessionError', {
|
||||
error: sessionError,
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
reject(
|
||||
new Error(typeof sessionError === 'string' ? sessionError : sessionError.message),
|
||||
);
|
||||
}
|
||||
// Node may emit `'exit'` BEFORE stdio finishes draining (documented:
|
||||
// child_process docs note "stdio streams might still be open" at exit
|
||||
// time). Wait for stdout to fully end/close so the `stdout.on('end')`
|
||||
// handler has scheduled `pipeline.flush()` onto `stdoutBroadcastQueue`,
|
||||
// THEN wait for the queue itself to settle. Without this two-step
|
||||
// gate, trailing flushed events (final synthesized tool_end /
|
||||
// tool_result) would race against — and lose to — the
|
||||
// `heteroAgentSessionComplete` broadcast, leaving renderer-side
|
||||
// persistence to finalize on incomplete state.
|
||||
const stdoutDrained = streamFinished(stdout, { writable: false }).catch(() => {
|
||||
/* end / close / error are all "done"; we still want to settle. */
|
||||
});
|
||||
|
||||
void stdoutDrained
|
||||
.then(() => stdoutBroadcastQueue)
|
||||
.finally(async () => {
|
||||
void this.writeCliTraceJson(traceSession, 'exit.json', {
|
||||
code,
|
||||
finishedAt: new Date().toISOString(),
|
||||
signal,
|
||||
});
|
||||
await this.flushCliTrace(traceSession);
|
||||
|
||||
logger.info('Agent process exited:', { code, sessionId: session.sessionId, signal });
|
||||
session.process = undefined;
|
||||
|
||||
// If *we* killed it (cancel / stop / before-quit), treat the non-zero
|
||||
// exit as a clean shutdown — surfacing it as an error would make a
|
||||
// user-initiated cancel look like an agent failure, and an Electron
|
||||
// shutdown affecting OTHER running CC sessions would pollute their
|
||||
// topics with a misleading "Agent exited with code 143" message.
|
||||
if (session.cancelledByUs) {
|
||||
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
|
||||
resolve();
|
||||
} else {
|
||||
const stderrOutput = stderrChunks.join('').trim();
|
||||
const errorMsg = this.getExitErrorMessage(code, session, stderrOutput);
|
||||
const sessionError = this.getSessionErrorPayload(errorMsg, session);
|
||||
this.broadcast('heteroAgentSessionError', {
|
||||
error: sessionError,
|
||||
sessionId: session.sessionId,
|
||||
});
|
||||
reject(
|
||||
new Error(typeof sessionError === 'string' ? sessionError : sessionError.message),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { constants } from 'node:fs';
|
||||
import { access, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises';
|
||||
import { access, mkdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
@@ -24,6 +24,9 @@ import {
|
||||
type PickFileResult,
|
||||
type PrepareSkillDirectoryParams,
|
||||
type PrepareSkillDirectoryResult,
|
||||
type ProjectFileIndexEntry,
|
||||
type ProjectFileIndexParams,
|
||||
type ProjectFileIndexResult,
|
||||
type RenameLocalFileResult,
|
||||
type ResolveSkillResourcePathParams,
|
||||
type ResolveSkillResourcePathResult,
|
||||
@@ -35,6 +38,7 @@ import {
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import {
|
||||
editLocalFile,
|
||||
expandTilde,
|
||||
listLocalFiles,
|
||||
moveLocalFiles,
|
||||
readLocalFile,
|
||||
@@ -42,6 +46,7 @@ import {
|
||||
writeLocalFile,
|
||||
} from '@lobechat/local-file-shell';
|
||||
import { dialog, shell } from 'electron';
|
||||
import { execa } from 'execa';
|
||||
import { unzipSync } from 'fflate';
|
||||
|
||||
import { type FileResult, type SearchOptions } from '@/modules/fileSearch';
|
||||
@@ -81,6 +86,50 @@ const resolveNearestExistingRealPath = async (targetPath: string): Promise<strin
|
||||
}
|
||||
};
|
||||
|
||||
const toPosixRelativePath = (filePath: string) => filePath.split(path.sep).join('/');
|
||||
|
||||
const createProjectFileEntry = (
|
||||
root: string,
|
||||
absolutePath: string,
|
||||
isDirectory: boolean,
|
||||
): ProjectFileIndexEntry => {
|
||||
const relativePath = toPosixRelativePath(path.relative(root, absolutePath));
|
||||
|
||||
return {
|
||||
isDirectory,
|
||||
name: path.basename(absolutePath),
|
||||
path: absolutePath,
|
||||
relativePath: isDirectory ? `${relativePath}/` : relativePath,
|
||||
};
|
||||
};
|
||||
|
||||
const collectProjectDirectories = (files: string[], root: string): ProjectFileIndexEntry[] => {
|
||||
const directories = new Set<string>();
|
||||
|
||||
for (const filePath of files) {
|
||||
let current = path.dirname(filePath);
|
||||
while (current && current !== root && current.startsWith(`${root}${path.sep}`)) {
|
||||
if (directories.has(current)) break;
|
||||
directories.add(current);
|
||||
current = path.dirname(current);
|
||||
}
|
||||
}
|
||||
|
||||
return [...directories].map((directory) => createProjectFileEntry(root, directory, true));
|
||||
};
|
||||
|
||||
const createDetectedProjectFileEntry = async (
|
||||
root: string,
|
||||
absolutePath: string,
|
||||
): Promise<ProjectFileIndexEntry> => {
|
||||
try {
|
||||
const stats = await stat(absolutePath);
|
||||
return createProjectFileEntry(root, absolutePath, stats.isDirectory());
|
||||
} catch {
|
||||
return createProjectFileEntry(root, absolutePath, false);
|
||||
}
|
||||
};
|
||||
|
||||
const resolveSafePathRealPrefixes = async (): Promise<string[]> => {
|
||||
const prefixes = new Set<string>(SAFE_PATH_PREFIXES);
|
||||
|
||||
@@ -413,14 +462,127 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
|
||||
// ==================== Search & Find ====================
|
||||
|
||||
@IpcMethod()
|
||||
async getProjectFileIndex(params: ProjectFileIndexParams = {}): Promise<ProjectFileIndexResult> {
|
||||
const requestedScope = params.scope || process.cwd();
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
const rootResult = await execa(
|
||||
'git',
|
||||
['-C', requestedScope, 'rev-parse', '--show-toplevel'],
|
||||
{
|
||||
reject: false,
|
||||
timeout: 5000,
|
||||
},
|
||||
);
|
||||
const root = rootResult.exitCode === 0 ? rootResult.stdout.trim() : requestedScope;
|
||||
|
||||
if (rootResult.exitCode === 0) {
|
||||
const [trackedResult, untrackedResult] = await Promise.all([
|
||||
execa(
|
||||
'git',
|
||||
['-C', root, '-c', 'core.quotepath=false', 'ls-files', '--recurse-submodules'],
|
||||
{
|
||||
reject: false,
|
||||
timeout: 10_000,
|
||||
},
|
||||
),
|
||||
execa(
|
||||
'git',
|
||||
[
|
||||
'-C',
|
||||
root,
|
||||
'-c',
|
||||
'core.quotepath=false',
|
||||
'ls-files',
|
||||
'--others',
|
||||
'--exclude-standard',
|
||||
],
|
||||
{ reject: false, timeout: 10_000 },
|
||||
),
|
||||
]);
|
||||
|
||||
if (trackedResult.exitCode !== 0) {
|
||||
throw new Error(trackedResult.stderr || 'git ls-files failed');
|
||||
}
|
||||
|
||||
const files = [
|
||||
...trackedResult.stdout.split('\n'),
|
||||
...(untrackedResult.exitCode === 0 ? untrackedResult.stdout.split('\n') : []),
|
||||
]
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.map((relativePath) => path.resolve(root, relativePath));
|
||||
|
||||
const seen = new Set<string>();
|
||||
const fileEntries = files
|
||||
.filter((filePath) => {
|
||||
if (seen.has(filePath)) return false;
|
||||
seen.add(filePath);
|
||||
return true;
|
||||
})
|
||||
.map((filePath) => createProjectFileEntry(root, filePath, false));
|
||||
|
||||
const entries = [...collectProjectDirectories(files, root), ...fileEntries];
|
||||
logger.debug('Project file index built from git', {
|
||||
duration: Date.now() - startedAt,
|
||||
entries: entries.length,
|
||||
files: fileEntries.length,
|
||||
requestedScope,
|
||||
root,
|
||||
});
|
||||
|
||||
return {
|
||||
entries,
|
||||
indexedAt: new Date().toISOString(),
|
||||
root,
|
||||
source: 'git',
|
||||
totalCount: entries.length,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('Git project file index failed, falling back to glob', {
|
||||
error,
|
||||
requestedScope,
|
||||
});
|
||||
}
|
||||
|
||||
const fallback = await this.searchService.glob({ pattern: '**/*', scope: requestedScope });
|
||||
const files = fallback.files.map((filePath) => path.resolve(filePath));
|
||||
const entries = await Promise.all(
|
||||
files.map((filePath) => createDetectedProjectFileEntry(requestedScope, filePath)),
|
||||
);
|
||||
|
||||
logger.debug('Project file index built from glob', {
|
||||
duration: Date.now() - startedAt,
|
||||
entries: entries.length,
|
||||
engine: fallback.engine,
|
||||
requestedScope,
|
||||
});
|
||||
|
||||
return {
|
||||
entries,
|
||||
indexedAt: new Date().toISOString(),
|
||||
root: requestedScope,
|
||||
source: 'glob',
|
||||
totalCount: entries.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle IPC event for local file search
|
||||
*/
|
||||
@IpcMethod()
|
||||
async handleLocalFilesSearch(params: LocalSearchFilesParams): Promise<FileResult[]> {
|
||||
const effectiveDirectory = expandTilde(params.directory ?? params.scope);
|
||||
|
||||
logger.debug('Received file search request:', {
|
||||
directory: params.directory,
|
||||
effectiveDirectory,
|
||||
limit: params.limit,
|
||||
keywords: params.keywords,
|
||||
scope: params.scope,
|
||||
});
|
||||
|
||||
// Build search options from params, mapping directory to onlyIn
|
||||
@@ -436,7 +598,7 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
liveUpdate: params.liveUpdate,
|
||||
modifiedAfter: params.modifiedAfter ? new Date(params.modifiedAfter) : undefined,
|
||||
modifiedBefore: params.modifiedBefore ? new Date(params.modifiedBefore) : undefined,
|
||||
onlyIn: params.directory, // Map directory param to onlyIn option
|
||||
onlyIn: effectiveDirectory,
|
||||
sortBy: params.sortBy,
|
||||
sortDirection: params.sortDirection,
|
||||
};
|
||||
@@ -446,6 +608,14 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
logger.debug('File search completed', {
|
||||
count: results.length,
|
||||
directory: params.directory,
|
||||
effectiveDirectory,
|
||||
results: results.slice(0, 5).map((result) => ({
|
||||
engine: result.engine,
|
||||
isDirectory: result.isDirectory,
|
||||
name: result.name,
|
||||
path: result.path,
|
||||
})),
|
||||
scope: params.scope,
|
||||
});
|
||||
return results;
|
||||
} catch (error) {
|
||||
|
||||
@@ -19,6 +19,26 @@ export default class TrayMenuCtr extends ControllerModule {
|
||||
mainWindow.toggleVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get whether the application tray is visible.
|
||||
*/
|
||||
@IpcMethod()
|
||||
getAppTrayVisible(): boolean {
|
||||
return this.app.storeManager.get('appTrayVisible', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist and apply application tray visibility.
|
||||
*/
|
||||
@IpcMethod()
|
||||
setAppTrayVisible(visible: boolean) {
|
||||
logger.debug(`Set app tray visibility: ${visible}`);
|
||||
this.app.storeManager.set('appTrayVisible', visible);
|
||||
this.app.trayManager.setAppTrayVisible(visible);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Show tray balloon notification
|
||||
* @param options Balloon options
|
||||
|
||||
@@ -433,18 +433,23 @@ describe('GatewayConnectionCtr', () => {
|
||||
}
|
||||
|
||||
it.each([
|
||||
['readLocalFile', 'readFile', mockLocalFileCtr],
|
||||
['listLocalFiles', 'listLocalFiles', mockLocalFileCtr],
|
||||
['moveLocalFiles', 'handleMoveFiles', mockLocalFileCtr],
|
||||
['renameLocalFile', 'handleRenameFile', mockLocalFileCtr],
|
||||
['searchLocalFiles', 'handleLocalFilesSearch', mockLocalFileCtr],
|
||||
['writeLocalFile', 'handleWriteFile', mockLocalFileCtr],
|
||||
['editLocalFile', 'handleEditFile', mockLocalFileCtr],
|
||||
['globLocalFiles', 'handleGlobFiles', mockLocalFileCtr],
|
||||
['readFile', 'readFile', mockLocalFileCtr],
|
||||
['listFiles', 'listLocalFiles', mockLocalFileCtr],
|
||||
['moveFiles', 'handleMoveFiles', mockLocalFileCtr],
|
||||
['searchFiles', 'handleLocalFilesSearch', mockLocalFileCtr],
|
||||
['writeFile', 'handleWriteFile', mockLocalFileCtr],
|
||||
['editFile', 'handleEditFile', mockLocalFileCtr],
|
||||
['globFiles', 'handleGlobFiles', mockLocalFileCtr],
|
||||
['grepContent', 'handleGrepContent', mockLocalFileCtr],
|
||||
['runCommand', 'handleRunCommand', mockShellCommandCtr],
|
||||
['getCommandOutput', 'handleGetCommandOutput', mockShellCommandCtr],
|
||||
['killCommand', 'handleKillCommand', mockShellCommandCtr],
|
||||
// Legacy aliases — older Gateway versions may still send the long form.
|
||||
// `renameLocalFile` is kept even though the new surface drops rename.
|
||||
['readLocalFile', 'readFile', mockLocalFileCtr],
|
||||
['listLocalFiles', 'listLocalFiles', mockLocalFileCtr],
|
||||
['writeLocalFile', 'handleWriteFile', mockLocalFileCtr],
|
||||
['renameLocalFile', 'handleRenameFile', mockLocalFileCtr],
|
||||
] as const)('should route %s to %s', async (apiName, methodName, controller) => {
|
||||
const client = await connectAndOpen();
|
||||
const args = { test: 'arg' };
|
||||
@@ -470,7 +475,7 @@ describe('GatewayConnectionCtr', () => {
|
||||
});
|
||||
const client = await connectAndOpen();
|
||||
|
||||
client.simulateToolCallRequest('readLocalFile', { path: '/a.txt' }, 'req-42');
|
||||
client.simulateToolCallRequest('readFile', { path: '/a.txt' }, 'req-42');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(client.sendToolCallResponse).toHaveBeenCalledWith({
|
||||
@@ -497,7 +502,7 @@ describe('GatewayConnectionCtr', () => {
|
||||
vi.mocked(mockLocalFileCtr.readFile).mockRejectedValueOnce(new Error('File not found'));
|
||||
const client = await connectAndOpen();
|
||||
|
||||
client.simulateToolCallRequest('readLocalFile', { path: '/missing' }, 'req-err');
|
||||
client.simulateToolCallRequest('readFile', { path: '/missing' }, 'req-err');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(client.sendToolCallResponse).toHaveBeenCalledWith({
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { dequoteGitPath, quoteGitPath } from '../GitCtr';
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('quoteGitPath', () => {
|
||||
it('leaves plain ASCII paths unquoted (including spaces)', () => {
|
||||
expect(quoteGitPath('a/', 'src/foo.ts')).toBe('a/src/foo.ts');
|
||||
expect(quoteGitPath('b/', 'src/foo bar.ts')).toBe('b/src/foo bar.ts');
|
||||
expect(quoteGitPath('a/', 'with-dash_and.underscore')).toBe('a/with-dash_and.underscore');
|
||||
});
|
||||
|
||||
it('C-style escapes TAB / LF / CR / quote / backslash', () => {
|
||||
expect(quoteGitPath('b/', 'with\ttab.txt')).toBe('"b/with\\ttab.txt"');
|
||||
expect(quoteGitPath('b/', 'with\nlf.txt')).toBe('"b/with\\nlf.txt"');
|
||||
expect(quoteGitPath('b/', 'with\rcr.txt')).toBe('"b/with\\rcr.txt"');
|
||||
expect(quoteGitPath('b/', 'with"quote.txt')).toBe('"b/with\\"quote.txt"');
|
||||
expect(quoteGitPath('b/', 'with\\backslash.txt')).toBe('"b/with\\\\backslash.txt"');
|
||||
});
|
||||
|
||||
it('octal-escapes other control bytes (NUL, 0x1F, DEL)', () => {
|
||||
expect(quoteGitPath('a/', 'nul\x00here')).toBe('"a/nul\\000here"');
|
||||
expect(quoteGitPath('a/', 'unit\x1Fsep')).toBe('"a/unit\\037sep"');
|
||||
expect(quoteGitPath('a/', 'del\x7Fchar')).toBe('"a/del\\177char"');
|
||||
});
|
||||
|
||||
it('puts the prefix inside the quotes', () => {
|
||||
// Real git output for `git diff` of a tab-containing file:
|
||||
// diff --git "a/with\there" "b/with\there"
|
||||
expect(quoteGitPath('a/', 'with\there')).toBe('"a/with\\there"');
|
||||
expect(quoteGitPath('b/', 'with\there')).toBe('"b/with\\there"');
|
||||
});
|
||||
|
||||
it('round-trips through dequoteGitPath for problem characters', () => {
|
||||
const cases = [
|
||||
'with\ttab.txt',
|
||||
'with\nlf.txt',
|
||||
'with\rcr.txt',
|
||||
'with"quote.txt',
|
||||
'with\\backslash.txt',
|
||||
'nul\x00inside',
|
||||
'mix\t"of\\everything\n',
|
||||
];
|
||||
for (const original of cases) {
|
||||
const quoted = quoteGitPath('b/', original);
|
||||
// Strip the surrounding quotes + b/ prefix, then de-escape.
|
||||
expect(quoted.startsWith('"b/')).toBe(true);
|
||||
expect(quoted.endsWith('"')).toBe(true);
|
||||
const stripped = quoted.slice(1, -1).slice('b/'.length);
|
||||
expect(dequoteGitPath(stripped)).toBe(original);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('dequoteGitPath', () => {
|
||||
it('decodes named C-style escapes', () => {
|
||||
expect(dequoteGitPath('with\\ttab')).toBe('with\ttab');
|
||||
expect(dequoteGitPath('with\\nlf')).toBe('with\nlf');
|
||||
expect(dequoteGitPath('with\\rcr')).toBe('with\rcr');
|
||||
expect(dequoteGitPath('with\\"quote')).toBe('with"quote');
|
||||
expect(dequoteGitPath('with\\\\bs')).toBe('with\\bs');
|
||||
});
|
||||
|
||||
it('decodes 3-digit octal escapes', () => {
|
||||
expect(dequoteGitPath('nul\\000here')).toBe('nul\x00here');
|
||||
expect(dequoteGitPath('unit\\037sep')).toBe('unit\x1Fsep');
|
||||
expect(dequoteGitPath('del\\177char')).toBe('del\x7Fchar');
|
||||
});
|
||||
|
||||
it('leaves unescaped chars alone', () => {
|
||||
expect(dequoteGitPath('plain ascii here')).toBe('plain ascii here');
|
||||
});
|
||||
});
|
||||
@@ -11,8 +11,12 @@ import HeterogeneousAgentCtr from '../HeterogeneousAgentCtr';
|
||||
|
||||
const FAKE_DESKTOP_PATH = '/Users/fake/Desktop';
|
||||
|
||||
const { mockGetAllWindows } = vi.hoisted(() => ({
|
||||
mockGetAllWindows: vi.fn<() => any[]>(() => []),
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: { getAllWindows: () => [] },
|
||||
BrowserWindow: { getAllWindows: () => mockGetAllWindows() },
|
||||
app: {
|
||||
getPath: vi.fn((name: string) => (name === 'desktop' ? FAKE_DESKTOP_PATH : `/fake/${name}`)),
|
||||
isPackaged: false,
|
||||
@@ -114,13 +118,24 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
await rm(appStoragePath, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
describe('resolveImage', () => {
|
||||
describe('image cache (delegates to shared `normalizeImage`)', () => {
|
||||
// Image fetch + cache moved to `@lobechat/heterogeneous-agents/spawn`'s
|
||||
// `normalizeImage`. The desktop controller passes its own cacheDir so the
|
||||
// path-traversal invariant — id segments like `../../foo` MUST be hashed,
|
||||
// never used as path segments — is enforced by the shared helper. Verify
|
||||
// that invariant against the same cacheDir the controller would use.
|
||||
const fixtureCacheDir = (storage: string) => path.join(storage, 'heteroAgent/files');
|
||||
const importNormalize = async () => {
|
||||
const { mkdir } = await import('node:fs/promises');
|
||||
const mod = await import('@lobechat/heterogeneous-agents/spawn');
|
||||
return { mkdir, normalizeImage: mod.normalizeImage };
|
||||
};
|
||||
|
||||
it('stores traversal-looking ids inside the cache root via a stable hash key', async () => {
|
||||
const ctr = new HeterogeneousAgentCtr({
|
||||
appStoragePath,
|
||||
storeManager: { get: vi.fn() },
|
||||
} as any);
|
||||
const cacheDir = path.join(appStoragePath, 'heteroAgent/files');
|
||||
const { mkdir, normalizeImage } = await importNormalize();
|
||||
const cacheDir = fixtureCacheDir(appStoragePath);
|
||||
await mkdir(cacheDir, { recursive: true });
|
||||
|
||||
const escapedTargetName = `${path.basename(appStoragePath)}-outside-storage`;
|
||||
const escapePath = path.join(cacheDir, `../../../${escapedTargetName}`);
|
||||
|
||||
@@ -130,10 +145,14 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
// best-effort cleanup
|
||||
}
|
||||
|
||||
await (ctr as any).resolveImage({
|
||||
id: `../../../${escapedTargetName}`,
|
||||
url: 'data:text/plain;base64,T1VUU0lERQ==',
|
||||
});
|
||||
await normalizeImage(
|
||||
{
|
||||
id: `../../../${escapedTargetName}`,
|
||||
type: 'url',
|
||||
url: 'data:text/plain;base64,T1VUU0lERQ==',
|
||||
},
|
||||
{ cacheDir, fetcher: (async () => new Response('OUTSIDE', { status: 200 })) as any },
|
||||
);
|
||||
|
||||
const cacheEntries = await readdir(cacheDir);
|
||||
|
||||
@@ -149,11 +168,10 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
});
|
||||
|
||||
it('does not trust pre-seeded out-of-root traversal cache files as cache hits', async () => {
|
||||
const ctr = new HeterogeneousAgentCtr({
|
||||
appStoragePath,
|
||||
storeManager: { get: vi.fn() },
|
||||
} as any);
|
||||
const cacheDir = path.join(appStoragePath, 'heteroAgent/files');
|
||||
const { mkdir, normalizeImage } = await importNormalize();
|
||||
const cacheDir = fixtureCacheDir(appStoragePath);
|
||||
await mkdir(cacheDir, { recursive: true });
|
||||
|
||||
const traversalId = '../../preexisting-secret';
|
||||
const outOfRootDataPath = path.join(cacheDir, traversalId);
|
||||
const outOfRootMetaPath = path.join(cacheDir, `${traversalId}.meta`);
|
||||
@@ -164,13 +182,20 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
JSON.stringify({ id: traversalId, mimeType: 'text/plain' }),
|
||||
);
|
||||
|
||||
const result = await (ctr as any).resolveImage({
|
||||
id: traversalId,
|
||||
url: 'data:text/plain;base64,SUdOT1JFRA==',
|
||||
});
|
||||
const result = await normalizeImage(
|
||||
{ id: traversalId, type: 'url', url: 'data:text/plain;base64,SUdOT1JFRA==' },
|
||||
{
|
||||
cacheDir,
|
||||
fetcher: (async () =>
|
||||
new Response('IGNORED', {
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
status: 200,
|
||||
})) as any,
|
||||
},
|
||||
);
|
||||
|
||||
expect(Buffer.from(result.buffer).toString('utf8')).toBe('IGNORED');
|
||||
expect(result.mimeType).toBe('text/plain');
|
||||
expect(result.mediaType).toBe('text/plain');
|
||||
await expect(readFile(outOfRootDataPath, 'utf8')).resolves.toBe('SECRET');
|
||||
});
|
||||
});
|
||||
@@ -185,6 +210,7 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
prompt: string,
|
||||
sessionOverrides: Record<string, any> = {},
|
||||
stdoutLines: string[] = [],
|
||||
sendPromptOverrides: Partial<{ imageList: Array<{ id: string; url: string }> }> = {},
|
||||
) => {
|
||||
const { proc, writes } = createFakeProc({ stdoutLines });
|
||||
nextFakeProc = proc;
|
||||
@@ -198,7 +224,7 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
command: 'claude',
|
||||
...sessionOverrides,
|
||||
});
|
||||
await ctr.sendPrompt({ prompt, sessionId });
|
||||
await ctr.sendPrompt({ operationId: 'op-test', prompt, sessionId, ...sendPromptOverrides });
|
||||
|
||||
const { args: cliArgs, command, options } = spawnCalls[0];
|
||||
return { cliArgs, command, ctr, options, sessionId, writes };
|
||||
@@ -261,6 +287,23 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
expect(options.cwd).toBe(explicitCwd);
|
||||
});
|
||||
|
||||
it('omits the empty text block when only images are attached', async () => {
|
||||
const { writes } = await runSendPrompt('', {}, [], {
|
||||
imageList: [{ id: 'image-1', url: 'data:image/png;base64,UE5HX1RFU1Q=' }],
|
||||
});
|
||||
|
||||
expect(writes).toHaveLength(1);
|
||||
const msg = JSON.parse(writes[0].trimEnd());
|
||||
// Anthropic rejects `{ text: '', type: 'text' }` with
|
||||
// "messages: text content blocks must be non-empty".
|
||||
expect(msg.message.content).toEqual([
|
||||
{
|
||||
source: { data: 'UE5HX1RFU1Q=', media_type: 'image/png', type: 'base64' },
|
||||
type: 'image',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('captures the Claude Code session id from stream-json init events', async () => {
|
||||
const { ctr, sessionId } = await runSendPrompt('hello', {}, [
|
||||
`${JSON.stringify({ session_id: 'sess_cc_123', subtype: 'init', type: 'system' })}\n`,
|
||||
@@ -296,7 +339,7 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
command: 'codex',
|
||||
...sessionOverrides,
|
||||
});
|
||||
await ctr.sendPrompt({ prompt, sessionId, ...sendPromptOverrides });
|
||||
await ctr.sendPrompt({ operationId: 'op-test', prompt, sessionId, ...sendPromptOverrides });
|
||||
|
||||
const { args: cliArgs, command, options } = spawnCalls[0];
|
||||
return { cliArgs, command, ctr, options, sessionId, writes };
|
||||
@@ -314,9 +357,9 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
command: 'codex',
|
||||
});
|
||||
|
||||
await expect(ctr.sendPrompt({ prompt: 'hello', sessionId })).rejects.toThrow(
|
||||
'Codex CLI was not found',
|
||||
);
|
||||
await expect(
|
||||
ctr.sendPrompt({ operationId: 'op-test', prompt: 'hello', sessionId }),
|
||||
).rejects.toThrow('Codex CLI was not found');
|
||||
|
||||
expect(detect).toHaveBeenCalledWith('codex', true);
|
||||
expect(spawnCalls).toHaveLength(0);
|
||||
@@ -334,9 +377,9 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
command: 'claude',
|
||||
});
|
||||
|
||||
await expect(ctr.sendPrompt({ prompt: 'hello', sessionId })).rejects.toThrow(
|
||||
'Claude Code CLI was not found',
|
||||
);
|
||||
await expect(
|
||||
ctr.sendPrompt({ operationId: 'op-test', prompt: 'hello', sessionId }),
|
||||
).rejects.toThrow('Claude Code CLI was not found');
|
||||
|
||||
expect(detect).toHaveBeenCalledWith('claude', true);
|
||||
expect(spawnCalls).toHaveLength(0);
|
||||
@@ -372,9 +415,9 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
command: 'claude-alt',
|
||||
});
|
||||
|
||||
await expect(ctr.sendPrompt({ prompt: 'hello', sessionId })).rejects.toThrow(
|
||||
'Claude Code CLI was not found',
|
||||
);
|
||||
await expect(
|
||||
ctr.sendPrompt({ operationId: 'op-test', prompt: 'hello', sessionId }),
|
||||
).rejects.toThrow('Claude Code CLI was not found');
|
||||
|
||||
expect(detect).not.toHaveBeenCalled();
|
||||
expect(spawnCalls).toHaveLength(0);
|
||||
@@ -475,6 +518,7 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
await expect(
|
||||
ctr.sendPrompt({
|
||||
imageList,
|
||||
operationId: 'op-test',
|
||||
prompt: 'inspect the screenshots',
|
||||
sessionId,
|
||||
}),
|
||||
@@ -508,9 +552,9 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
command: 'codex',
|
||||
});
|
||||
|
||||
await expect(ctr.sendPrompt({ prompt: 'hello', sessionId })).rejects.toThrow(
|
||||
'Agent exited with code 1',
|
||||
);
|
||||
await expect(
|
||||
ctr.sendPrompt({ operationId: 'op-test', prompt: 'hello', sessionId }),
|
||||
).rejects.toThrow('Agent exited with code 1');
|
||||
});
|
||||
|
||||
it('uses codex exec resume syntax when continuing an existing thread', async () => {
|
||||
@@ -654,4 +698,108 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Node may emit `proc.on('exit')` BEFORE stdout fully drains (documented in
|
||||
* child_process docs as "stdio streams might still be open"). The phase 0
|
||||
* refactor moved adapter ownership to main, so renderer no longer flushes
|
||||
* its own adapter on session-complete — meaning trailing events from
|
||||
* `pipeline.flush()` (e.g. Codex's synthesized `tool_end` for unfinished
|
||||
* tool calls) would race against — and lose to — the
|
||||
* `heteroAgentSessionComplete` broadcast without an explicit gate.
|
||||
*
|
||||
* The fix in `proc.on('exit')` is to await stdout `'end'/'close'` (so the
|
||||
* `stdout.on('end')` handler can schedule `pipeline.flush()` onto the
|
||||
* broadcast queue), then drain the queue, then broadcast complete.
|
||||
*/
|
||||
describe('exit-before-end ordering (LOBE-8516 phase 0 race)', () => {
|
||||
let broadcasts: Array<{ channel: string; data: any }>;
|
||||
|
||||
beforeEach(() => {
|
||||
spawnCalls.length = 0;
|
||||
execFileMock.mockReset();
|
||||
broadcasts = [];
|
||||
mockGetAllWindows.mockImplementation(() => [
|
||||
{
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
send: (channel: string, data: any) => broadcasts.push({ channel, data }),
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockGetAllWindows.mockReset();
|
||||
mockGetAllWindows.mockReturnValue([]);
|
||||
});
|
||||
|
||||
it('delivers pipeline.flush() events BEFORE heteroAgentSessionComplete even when proc exit precedes stdout end', async () => {
|
||||
// Codex `item.started` for a tool — adapter buffers it as a pending
|
||||
// tool call. On flush, adapter synthesizes a trailing `tool_end`. This
|
||||
// is exactly the kind of event the race would lose against complete.
|
||||
const itemStarted = `${JSON.stringify({
|
||||
item: {
|
||||
aggregated_output: '',
|
||||
command: 'echo hi',
|
||||
id: 'cmd-1',
|
||||
status: 'in_progress',
|
||||
type: 'command_execution',
|
||||
},
|
||||
type: 'item.started',
|
||||
})}\n`;
|
||||
const threadStarted = `${JSON.stringify({ thread_id: 't1', type: 'thread.started' })}\n`;
|
||||
|
||||
const proc = new EventEmitter() as any;
|
||||
const stdout = new PassThrough();
|
||||
const stderr = new PassThrough();
|
||||
proc.stdout = stdout;
|
||||
proc.stderr = stderr;
|
||||
proc.stdin = {
|
||||
end: vi.fn(),
|
||||
write: vi.fn((_chunk: any, cb?: () => void) => {
|
||||
cb?.();
|
||||
return true;
|
||||
}),
|
||||
};
|
||||
proc.kill = vi.fn();
|
||||
proc.killed = false;
|
||||
proc.__start = () => {
|
||||
setImmediate(() => {
|
||||
stdout.write(threadStarted);
|
||||
stdout.write(itemStarted);
|
||||
stderr.end();
|
||||
// ⚠️ Reproduce the documented Node race: emit exit BEFORE stdout
|
||||
// ends. Without the streamFinished gate in the controller, the
|
||||
// broadcast queue settles immediately (no flush queued yet) and
|
||||
// complete fires before the trailing tool_end ever broadcasts.
|
||||
proc.emit('exit', 0);
|
||||
setImmediate(() => stdout.end());
|
||||
});
|
||||
};
|
||||
nextFakeProc = proc;
|
||||
|
||||
const ctr = new HeterogeneousAgentCtr({
|
||||
appStoragePath,
|
||||
storeManager: { get: vi.fn() },
|
||||
} as any);
|
||||
const { sessionId } = await ctr.startSession({ agentType: 'codex', command: 'codex' });
|
||||
await ctr.sendPrompt({ operationId: 'op-test', prompt: 'hello', sessionId });
|
||||
|
||||
const events = broadcasts.filter((b) => b.channel === 'heteroAgentEvent');
|
||||
const completeIdx = broadcasts.findIndex((b) => b.channel === 'heteroAgentSessionComplete');
|
||||
const lastEventIdx = broadcasts.findLastIndex((b) => b.channel === 'heteroAgentEvent');
|
||||
|
||||
expect(completeIdx).toBeGreaterThan(-1);
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
// Every stream event must land before complete — no trailing events
|
||||
// sneak in after the renderer has been told the session is done.
|
||||
expect(lastEventIdx).toBeLessThan(completeIdx);
|
||||
|
||||
// Specifically: the synthesized tool_end for the pending command
|
||||
// execution (emitted only by adapter.flush()) is in the broadcast.
|
||||
const toolEnds = events.filter((b) => (b.data as any)?.event?.type === 'tool_end');
|
||||
expect(toolEnds.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,8 @@ import { type App } from '@/core/App';
|
||||
|
||||
import LocalFileCtr from '../LocalFileCtr';
|
||||
|
||||
const { ipcMainHandleMock, fetchMock } = vi.hoisted(() => ({
|
||||
const { execaMock, ipcMainHandleMock, fetchMock } = vi.hoisted(() => ({
|
||||
execaMock: vi.fn(),
|
||||
ipcMainHandleMock: vi.fn(),
|
||||
fetchMock: vi.fn(),
|
||||
}));
|
||||
@@ -14,6 +15,10 @@ vi.mock('@/utils/net-fetch', () => ({
|
||||
netFetch: fetchMock,
|
||||
}));
|
||||
|
||||
vi.mock('execa', () => ({
|
||||
execa: execaMock,
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
@@ -535,6 +540,18 @@ describe('LocalFileCtr', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should use scope as the default search directory', async () => {
|
||||
mockSearchService.search.mockResolvedValue([]);
|
||||
|
||||
await localFileCtr.handleLocalFilesSearch({ keywords: 'src', scope: '/workspace/project' });
|
||||
|
||||
expect(mockSearchService.search).toHaveBeenCalledWith('src', {
|
||||
keywords: 'src',
|
||||
limit: 30,
|
||||
onlyIn: '/workspace/project',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array on search error', async () => {
|
||||
mockSearchService.search.mockRejectedValue(new Error('Search failed'));
|
||||
|
||||
@@ -544,6 +561,94 @@ describe('LocalFileCtr', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProjectFileIndex', () => {
|
||||
it('should build a project file index from git files', async () => {
|
||||
execaMock
|
||||
.mockResolvedValueOnce({ exitCode: 0, stdout: '/workspace/project' })
|
||||
.mockResolvedValueOnce({
|
||||
exitCode: 0,
|
||||
stdout: 'src/index.ts\nsrc/components/Button.tsx',
|
||||
})
|
||||
.mockResolvedValueOnce({ exitCode: 0, stdout: 'tmp/local.ts' });
|
||||
|
||||
const result = await localFileCtr.getProjectFileIndex({ scope: '/workspace/project' });
|
||||
|
||||
expect(result.source).toBe('git');
|
||||
expect(result.root).toBe('/workspace/project');
|
||||
expect(result.entries).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
isDirectory: true,
|
||||
path: '/workspace/project/src',
|
||||
relativePath: 'src/',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
isDirectory: false,
|
||||
path: '/workspace/project/src/index.ts',
|
||||
relativePath: 'src/index.ts',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
isDirectory: false,
|
||||
path: '/workspace/project/tmp/local.ts',
|
||||
relativePath: 'tmp/local.ts',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(result.totalCount).toBe(result.entries.length);
|
||||
});
|
||||
|
||||
it('should fall back to glob when git indexing fails', async () => {
|
||||
execaMock.mockResolvedValueOnce({ exitCode: 1, stdout: '' });
|
||||
mockSearchService.glob.mockResolvedValue({
|
||||
engine: 'fast-glob',
|
||||
files: ['/workspace/project/src', '/workspace/project/src/index.ts'],
|
||||
success: true,
|
||||
total_files: 2,
|
||||
});
|
||||
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath: string) => ({
|
||||
isDirectory: () => filePath === '/workspace/project/src',
|
||||
}));
|
||||
|
||||
const result = await localFileCtr.getProjectFileIndex({ scope: '/workspace/project' });
|
||||
|
||||
expect(result.source).toBe('glob');
|
||||
expect(result.entries).toEqual([
|
||||
expect.objectContaining({
|
||||
isDirectory: true,
|
||||
path: '/workspace/project/src',
|
||||
relativePath: 'src/',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
isDirectory: false,
|
||||
path: '/workspace/project/src/index.ts',
|
||||
relativePath: 'src/index.ts',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should mark glob entries as files when stat fails', async () => {
|
||||
execaMock.mockResolvedValueOnce({ exitCode: 1, stdout: '' });
|
||||
mockSearchService.glob.mockResolvedValue({
|
||||
engine: 'fast-glob',
|
||||
files: ['/workspace/project/src/index.ts'],
|
||||
success: true,
|
||||
total_files: 1,
|
||||
});
|
||||
vi.mocked(mockFsPromises.stat).mockRejectedValue(new Error('missing'));
|
||||
|
||||
const result = await localFileCtr.getProjectFileIndex({ scope: '/workspace/project' });
|
||||
|
||||
expect(result.source).toBe('glob');
|
||||
expect(result.entries).toEqual([
|
||||
expect.objectContaining({
|
||||
isDirectory: false,
|
||||
path: '/workspace/project/src/index.ts',
|
||||
relativePath: 'src/index.ts',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleGlobFiles', () => {
|
||||
it('should glob files successfully', async () => {
|
||||
const mockResult = {
|
||||
|
||||
@@ -40,13 +40,21 @@ const mockDisplayBalloon = vi.fn();
|
||||
const mockUpdateIcon = vi.fn();
|
||||
const mockUpdateTooltip = vi.fn();
|
||||
const mockGetMainTray = vi.fn();
|
||||
const mockSetAppTrayVisible = vi.fn();
|
||||
const mockStoreGet = vi.fn(() => true);
|
||||
const mockStoreSet = vi.fn();
|
||||
|
||||
const mockApp = {
|
||||
browserManager: {
|
||||
getMainWindow: mockGetMainWindow,
|
||||
},
|
||||
storeManager: {
|
||||
get: mockStoreGet,
|
||||
set: mockStoreSet,
|
||||
},
|
||||
trayManager: {
|
||||
getMainTray: mockGetMainTray,
|
||||
setAppTrayVisible: mockSetAppTrayVisible,
|
||||
},
|
||||
} as unknown as App;
|
||||
|
||||
@@ -58,9 +66,31 @@ describe('TrayMenuCtr', () => {
|
||||
ipcMainHandleMock.mockClear();
|
||||
// Reset mockedTray for each test
|
||||
mockGetMainTray.mockReset();
|
||||
mockStoreGet.mockReturnValue(true);
|
||||
trayMenuCtr = new TrayMenuCtr(mockApp);
|
||||
});
|
||||
|
||||
describe('getAppTrayVisible', () => {
|
||||
it('should return stored app tray visibility', () => {
|
||||
mockStoreGet.mockReturnValue(false);
|
||||
|
||||
const result = trayMenuCtr.getAppTrayVisible();
|
||||
|
||||
expect(mockStoreGet).toHaveBeenCalledWith('appTrayVisible', true);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setAppTrayVisible', () => {
|
||||
it('should persist and apply app tray visibility', () => {
|
||||
const result = trayMenuCtr.setAppTrayVisible(false);
|
||||
|
||||
expect(mockStoreSet).toHaveBeenCalledWith('appTrayVisible', false);
|
||||
expect(mockSetAppTrayVisible).toHaveBeenCalledWith(false);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
// Restore platform settings after all tests complete
|
||||
afterAll(() => {
|
||||
// Restore the original platform
|
||||
|
||||
@@ -39,6 +39,12 @@ export class TrayManager {
|
||||
initializeTrays() {
|
||||
logger.debug('Initialize application tray');
|
||||
|
||||
if (!this.app.storeManager.get('appTrayVisible', true)) {
|
||||
logger.debug('Application tray is disabled by user settings');
|
||||
this.destroyAll();
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize main tray
|
||||
const mainTray = this.initializeMainTray();
|
||||
|
||||
@@ -58,6 +64,19 @@ export class TrayManager {
|
||||
return this.retrieveByIdentifier('main');
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the application tray at runtime.
|
||||
*/
|
||||
setAppTrayVisible(visible: boolean) {
|
||||
logger.debug(`Set application tray visible: ${visible}`);
|
||||
|
||||
if (visible) {
|
||||
this.initializeTrays();
|
||||
} else {
|
||||
this.destroyAll();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize main tray. On macOS we ship a template image (black + alpha)
|
||||
* so the system recolors it automatically for light / dark menu bars.
|
||||
|
||||
@@ -55,6 +55,9 @@ describe('TrayManager', () => {
|
||||
menuManager: {
|
||||
buildTrayMenu: vi.fn(() => ({ _mockMenu: true }) as any),
|
||||
},
|
||||
storeManager: {
|
||||
get: vi.fn(() => true),
|
||||
},
|
||||
} as unknown as App;
|
||||
|
||||
// Mock Tray constructor
|
||||
@@ -93,6 +96,15 @@ describe('TrayManager', () => {
|
||||
expect(mockApp.menuManager.buildTrayMenu).toHaveBeenCalled();
|
||||
expect(mockTray.setMenu).toHaveBeenCalledWith({ _mockMenu: true });
|
||||
});
|
||||
|
||||
it('should skip tray initialization when app tray is disabled', () => {
|
||||
vi.mocked(mockApp.storeManager.get).mockReturnValue(false);
|
||||
|
||||
trayManager.initializeTrays();
|
||||
|
||||
expect(Tray).not.toHaveBeenCalled();
|
||||
expect(trayManager.trays.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initializeMainTray', () => {
|
||||
@@ -273,6 +285,24 @@ describe('TrayManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('setAppTrayVisible', () => {
|
||||
it('should initialize trays when visible is true', () => {
|
||||
trayManager.setAppTrayVisible(true);
|
||||
|
||||
expect(Tray).toHaveBeenCalled();
|
||||
expect(trayManager.trays.has('main')).toBe(true);
|
||||
});
|
||||
|
||||
it('should destroy all trays when visible is false', () => {
|
||||
trayManager.initializeTrays();
|
||||
|
||||
trayManager.setAppTrayVisible(false);
|
||||
|
||||
expect(mockTray.destroy).toHaveBeenCalled();
|
||||
expect(trayManager.trays.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retrieveOrInitialize (private method)', () => {
|
||||
it('should create new tray when it does not exist', () => {
|
||||
const options = {
|
||||
|
||||
@@ -75,6 +75,7 @@ const menu = {
|
||||
'tray.open': 'Open {{appName}}',
|
||||
'tray.quickChat': 'Quick Chat',
|
||||
'tray.quit': 'Quit',
|
||||
'tray.settings': 'Settings',
|
||||
'tray.show': 'Show {{appName}}',
|
||||
'view.forceReload': 'Force Reload',
|
||||
'view.reload': 'Reload',
|
||||
|
||||
@@ -63,7 +63,9 @@ const createMockApp = () => {
|
||||
'dev.devPanel': 'Dev Panel',
|
||||
'tray.openMiniToolbar': 'Quick Composer',
|
||||
'tray.open': `Open ${params?.appName || 'App'}`,
|
||||
'tray.quickChat': 'Quick Chat',
|
||||
'tray.quit': 'Quit',
|
||||
'tray.settings': 'Settings',
|
||||
};
|
||||
return translations[key] || key;
|
||||
});
|
||||
@@ -197,6 +199,7 @@ describe('LinuxMenu', () => {
|
||||
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
|
||||
expect(template.length).toBeGreaterThan(0);
|
||||
expect(template.some((item: any) => item.label?.includes('Open'))).toBe(true);
|
||||
expect(template.some((item: any) => item.label === 'Settings')).toBe(true);
|
||||
expect(template.some((item: any) => item.label === 'Quit')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -466,7 +466,7 @@ export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||
{ type: 'separator' },
|
||||
{
|
||||
click: () => this.app.browserManager.retrieveByIdentifier('settings').show(),
|
||||
label: t('file.preferences'),
|
||||
label: t('tray.settings'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ label: t('tray.quit'), role: 'quit' },
|
||||
|
||||
@@ -31,6 +31,19 @@ vi.mock('electron', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('electron-is', () => ({
|
||||
macOS: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock isDev
|
||||
vi.mock('@/const/env', () => ({
|
||||
isDev: false,
|
||||
@@ -177,6 +190,7 @@ describe('MacOSMenu', () => {
|
||||
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
|
||||
expect(template.length).toBeGreaterThan(0);
|
||||
expect(template.some((item: any) => item.label?.includes('Show'))).toBe(true);
|
||||
expect(template.some((item: any) => item.label === 'Settings')).toBe(true);
|
||||
expect(template.some((item: any) => item.label === 'Quit')).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -694,7 +694,7 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||
mainWindow.show();
|
||||
mainWindow.broadcast('navigate', { path: '/settings' });
|
||||
},
|
||||
label: t('file.preferences'),
|
||||
label: t('tray.settings'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ label: t('tray.quit'), role: 'quit' },
|
||||
|
||||
@@ -58,7 +58,9 @@ const createMockApp = () => {
|
||||
'dev.devPanel': 'Dev Panel',
|
||||
'tray.openMiniToolbar': 'Quick Composer',
|
||||
'tray.open': `Open ${params?.appName || 'App'}`,
|
||||
'tray.quickChat': 'Quick Chat',
|
||||
'tray.quit': 'Quit',
|
||||
'tray.settings': 'Settings',
|
||||
};
|
||||
return translations[key] || key;
|
||||
});
|
||||
@@ -179,6 +181,7 @@ describe('WindowsMenu', () => {
|
||||
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
|
||||
expect(template.length).toBeGreaterThan(0);
|
||||
expect(template.some((item: any) => item.label?.includes('Open'))).toBe(true);
|
||||
expect(template.some((item: any) => item.label === 'Settings')).toBe(true);
|
||||
expect(template.some((item: any) => item.label === 'Quit')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -473,7 +473,7 @@ export class WindowsMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||
{ type: 'separator' },
|
||||
{
|
||||
click: () => this.app.browserManager.retrieveByIdentifier('settings').show(),
|
||||
label: t('file.preferences'),
|
||||
label: t('tray.settings'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ label: t('tray.quit'), role: 'quit' },
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { buildFilenameKeywordExpression } from '../impl/macOS';
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('buildFilenameKeywordExpression', () => {
|
||||
it('produces a single substring term for one keyword', () => {
|
||||
expect(buildFilenameKeywordExpression('package.json')).toBe(
|
||||
'kMDItemFSName == "*package.json*"cd',
|
||||
);
|
||||
});
|
||||
|
||||
it('splits whitespace-separated keywords into AND-ed substring terms', () => {
|
||||
// Critical fix: a free-form keyword string from the LLM (e.g. "LobeHub
|
||||
// Financial Statement") used to require that exact phrase to appear in the
|
||||
// filename. Real files reorder words and use _/-/. as separators, so the
|
||||
// literal phrase almost never matched. AND-ing per-token substrings keeps
|
||||
// each token literal but removes the order constraint.
|
||||
expect(buildFilenameKeywordExpression('LobeHub Financial Statement')).toBe(
|
||||
'(kMDItemFSName == "*LobeHub*"cd && kMDItemFSName == "*Financial*"cd && kMDItemFSName == "*Statement*"cd)',
|
||||
);
|
||||
});
|
||||
|
||||
it('collapses repeated whitespace and trims surrounding spaces', () => {
|
||||
expect(buildFilenameKeywordExpression(' foo \t\n bar ')).toBe(
|
||||
'(kMDItemFSName == "*foo*"cd && kMDItemFSName == "*bar*"cd)',
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes embedded double quotes in each token', () => {
|
||||
expect(buildFilenameKeywordExpression('foo "bar" baz')).toBe(
|
||||
'(kMDItemFSName == "*foo*"cd && kMDItemFSName == "*\\"bar\\"*"cd && kMDItemFSName == "*baz*"cd)',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an empty string when keywords are blank', () => {
|
||||
expect(buildFilenameKeywordExpression('')).toBe('');
|
||||
expect(buildFilenameKeywordExpression(' \t ')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { LinuxSearchServiceImpl } from '../impl/linux';
|
||||
|
||||
vi.mock('node:os', () => ({
|
||||
homedir: vi.fn().mockReturnValue('/Users/test-home'),
|
||||
platform: vi.fn().mockReturnValue('linux'),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const fgMock = vi.fn();
|
||||
vi.mock('fast-glob', () => ({
|
||||
default: (...args: unknown[]) => fgMock(...args),
|
||||
}));
|
||||
|
||||
const execaMock = vi.fn();
|
||||
vi.mock('execa', () => ({
|
||||
execa: (...args: unknown[]) => execaMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
stat: vi.fn().mockResolvedValue({
|
||||
atime: new Date(),
|
||||
birthtime: new Date(),
|
||||
isDirectory: () => false,
|
||||
mtime: new Date(),
|
||||
size: 0,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('UnixFileSearch glob fallback root', () => {
|
||||
beforeEach(() => {
|
||||
fgMock.mockReset();
|
||||
execaMock.mockReset();
|
||||
// Force the Unix tool selection to fall through to fast-glob so we
|
||||
// don't have to mock fd/find availability checks.
|
||||
execaMock.mockRejectedValue(new Error('command not found'));
|
||||
fgMock.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('runs glob inside the user home directory when no scope is provided', async () => {
|
||||
// Regression: previously fell back to process.cwd(), which inside a
|
||||
// packaged Electron app is the bundle path — making `**/*foo*` searches
|
||||
// effectively look at nothing user-visible.
|
||||
const impl = new LinuxSearchServiceImpl();
|
||||
await impl.glob({ pattern: '**/*report*' });
|
||||
|
||||
expect(fgMock).toHaveBeenCalledTimes(1);
|
||||
const [pattern, options] = fgMock.mock.calls[0] as [string, { cwd: string }];
|
||||
expect(pattern).toBe('**/*report*');
|
||||
expect(options.cwd).toBe('/Users/test-home');
|
||||
});
|
||||
|
||||
it('honors an explicit scope over the home-directory fallback', async () => {
|
||||
const impl = new LinuxSearchServiceImpl();
|
||||
await impl.glob({ pattern: '**/*.ts', scope: '/Users/test-home/Downloads' });
|
||||
|
||||
const [, options] = fgMock.mock.calls[0] as [string, { cwd: string }];
|
||||
expect(options.cwd).toBe('/Users/test-home/Downloads');
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,29 @@ import { UnixFileSearch } from './unix';
|
||||
|
||||
const logger = createLogger('module:FileSearch:macOS');
|
||||
|
||||
/**
|
||||
* Build the kMDItemFSName expression for a free-form keyword string.
|
||||
*
|
||||
* Splits on whitespace and ANDs each token as a case/diacritic-insensitive
|
||||
* substring match, so "Foo Bar" matches both `Bar_Foo.pdf` and `Foo Bar.pdf`
|
||||
* — instead of requiring the literal phrase "Foo Bar" to appear.
|
||||
*
|
||||
* Returns an empty string when the keywords contain no usable token.
|
||||
*/
|
||||
export const buildFilenameKeywordExpression = (keywords: string): string => {
|
||||
const tokens = keywords
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((token) => token.replaceAll('"', '\\"'));
|
||||
|
||||
if (tokens.length === 0) return '';
|
||||
|
||||
const term = (token: string) => `kMDItemFSName == "*${token}*"cd`;
|
||||
if (tokens.length === 1) return term(tokens[0]);
|
||||
return `(${tokens.map(term).join(' && ')})`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fallback tool type for macOS file search
|
||||
* Priority: mdfind > fd > find > fast-glob
|
||||
@@ -95,7 +118,16 @@ export class MacOSSearchServiceImpl extends UnixFileSearch {
|
||||
* Search using Spotlight (mdfind)
|
||||
*/
|
||||
private async searchWithSpotlight(options: SearchOptions): Promise<FileResult[]> {
|
||||
const { cmd, args, commandString } = this.buildSearchCommand(options);
|
||||
const { cmd, args, commandString, hasQuery } = this.buildSearchCommand(options);
|
||||
|
||||
// Spotlight (mdfind) requires a query expression; running it with only flags
|
||||
// (e.g. -onlyin) makes mdfind print its usage to stdout and we'd treat each
|
||||
// line as a fake file. Short-circuit to an empty result instead.
|
||||
if (!hasQuery) {
|
||||
logger.warn('Skipping mdfind: no keywords/contentContains/fileTypes/date filter provided');
|
||||
return [];
|
||||
}
|
||||
|
||||
logger.debug(`Executing command: ${commandString}`);
|
||||
|
||||
try {
|
||||
@@ -176,6 +208,7 @@ export class MacOSSearchServiceImpl extends UnixFileSearch {
|
||||
args: string[];
|
||||
cmd: string;
|
||||
commandString: string;
|
||||
hasQuery: boolean;
|
||||
} {
|
||||
const cmd = 'mdfind';
|
||||
const args: string[] = [];
|
||||
@@ -204,7 +237,7 @@ export class MacOSSearchServiceImpl extends UnixFileSearch {
|
||||
|
||||
if (options.keywords) {
|
||||
if (!options.keywords.includes('kMDItem')) {
|
||||
queryExpression = `kMDItemFSName == "*${options.keywords.replaceAll('"', '\\"')}*"cd`;
|
||||
queryExpression = buildFilenameKeywordExpression(options.keywords);
|
||||
} else {
|
||||
queryExpression = options.keywords;
|
||||
}
|
||||
@@ -271,13 +304,15 @@ export class MacOSSearchServiceImpl extends UnixFileSearch {
|
||||
}
|
||||
}
|
||||
|
||||
if (queryExpression) {
|
||||
const hasQuery = Boolean(queryExpression);
|
||||
|
||||
if (hasQuery) {
|
||||
args.push(queryExpression);
|
||||
}
|
||||
|
||||
const commandString = `${cmd} ${args.map((arg) => (arg.includes(' ') || arg.includes('*') ? `"${arg}"` : arg)).join(' ')}`;
|
||||
|
||||
return { args, cmd, commandString };
|
||||
return { args, cmd, commandString, hasQuery };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -288,7 +323,7 @@ export class MacOSSearchServiceImpl extends UnixFileSearch {
|
||||
options: SearchOptions,
|
||||
engine?: string,
|
||||
): Promise<FileResult[]> {
|
||||
const resultPromises = filePaths.map(async (filePath) => {
|
||||
const resultPromises = filePaths.map(async (filePath): Promise<FileResult | null> => {
|
||||
try {
|
||||
const stats = await stat(filePath);
|
||||
|
||||
@@ -313,23 +348,15 @@ export class MacOSSearchServiceImpl extends UnixFileSearch {
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.warn(`Error processing file stats for ${filePath}: ${(error as Error).message}`);
|
||||
return {
|
||||
contentType: 'unknown',
|
||||
createdTime: new Date(),
|
||||
engine,
|
||||
isDirectory: false,
|
||||
lastAccessTime: new Date(),
|
||||
modifiedTime: new Date(),
|
||||
name: path.basename(filePath),
|
||||
path: filePath,
|
||||
size: 0,
|
||||
type: path.extname(filePath).toLowerCase().replace('.', ''),
|
||||
};
|
||||
// Drop the row instead of fabricating a 0-byte placeholder. mdfind
|
||||
// occasionally returns non-path lines (e.g. usage text when the query
|
||||
// is malformed) which would otherwise render as phantom files.
|
||||
logger.warn(`Dropping unstattable search hit ${filePath}: ${(error as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
let results = await Promise.all(resultPromises);
|
||||
let results = (await Promise.all(resultPromises)).filter((r): r is FileResult => r !== null);
|
||||
|
||||
if (options.sortBy) {
|
||||
results = this.sortResults(results, options.sortBy, options.sortDirection);
|
||||
|
||||
@@ -337,7 +337,7 @@ export abstract class UnixFileSearch extends BaseFileSearch {
|
||||
* @returns Glob results
|
||||
*/
|
||||
protected async globWithFd(params: GlobFilesParams): Promise<GlobFilesResult> {
|
||||
const searchPath = params.scope || process.cwd();
|
||||
const searchPath = params.scope || os.homedir() || process.cwd();
|
||||
const logPrefix = `[glob:fd: ${params.pattern}]`;
|
||||
|
||||
logger.debug(`${logPrefix} Starting fd glob`, { searchPath });
|
||||
@@ -393,7 +393,7 @@ export abstract class UnixFileSearch extends BaseFileSearch {
|
||||
* @returns Glob results
|
||||
*/
|
||||
protected async globWithFind(params: GlobFilesParams): Promise<GlobFilesResult> {
|
||||
const searchPath = params.scope || process.cwd();
|
||||
const searchPath = params.scope || os.homedir() || process.cwd();
|
||||
const logPrefix = `[glob:find: ${params.pattern}]`;
|
||||
|
||||
logger.debug(`${logPrefix} Starting find glob`, { searchPath });
|
||||
@@ -455,7 +455,7 @@ export abstract class UnixFileSearch extends BaseFileSearch {
|
||||
* @returns Glob results
|
||||
*/
|
||||
protected async globWithFastGlob(params: GlobFilesParams): Promise<GlobFilesResult> {
|
||||
const searchPath = params.scope || process.cwd();
|
||||
const searchPath = params.scope || os.homedir() || process.cwd();
|
||||
const logPrefix = `[glob:fast-glob: ${params.pattern}]`;
|
||||
|
||||
logger.debug(`${logPrefix} Starting fast-glob`, { searchPath });
|
||||
|
||||
@@ -335,7 +335,7 @@ export class WindowsSearchServiceImpl extends BaseFileSearch {
|
||||
* @returns Glob results
|
||||
*/
|
||||
private async globWithFd(params: GlobFilesParams): Promise<GlobFilesResult> {
|
||||
const searchPath = params.scope || process.cwd();
|
||||
const searchPath = params.scope || os.homedir() || process.cwd();
|
||||
const logPrefix = `[glob:fd: ${params.pattern}]`;
|
||||
|
||||
logger.debug(`${logPrefix} Starting fd glob`, { searchPath });
|
||||
@@ -390,7 +390,7 @@ export class WindowsSearchServiceImpl extends BaseFileSearch {
|
||||
* @returns Glob results
|
||||
*/
|
||||
private async globWithFastGlob(params: GlobFilesParams): Promise<GlobFilesResult> {
|
||||
const searchPath = params.scope || process.cwd();
|
||||
const searchPath = params.scope || os.homedir() || process.cwd();
|
||||
const logPrefix = `[glob:fast-glob: ${params.pattern}]`;
|
||||
|
||||
logger.debug(`${logPrefix} Starting fast-glob`, { searchPath });
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { JsonlStreamProcessor } from '../jsonlProcessor';
|
||||
import type { HeterogeneousAgentBuildPlanParams, HeterogeneousAgentDriver } from '../types';
|
||||
|
||||
const CLAUDE_CODE_BASE_ARGS = [
|
||||
@@ -32,10 +31,4 @@ export const claudeCodeDriver: HeterogeneousAgentDriver = {
|
||||
stdinPayload,
|
||||
};
|
||||
},
|
||||
createStreamProcessor() {
|
||||
return new JsonlStreamProcessor({
|
||||
extractSessionId: (payload) =>
|
||||
payload?.type === 'system' && payload?.subtype === 'init' ? payload?.session_id : undefined,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { JsonlStreamProcessor } from '../jsonlProcessor';
|
||||
import type { HeterogeneousAgentBuildPlanParams, HeterogeneousAgentDriver } from '../types';
|
||||
|
||||
const CODEX_REQUIRED_ARGS = ['--json', '--skip-git-repo-check'] as const;
|
||||
@@ -41,10 +40,4 @@ export const codexDriver: HeterogeneousAgentDriver = {
|
||||
stdinPayload: prompt,
|
||||
};
|
||||
},
|
||||
createStreamProcessor() {
|
||||
return new JsonlStreamProcessor({
|
||||
extractSessionId: (payload) =>
|
||||
payload?.type === 'thread.started' ? payload?.thread_id : undefined,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { HeterogeneousAgentParsedOutput, HeterogeneousAgentStreamProcessor } from './types';
|
||||
|
||||
export interface JsonlProcessorOptions {
|
||||
extractSessionId?: (payload: any) => string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses stdout as JSONL / NDJSON while tolerating non-JSON noise lines.
|
||||
* Different CLIs still end up sharing this framing logic even when the
|
||||
* payload schema differs.
|
||||
*/
|
||||
export class JsonlStreamProcessor implements HeterogeneousAgentStreamProcessor {
|
||||
private buffer = '';
|
||||
|
||||
constructor(private readonly options: JsonlProcessorOptions = {}) {}
|
||||
|
||||
push(chunk: Buffer | string): HeterogeneousAgentParsedOutput[] {
|
||||
this.buffer += chunk instanceof Buffer ? chunk.toString('utf8') : chunk;
|
||||
return this.drainCompleteLines();
|
||||
}
|
||||
|
||||
flush(): HeterogeneousAgentParsedOutput[] {
|
||||
const trailing = this.buffer.trim();
|
||||
this.buffer = '';
|
||||
|
||||
if (!trailing) return [];
|
||||
|
||||
try {
|
||||
return [this.toParsedOutput(JSON.parse(trailing))];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private drainCompleteLines(): HeterogeneousAgentParsedOutput[] {
|
||||
const lines = this.buffer.split('\n');
|
||||
this.buffer = lines.pop() || '';
|
||||
|
||||
const parsed: HeterogeneousAgentParsedOutput[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
try {
|
||||
parsed.push(this.toParsedOutput(JSON.parse(trimmed)));
|
||||
} catch {
|
||||
// Ignore non-JSON stdout noise.
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private toParsedOutput(payload: any): HeterogeneousAgentParsedOutput {
|
||||
return {
|
||||
agentSessionId: this.options.extractSessionId?.(payload),
|
||||
payload,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -24,19 +24,13 @@ export interface HeterogeneousAgentBuildPlanParams {
|
||||
resumeSessionId?: string;
|
||||
}
|
||||
|
||||
export interface HeterogeneousAgentParsedOutput {
|
||||
agentSessionId?: string;
|
||||
payload: any;
|
||||
}
|
||||
|
||||
export interface HeterogeneousAgentStreamProcessor {
|
||||
flush: () => HeterogeneousAgentParsedOutput[];
|
||||
push: (chunk: Buffer | string) => HeterogeneousAgentParsedOutput[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-agent CLI flag composition + stdin shape. Stream framing is no longer the
|
||||
* driver's concern — `AgentStreamPipeline` (`@lobechat/heterogeneous-agents/spawn`)
|
||||
* runs JSONL parsing + adapter conversion uniformly for every agent type.
|
||||
*/
|
||||
export interface HeterogeneousAgentDriver {
|
||||
buildSpawnPlan: (
|
||||
params: HeterogeneousAgentBuildPlanParams,
|
||||
) => Promise<HeterogeneousAgentBuildPlan>;
|
||||
createStreamProcessor: () => HeterogeneousAgentStreamProcessor;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
|
||||
import type {
|
||||
AgentRunRequestMessage,
|
||||
SystemInfoRequestMessage,
|
||||
ToolCallRequestMessage,
|
||||
} from '@lobechat/device-gateway-client';
|
||||
@@ -21,6 +22,10 @@ interface ToolCallHandler {
|
||||
(apiName: string, args: any): Promise<unknown>;
|
||||
}
|
||||
|
||||
interface AgentRunHandler {
|
||||
(request: AgentRunRequestMessage): Promise<{ reason?: string; status: 'accepted' | 'rejected' }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* GatewayConnectionService
|
||||
*
|
||||
@@ -35,6 +40,7 @@ export default class GatewayConnectionService extends ServiceModule {
|
||||
private tokenProvider: (() => Promise<string | null>) | null = null;
|
||||
private tokenRefresher: (() => Promise<{ error?: string; success: boolean }>) | null = null;
|
||||
private toolCallHandler: ToolCallHandler | null = null;
|
||||
private agentRunHandler: AgentRunHandler | null = null;
|
||||
|
||||
// ─── Configuration ───
|
||||
|
||||
@@ -59,6 +65,10 @@ export default class GatewayConnectionService extends ServiceModule {
|
||||
this.toolCallHandler = handler;
|
||||
}
|
||||
|
||||
setAgentRunHandler(handler: AgentRunHandler) {
|
||||
this.agentRunHandler = handler;
|
||||
}
|
||||
|
||||
// ─── Device ID ───
|
||||
|
||||
loadOrCreateDeviceId() {
|
||||
@@ -178,6 +188,10 @@ export default class GatewayConnectionService extends ServiceModule {
|
||||
this.handleSystemInfoRequest(client, request);
|
||||
});
|
||||
|
||||
client.on('agent_run_request', (request) => {
|
||||
this.handleAgentRunRequest(client, request);
|
||||
});
|
||||
|
||||
client.on('auth_expired', () => {
|
||||
logger.warn('Received auth_expired, will reconnect with refreshed token');
|
||||
this.handleAuthExpired();
|
||||
@@ -239,6 +253,30 @@ export default class GatewayConnectionService extends ServiceModule {
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Agent Run ───
|
||||
|
||||
private handleAgentRunRequest = async (
|
||||
client: GatewayClient,
|
||||
request: AgentRunRequestMessage,
|
||||
) => {
|
||||
logger.info(
|
||||
`Received agent_run_request: operationId=${request.operationId} type=${request.agentType}`,
|
||||
);
|
||||
|
||||
if (!this.agentRunHandler) {
|
||||
logger.warn('No agent run handler configured, rejecting request');
|
||||
client.sendAgentRunAck({
|
||||
operationId: request.operationId,
|
||||
reason: 'no handler',
|
||||
status: 'rejected',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.agentRunHandler(request);
|
||||
client.sendAgentRunAck({ operationId: request.operationId, ...result });
|
||||
};
|
||||
|
||||
// ─── Tool Call Routing ───
|
||||
|
||||
private handleToolCallRequest = async (
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
|
||||
export interface ElectronMainStore {
|
||||
appTrayVisible: boolean;
|
||||
dataSyncConfig: DataSyncConfig;
|
||||
encryptedTokens: {
|
||||
accessToken?: string;
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { resolveOverlayModelSelectionPayload, shouldShowOverlayModelSelector } from './ChatPanel';
|
||||
import { resolvePanelPlacement } from './panelPlacement';
|
||||
|
||||
describe('resolvePanelPlacement', () => {
|
||||
vi.mock('./chatPanel.css.ts', () => new Proxy({}, { get: (_, key) => String(key) }));
|
||||
|
||||
vi.mock('./cn', () => ({
|
||||
cn: (...classes: Array<string | false | null | undefined>) => classes.filter(Boolean).join(' '),
|
||||
}));
|
||||
|
||||
vi.mock('./Avatar', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@lobehub/icons', () => ({
|
||||
ModelIcon: () => null,
|
||||
}));
|
||||
|
||||
describe('ChatPanel', () => {
|
||||
it('keeps the last selection placement while a reselection is in progress', () => {
|
||||
expect(
|
||||
resolvePanelPlacement({
|
||||
@@ -30,4 +45,43 @@ describe('resolvePanelPlacement', () => {
|
||||
width: 420,
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the model selector and omits model payload for heterogeneous agents', () => {
|
||||
const heterogeneousAgent = {
|
||||
heterogeneousType: 'codex',
|
||||
id: 'agent-codex',
|
||||
title: 'Codex Agent',
|
||||
};
|
||||
|
||||
expect(shouldShowOverlayModelSelector(heterogeneousAgent)).toBe(false);
|
||||
expect(
|
||||
resolveOverlayModelSelectionPayload({
|
||||
agent: heterogeneousAgent,
|
||||
model: { id: 'gpt-4.1', provider: 'openai' },
|
||||
modelId: 'gpt-4.1',
|
||||
}),
|
||||
).toEqual({
|
||||
modelId: undefined,
|
||||
provider: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the model selector and payload for regular agents', () => {
|
||||
const regularAgent = {
|
||||
id: 'agent-regular',
|
||||
title: 'Regular Agent',
|
||||
};
|
||||
|
||||
expect(shouldShowOverlayModelSelector(regularAgent)).toBe(true);
|
||||
expect(
|
||||
resolveOverlayModelSelectionPayload({
|
||||
agent: regularAgent,
|
||||
model: { id: 'gpt-4.1', provider: 'openai' },
|
||||
modelId: 'gpt-4.1',
|
||||
}),
|
||||
).toEqual({
|
||||
modelId: 'gpt-4.1',
|
||||
provider: 'openai',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,6 +57,25 @@ export interface ChatPanelProps {
|
||||
viewportWidth: number;
|
||||
}
|
||||
|
||||
export const shouldShowOverlayModelSelector = (agent?: ScreenCaptureAgentOption) =>
|
||||
!agent?.heterogeneousType;
|
||||
|
||||
export const resolveOverlayModelSelectionPayload = ({
|
||||
agent,
|
||||
model,
|
||||
modelId,
|
||||
}: {
|
||||
agent?: ScreenCaptureAgentOption;
|
||||
model?: ScreenCaptureModelOption;
|
||||
modelId?: string;
|
||||
}) => {
|
||||
if (!shouldShowOverlayModelSelector(agent)) {
|
||||
return { modelId: undefined, provider: undefined };
|
||||
}
|
||||
|
||||
return { modelId, provider: model?.provider };
|
||||
};
|
||||
|
||||
const formatBytes = (rect: Rect): string =>
|
||||
`${Math.round(rect.width)} × ${Math.round(rect.height)} · ${OVERLAY_COPY.selectionFormatLabel}`;
|
||||
|
||||
@@ -140,6 +159,7 @@ const ChatPanel = memo<ChatPanelProps>(
|
||||
() => models?.find((item) => item.id === modelId),
|
||||
[models, modelId],
|
||||
);
|
||||
const showModelSelector = shouldShowOverlayModelSelector(currentAgent);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialAgentId) return;
|
||||
@@ -276,14 +296,29 @@ const ChatPanel = memo<ChatPanelProps>(
|
||||
|
||||
const submit = useCallback(() => {
|
||||
if (selections.length === 0 || !prompt.trim() || !allUploadsReady) return;
|
||||
const modelSelection = resolveOverlayModelSelectionPayload({
|
||||
agent: currentAgent,
|
||||
model: currentModel,
|
||||
modelId,
|
||||
});
|
||||
|
||||
onSubmit({
|
||||
agentId,
|
||||
captureIds: selections.map((item) => item.captureId),
|
||||
modelId,
|
||||
modelId: modelSelection.modelId,
|
||||
prompt: prompt.trim(),
|
||||
provider: currentModel?.provider,
|
||||
provider: modelSelection.provider,
|
||||
});
|
||||
}, [selections, prompt, agentId, modelId, currentModel, onSubmit, allUploadsReady]);
|
||||
}, [
|
||||
selections,
|
||||
prompt,
|
||||
agentId,
|
||||
currentAgent,
|
||||
modelId,
|
||||
currentModel,
|
||||
onSubmit,
|
||||
allUploadsReady,
|
||||
]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
||||
@@ -464,38 +499,40 @@ const ChatPanel = memo<ChatPanelProps>(
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label
|
||||
aria-label={OVERLAY_COPY.modelSelectLabel}
|
||||
className={cn(styles.selectChip, !hasModels && styles.selectChipDisabled)}
|
||||
>
|
||||
{currentModel ? (
|
||||
<span className={styles.modelIconBox}>
|
||||
<ModelIcon model={currentModel.id} size={16} />
|
||||
</span>
|
||||
) : (
|
||||
<span className={styles.modelIconBoxFallback} />
|
||||
)}
|
||||
<span className={styles.chipLabel}>
|
||||
{currentModel?.displayName ??
|
||||
currentModel?.id ??
|
||||
OVERLAY_COPY.modelSelectPlaceholder}
|
||||
</span>
|
||||
<ChevronDownIcon className={styles.chevron} size={12} strokeWidth={2} />
|
||||
<select
|
||||
{showModelSelector && (
|
||||
<label
|
||||
aria-label={OVERLAY_COPY.modelSelectLabel}
|
||||
className={styles.nativeSelect}
|
||||
disabled={!hasModels}
|
||||
value={modelId ?? ''}
|
||||
onChange={handleModelChange}
|
||||
className={cn(styles.selectChip, !hasModels && styles.selectChipDisabled)}
|
||||
>
|
||||
{!hasModels && <option value="">{OVERLAY_COPY.modelSelectPlaceholder}</option>}
|
||||
{models?.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.displayName ?? item.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{currentModel ? (
|
||||
<span className={styles.modelIconBox}>
|
||||
<ModelIcon model={currentModel.id} size={16} />
|
||||
</span>
|
||||
) : (
|
||||
<span className={styles.modelIconBoxFallback} />
|
||||
)}
|
||||
<span className={styles.chipLabel}>
|
||||
{currentModel?.displayName ??
|
||||
currentModel?.id ??
|
||||
OVERLAY_COPY.modelSelectPlaceholder}
|
||||
</span>
|
||||
<ChevronDownIcon className={styles.chevron} size={12} strokeWidth={2} />
|
||||
<select
|
||||
aria-label={OVERLAY_COPY.modelSelectLabel}
|
||||
className={styles.nativeSelect}
|
||||
disabled={!hasModels}
|
||||
value={modelId ?? ''}
|
||||
onChange={handleModelChange}
|
||||
>
|
||||
{!hasModels && <option value="">{OVERLAY_COPY.modelSelectPlaceholder}</option>}
|
||||
{models?.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.displayName ?? item.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.actionBarRight}>
|
||||
|
||||
@@ -484,8 +484,8 @@ export const connectorHidden = style({
|
||||
});
|
||||
|
||||
const fadeIn = keyframes({
|
||||
from: { opacity: 0, transform: 'translate(-50%, 8px)' },
|
||||
to: { opacity: 1, transform: 'translate(-50%, 0)' },
|
||||
from: { opacity: 0, transform: 'translateY(8px)' },
|
||||
to: { opacity: 1, transform: 'translateY(0)' },
|
||||
});
|
||||
|
||||
const spin = keyframes({
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
export const BRANDING_LOGO_URL = '';
|
||||
export const BRANDING_NAME = 'LobeHub';
|
||||
export const DEFAULT_EMBEDDING_PROVIDER = 'openai';
|
||||
export const DEFAULT_MINI_MODEL = 'gpt-5.4-mini';
|
||||
export const DEFAULT_MINI_PROVIDER = 'openai';
|
||||
export const DEFAULT_PROVIDER = 'openai';
|
||||
export const DEFAULT_MODEL = 'deepseek-v4-pro';
|
||||
export const DEFAULT_ONBOARDING_MODEL = 'gemini-3-flash-preview';
|
||||
export const DEFAULT_ONBOARDING_PROVIDER = 'google';
|
||||
export const DEFAULT_PROVIDER = 'deepseek';
|
||||
export const ORG_NAME = 'LobeHub';
|
||||
|
||||
@@ -2,7 +2,7 @@ import { DurableObject } from 'cloudflare:workers';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import { resolveSocketAuth, verifyApiKeyToken, verifyDesktopToken } from './auth';
|
||||
import type { DeviceAttachment, Env } from './types';
|
||||
import type { AgentRunRequestMessage, DeviceAttachment, Env } from './types';
|
||||
|
||||
const AUTH_TIMEOUT = 10_000; // 10s to authenticate after connect
|
||||
const HEARTBEAT_TIMEOUT = 90_000; // 90s without heartbeat → close
|
||||
@@ -31,6 +31,9 @@ export class DeviceGatewayDO extends DurableObject<Env> {
|
||||
.post('/api/device/system-info', async (c) => {
|
||||
return this.handleSystemInfo(c.req.raw);
|
||||
})
|
||||
.post('/api/device/agent/run', async (c) => {
|
||||
return this.handleAgentRun(c.req.raw);
|
||||
})
|
||||
.all('/api/device/devices', async () => {
|
||||
const sockets = this.getAuthenticatedSockets();
|
||||
const devices = sockets.map((ws) => ws.deserializeAttachment() as DeviceAttachment);
|
||||
@@ -102,12 +105,16 @@ export class DeviceGatewayDO extends DurableObject<Env> {
|
||||
if (!att.authenticated) return;
|
||||
|
||||
// ─── Business messages (authenticated only) ───
|
||||
if (data.type === 'tool_call_response' || data.type === 'system_info_response') {
|
||||
const pending = this.pendingRequests.get(data.requestId);
|
||||
if (
|
||||
data.type === 'tool_call_response' ||
|
||||
data.type === 'system_info_response' ||
|
||||
data.type === 'agent_run_ack'
|
||||
) {
|
||||
const pending = this.pendingRequests.get(data.requestId ?? data.operationId);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve(data.result);
|
||||
this.pendingRequests.delete(data.requestId);
|
||||
pending.resolve(data.type === 'agent_run_ack' ? data : data.result);
|
||||
this.pendingRequests.delete(data.requestId ?? data.operationId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,6 +285,60 @@ export class DeviceGatewayDO extends DurableObject<Env> {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Agent Run RPC ───
|
||||
|
||||
private async handleAgentRun(request: Request): Promise<Response> {
|
||||
const sockets = this.getAuthenticatedSockets();
|
||||
if (sockets.length === 0) {
|
||||
return Response.json({ error: 'DEVICE_OFFLINE', success: false }, { status: 503 });
|
||||
}
|
||||
|
||||
const body = (await request.json()) as {
|
||||
agentType: 'claude-code' | 'codex';
|
||||
cwd?: string;
|
||||
deviceId?: string;
|
||||
jwt: string;
|
||||
operationId: string;
|
||||
prompt: string;
|
||||
resumeSessionId?: string;
|
||||
timeout?: number;
|
||||
topicId: string;
|
||||
};
|
||||
const { deviceId, timeout = 10_000, ...runParams } = body;
|
||||
|
||||
const targetWs = deviceId
|
||||
? sockets.find((ws) => {
|
||||
const att = ws.deserializeAttachment() as DeviceAttachment;
|
||||
return att.deviceId === deviceId;
|
||||
})
|
||||
: sockets[0];
|
||||
|
||||
if (!targetWs) {
|
||||
return Response.json({ error: 'DEVICE_NOT_FOUND', success: false }, { status: 503 });
|
||||
}
|
||||
|
||||
try {
|
||||
const ack = await new Promise<{ status: string }>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingRequests.delete(runParams.operationId);
|
||||
reject(new Error('TIMEOUT'));
|
||||
}, timeout);
|
||||
|
||||
this.pendingRequests.set(runParams.operationId, { resolve, timer });
|
||||
|
||||
const msg: AgentRunRequestMessage = { type: 'agent_run_request', ...runParams };
|
||||
targetWs.send(JSON.stringify(msg));
|
||||
});
|
||||
|
||||
if (ack.status === 'rejected') {
|
||||
return Response.json({ error: 'DEVICE_REJECTED', success: false }, { status: 422 });
|
||||
}
|
||||
return Response.json({ success: true });
|
||||
} catch (err) {
|
||||
return Response.json({ error: (err as Error).message, success: false }, { status: 504 });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tool Call RPC ───
|
||||
|
||||
private async handleToolCall(request: Request): Promise<Response> {
|
||||
|
||||
@@ -92,12 +92,42 @@ export interface SystemInfoRequestMessage {
|
||||
type: 'system_info_request';
|
||||
}
|
||||
|
||||
/**
|
||||
* CF → Desktop: request the desktop to spawn `lh hetero exec` for a
|
||||
* heterogeneous agent run. The JWT is operation-scoped (4h TTL) and only
|
||||
* grants `heteroIngest` / `heteroFinish` for this operationId.
|
||||
*/
|
||||
export interface AgentRunRequestMessage {
|
||||
agentType: 'claude-code' | 'codex';
|
||||
/** Working directory to pass to `lh hetero exec --cwd`. */
|
||||
cwd?: string;
|
||||
/** Operation-scoped JWT signed by the server — inject as LOBEHUB_JWT env. */
|
||||
jwt: string;
|
||||
operationId: string;
|
||||
/** Plain-text prompt to pass via `lh hetero exec --prompt`. */
|
||||
prompt: string;
|
||||
/** Native CLI session id for `lh hetero exec --resume`. */
|
||||
resumeSessionId?: string;
|
||||
topicId: string;
|
||||
type: 'agent_run_request';
|
||||
}
|
||||
|
||||
/** Desktop → CF: acknowledgement for an `agent_run_request`. */
|
||||
export interface AgentRunAckMessage {
|
||||
operationId: string;
|
||||
reason?: string;
|
||||
status: 'accepted' | 'rejected';
|
||||
type: 'agent_run_ack';
|
||||
}
|
||||
|
||||
export type ClientMessage =
|
||||
| AgentRunAckMessage
|
||||
| AuthMessage
|
||||
| HeartbeatMessage
|
||||
| SystemInfoResponseMessage
|
||||
| ToolCallResponseMessage;
|
||||
export type ServerMessage =
|
||||
| AgentRunRequestMessage
|
||||
| AuthExpiredMessage
|
||||
| AuthFailedMessage
|
||||
| AuthSuccessMessage
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
[
|
||||
{
|
||||
"children": {},
|
||||
"date": "2026-05-01",
|
||||
"version": "2.1.56"
|
||||
},
|
||||
{
|
||||
"children": {},
|
||||
"date": "2026-04-29",
|
||||
"version": "2.1.55"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["clear stale topic when switching agents from a topic route."]
|
||||
},
|
||||
"date": "2026-04-27",
|
||||
"version": "2.1.54"
|
||||
},
|
||||
{
|
||||
"children": {},
|
||||
"date": "2026-04-20",
|
||||
|
||||
@@ -469,5 +469,6 @@
|
||||
"https://github.com/user-attachments/assets/facdc83c-e789-4649-8060-7f7a10a1b1dd": "/blog/assets05b20e40c03ced0ec8707fed2e8e0f25.webp",
|
||||
"https://github.com/user-attachments/assets/fcdfb9c5-819a-488f-b28d-0857fe861219": "/blog/assets8477415ecec1f37e38ab38ff1217d0a7.webp",
|
||||
"https://github.com/user-attachments/assets/fd60ab55-ead2-4930-ad00-fdf77662f5a0": "/blog/assets276a4e8748e9bd300b30dcd9d0e24980.webp",
|
||||
"https://file.rene.wang/clipboard-1777343750668-9b3dcb0dfff86.png": "/blog/assetsfa267a02f20bc5ba6f1273bcf27b7c9f.webp"
|
||||
"https://file.rene.wang/clipboard-1777343750668-9b3dcb0dfff86.png": "/blog/assetsfa267a02f20bc5ba6f1273bcf27b7c9f.webp",
|
||||
"https://file.rene.wang/Changelog-Seedance.png": "/blog/assetsb2bf4ddf0a45ff887a993c18cb7ab983.webp"
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
title: 'Delegate Claude Code and Codex'
|
||||
description: >-
|
||||
Delegate Claude Code and Codex from inside LobeHub, with a redesigned home, a Review tab for bulk git diffs, visual understanding, and a wave of new models.
|
||||
|
||||
|
||||
tags:
|
||||
- Coding agent
|
||||
- Claude Code
|
||||
- Home
|
||||
- Review
|
||||
- Models
|
||||
---
|
||||
|
||||
# Delegate Claude Code and Codex
|
||||
|
||||
## Features
|
||||
|
||||
- New: Delegate Claude Code and Codex in LobeHub
|
||||
- Agent-specific topic grouping: switch the topic list to group by agent, with a friendlier empty state
|
||||
- Review tab: a new tab that aggregates bulk git diffs across a tree, \~9× faster on large repos
|
||||
- Local file mention snapshots: drag a file into chat and a snapshot is captured for the model to reason over
|
||||
- Visual understanding tool: a new built-in tool for image analysis and visual reasoning
|
||||
- Line bot support: connect a Line channel as an agent endpoint
|
||||
- New models: `grok-4.3`, DeepSeek Anthropic runtime, plus `gpt-image-2` and Grok 4.20 in the model library
|
||||
|
||||
## Improvements and fixes
|
||||
|
||||
- DeepSeek now shows pricing in the model card and respects model defaults.
|
||||
- Document modal shows a skeleton while the title loads and surfaces the document update time in space.
|
||||
- Agent documents can be exposed as a virtual file system with fs-compatible output.
|
||||
- Sessions are revoked after a password reset, and tRPC pagination now enforces a max limit.
|
||||
- Skill OAuth no longer breaks the desktop app by skipping `redirectUri` on Electron.
|
||||
- CAPTCHA retries during sign-in are handled cleanly instead of failing the flow.
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
title: 在 LobeHub 中调度 Claude Code 与 Codex
|
||||
description: 在 LobeHub 中直接调度 Claude Code 与 Codex,全新首页、批量 git diff 的 Review 标签页、视觉理解工具,以及一批新模型。
|
||||
tags:
|
||||
- 编程 Agent
|
||||
- Claude Code
|
||||
- 首页
|
||||
- Review
|
||||
- 模型
|
||||
---
|
||||
|
||||
# 在 LobeHub 中调度 Claude Code 与 Codex
|
||||
|
||||
## 新功能
|
||||
|
||||
- 新增:在 LobeHub 中调度 Claude Code 与 Codex
|
||||
- 按 Agent 分组话题:可将话题列表切换为按 Agent 分组,并带有更友好的空状态
|
||||
- Review 标签页:新增 Review 标签页,可聚合树级别的批量 git diff,大型仓库下速度提升约 9 倍
|
||||
- 本地文件提及快照:将文件拖入聊天即可生成快照供模型理解
|
||||
- 视觉理解工具:内置的图像分析与视觉推理工具
|
||||
- Line Bot 接入:可将 Line 频道作为 Agent 接入端
|
||||
- 新模型:`grok-4.3`、DeepSeek Anthropic 运行时,以及模型库新增的 `gpt-image-2` 和 Grok 4.20
|
||||
|
||||
## 体验优化与修复
|
||||
|
||||
- DeepSeek 模型卡片展示价格并尊重模型默认配置。
|
||||
- 文档弹窗在标题加载时显示骨架,并在 Space 中展示文档更新时间。
|
||||
- Agent 文档可作为虚拟文件系统暴露,输出兼容 fs 接口。
|
||||
- 重置密码后会立即吊销已有会话,tRPC 分页接口新增最大条数限制。
|
||||
- 在桌面端跳过 Skill OAuth 的 `redirectUri`,避免应用进入异常状态。
|
||||
- 登录流程中的 CAPTCHA 重试可正常处理,不再直接失败。
|
||||
@@ -2,6 +2,15 @@
|
||||
"$schema": "https://github.com/lobehub/lobe-chat/blob/main/docs/changelog/schema.json",
|
||||
"cloud": [],
|
||||
"community": [
|
||||
{
|
||||
"image": "/blog/assetsb2bf4ddf0a45ff887a993c18cb7ab983.webp",
|
||||
"id": "2026-05-04-task-scheduler",
|
||||
"date": "2026-05-04",
|
||||
"versionRange": [
|
||||
"2.1.54",
|
||||
"2.1.56"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assetsfa267a02f20bc5ba6f1273bcf27b7c9f.webp",
|
||||
"id": "2026-04-27-heterogeneous-agent",
|
||||
|
||||
@@ -72,7 +72,7 @@ sequenceDiagram
|
||||
After the user sends a message, `sendMessage()`
|
||||
(`src/store/chat/slices/aiChat/actions/conversationLifecycle.ts`)
|
||||
creates the user message and assistant message placeholder,
|
||||
then calls `internal_execAgentRuntime()`.
|
||||
then calls `executeClientAgent()`.
|
||||
|
||||
### 2. Agent Runtime Drives the Loop
|
||||
|
||||
@@ -325,7 +325,7 @@ depends on the scenario:
|
||||
- **Client-side loop** (browser): Regular 1:1 chat,
|
||||
continue generation, group orchestration decisions.
|
||||
The loop runs in the browser, entry point is
|
||||
`internal_execAgentRuntime()`
|
||||
`executeClientAgent()`
|
||||
(`src/store/chat/slices/aiChat/actions/streamingExecutor.ts`)
|
||||
- **Server-side loop** (queue/local):
|
||||
Group chat supervisor agent, sub-agent tasks,
|
||||
|
||||
@@ -68,7 +68,7 @@ sequenceDiagram
|
||||
|
||||
用户发送消息后,`sendMessage()`
|
||||
(`src/store/chat/slices/aiChat/actions/conversationLifecycle.ts`)
|
||||
创建用户消息和助手消息占位,然后调用 `internal_execAgentRuntime()`。
|
||||
创建用户消息和助手消息占位,然后调用 `executeClientAgent()`。
|
||||
|
||||
### 2. Agent Runtime 驱动循环
|
||||
|
||||
@@ -293,7 +293,7 @@ Agent Runtime 循环的执行位置取决于场景:
|
||||
|
||||
- **客户端循环**(浏览器):常规 1:1 对话、继续生成、
|
||||
群组编排决策。循环在浏览器中运行,
|
||||
入口为 `internal_execAgentRuntime()`
|
||||
入口为 `executeClientAgent()`
|
||||
(`src/store/chat/slices/aiChat/actions/streamingExecutor.ts`)
|
||||
- **服务端循环**(队列 / 本地):群聊 supervisor agent、
|
||||
子 agent 任务、API/Cron 触发。循环在服务端运行,
|
||||
|
||||
@@ -868,6 +868,48 @@ table messages_files {
|
||||
}
|
||||
}
|
||||
|
||||
table messenger_account_links {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`]
|
||||
user_id text [not null]
|
||||
platform varchar(50) [not null]
|
||||
tenant_id varchar(255) [not null, default: '']
|
||||
platform_user_id varchar(255) [not null]
|
||||
platform_username text
|
||||
active_agent_id text
|
||||
accessed_at "timestamp with time zone" [not null, default: `now()`]
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
updated_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
(platform, tenant_id, platform_user_id) [name: 'messenger_account_links_platform_tenant_user_unique', unique]
|
||||
(user_id, platform, tenant_id) [name: 'messenger_account_links_user_platform_tenant_unique', unique]
|
||||
active_agent_id [name: 'messenger_account_links_active_agent_idx']
|
||||
}
|
||||
}
|
||||
|
||||
table messenger_installations {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`]
|
||||
platform varchar(50) [not null]
|
||||
tenant_id varchar(255) [not null]
|
||||
application_id varchar(255) [not null]
|
||||
account_id varchar(255)
|
||||
credentials text [not null]
|
||||
metadata jsonb [not null, default: `{}`]
|
||||
token_expires_at "timestamp with time zone"
|
||||
installed_by_user_id text
|
||||
installed_by_platform_user_id varchar(255)
|
||||
revoked_at "timestamp with time zone"
|
||||
accessed_at "timestamp with time zone" [not null, default: `now()`]
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
updated_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
(platform, application_id, tenant_id) [name: 'messenger_installations_platform_app_tenant_unique', unique]
|
||||
(platform, tenant_id) [name: 'messenger_installations_platform_tenant_idx']
|
||||
token_expires_at [name: 'messenger_installations_token_expires_at_idx']
|
||||
}
|
||||
}
|
||||
|
||||
table nextauth_accounts {
|
||||
access_token text
|
||||
expires_at integer
|
||||
@@ -1395,6 +1437,23 @@ table sessions {
|
||||
}
|
||||
}
|
||||
|
||||
table system_bot_providers {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`]
|
||||
platform varchar(50) [not null]
|
||||
enabled boolean [not null, default: true]
|
||||
credentials text [not null]
|
||||
application_id varchar(255)
|
||||
settings jsonb [not null, default: `{}`]
|
||||
connection_mode varchar(20)
|
||||
accessed_at "timestamp with time zone" [not null, default: `now()`]
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
updated_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
platform [name: 'system_bot_providers_platform_unique', unique]
|
||||
}
|
||||
}
|
||||
|
||||
table briefs {
|
||||
id text [pk, not null]
|
||||
user_id text [not null]
|
||||
@@ -1412,6 +1471,8 @@ table briefs {
|
||||
resolved_comment text
|
||||
read_at "timestamp with time zone"
|
||||
resolved_at "timestamp with time zone"
|
||||
trigger varchar(255)
|
||||
metadata jsonb
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
@@ -1422,6 +1483,7 @@ table briefs {
|
||||
type [name: 'briefs_type_idx']
|
||||
priority [name: 'briefs_priority_idx']
|
||||
(user_id, resolved_at) [name: 'briefs_unresolved_idx']
|
||||
trigger [name: 'briefs_trigger_idx']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1943,7 +2005,6 @@ table user_memory_persona_documents {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
ref: agent_skills.user_id - users.id
|
||||
|
||||
ref: agent_skills.zip_file_hash - global_files.hash_id
|
||||
|
||||
@@ -196,6 +196,31 @@ SSRF_ALLOW_IP_ADDRESS_LIST=192.168.1.100,10.0.0.50
|
||||
- Default: -
|
||||
- Example: `https://cdn.example.com`
|
||||
|
||||
## Visual Understanding
|
||||
|
||||
### `VISUAL_UNDERSTANDING_PROVIDER`
|
||||
|
||||
- Type: Optional
|
||||
- Description: Provider ID of the fallback visual understanding model. Configure this together with `VISUAL_UNDERSTANDING_MODEL` to let models without native image or video understanding inspect uploaded visual media through the built-in visual understanding tool.
|
||||
- Default: -
|
||||
- Example: `openai`, `google`, or `ollama`
|
||||
|
||||
### `VISUAL_UNDERSTANDING_MODEL`
|
||||
|
||||
- Type: Optional
|
||||
- Description: Model ID used by the fallback visual understanding tool. This model should support the visual media types you want to analyze. The feature is enabled only when both `VISUAL_UNDERSTANDING_PROVIDER` and `VISUAL_UNDERSTANDING_MODEL` are configured.
|
||||
- Default: -
|
||||
- Example: `gpt-4o`, `gemini-2.5-flash`, or your local visual model ID
|
||||
|
||||
Configuration example:
|
||||
|
||||
```bash
|
||||
VISUAL_UNDERSTANDING_PROVIDER=google
|
||||
VISUAL_UNDERSTANDING_MODEL=gemini-2.5-flash
|
||||
```
|
||||
|
||||
When this feature is enabled, users can upload images or videos while using a model that does not have native visual capabilities, as long as the active model supports tool use. File upload still requires the normal file storage configuration for your deployment.
|
||||
|
||||
## AI Image
|
||||
|
||||
### `AI_IMAGE_DEFAULT_IMAGE_NUM`
|
||||
|
||||
@@ -191,6 +191,31 @@ SSRF_ALLOW_IP_ADDRESS_LIST=192.168.1.100,10.0.0.50
|
||||
- 默认值:-
|
||||
- 示例:`https://cdn.example.com`
|
||||
|
||||
## 视觉理解
|
||||
|
||||
### `VISUAL_UNDERSTANDING_PROVIDER`
|
||||
|
||||
- 类型:可选
|
||||
- 描述:兜底视觉理解模型的服务商 ID。与 `VISUAL_UNDERSTANDING_MODEL` 一起配置后,不具备原生图片或视频理解能力的模型可以通过内置视觉理解工具分析上传的视觉媒体。
|
||||
- 默认值:-
|
||||
- 示例:`openai`、`google` 或 `ollama`
|
||||
|
||||
### `VISUAL_UNDERSTANDING_MODEL`
|
||||
|
||||
- 类型:可选
|
||||
- 描述:内置视觉理解工具使用的模型 ID。该模型应支持你希望分析的视觉媒体类型。仅当 `VISUAL_UNDERSTANDING_PROVIDER` 与 `VISUAL_UNDERSTANDING_MODEL` 同时配置时,此功能才会启用。
|
||||
- 默认值:-
|
||||
- 示例:`gpt-4o`、`gemini-2.5-flash` 或你的本地视觉模型 ID
|
||||
|
||||
配置示例:
|
||||
|
||||
```bash
|
||||
VISUAL_UNDERSTANDING_PROVIDER=google
|
||||
VISUAL_UNDERSTANDING_MODEL=gemini-2.5-flash
|
||||
```
|
||||
|
||||
启用后,当当前模型没有原生视觉能力但支持工具调用时,用户仍可上传图片或视频,并由兜底视觉理解模型进行分析。文件上传本身仍需要部署中正常配置文件存储能力。
|
||||
|
||||
## AI 图像
|
||||
|
||||
### `AI_IMAGE_DEFAULT_IMAGE_NUM`
|
||||
|
||||
@@ -119,6 +119,8 @@ See [AI Provider Configuration](/docs/self-hosting/environment-variables/model-p
|
||||
- **Cloudflare R2** — No egress fees
|
||||
- **RustFS / MinIO** — Self-hosted S3 alternative (included in Docker Compose)
|
||||
|
||||
**Visual understanding fallback** — Optional, but recommended if users will upload images or videos while chatting with models that do not have native visual capabilities. Configure `VISUAL_UNDERSTANDING_PROVIDER` and `VISUAL_UNDERSTANDING_MODEL` with a visual-capable model from one of your enabled AI providers. See [Basic environment variables](/docs/self-hosting/environment-variables/basic#visual-understanding) for details.
|
||||
|
||||
**Authentication provider** — For SSO and team features (Google OAuth, GitHub OAuth, Microsoft Azure AD, Auth0, Keycloak). See [Authentication Setup](/docs/self-hosting/auth) for configuration.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
@@ -123,6 +123,8 @@ LobeHub 由以下几个关键组件组成:
|
||||
- **Cloudflare R2** — 无出口流量费用
|
||||
- **RustFS / MinIO** — 自托管 S3 替代方案(Docker Compose 已内置)
|
||||
|
||||
**视觉理解兜底模型** — 可选,但如果用户会在没有原生视觉能力的模型中上传图片或视频,建议配置。你可以使用已启用 AI 提供商中的视觉模型配置 `VISUAL_UNDERSTANDING_PROVIDER` 和 `VISUAL_UNDERSTANDING_MODEL`。详见 [基础环境变量](/zh/docs/self-hosting/environment-variables/basic#视觉理解)。
|
||||
|
||||
**认证提供商** — 支持 SSO 和团队功能(Google OAuth、GitHub OAuth、Microsoft Azure AD、Auth0、Keycloak)。配置详见 [认证设置](/docs/self-hosting/auth)。
|
||||
|
||||
## 安全注意事项
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
---
|
||||
title: Connect LobeHub to LINE
|
||||
description: >-
|
||||
Learn how to connect a LINE Messaging API bot to your LobeHub agent,
|
||||
enabling your AI assistant to chat with users in LINE direct messages and
|
||||
group conversations.
|
||||
tags:
|
||||
- LINE
|
||||
- Message Channels
|
||||
- Bot Setup
|
||||
- Integration
|
||||
---
|
||||
|
||||
# Connect LobeHub to LINE
|
||||
|
||||
By connecting a LINE channel to your LobeHub agent, users can interact with the AI assistant through LINE direct messages, group chats, and multi-person rooms. The integration uses the official **LINE Messaging API** — there is no third-party broker between LINE and LobeHub.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A LobeHub account with an active subscription
|
||||
- A [LINE Business ID](https://account.line.biz/signup) (sign up with a LINE account or email)
|
||||
- A **LINE Official Account** — every Messaging API channel must be attached to one
|
||||
|
||||
> **Important change (since 2024-09-04):** LINE no longer lets you create a Messaging API channel directly from the LINE Developers Console. You must first create a LINE Official Account and enable the Messaging API on it from LINE Official Account Manager — the channel then appears automatically in the Developers Console.
|
||||
|
||||
## Step 1: Create a LINE Official Account and Enable the Messaging API
|
||||
|
||||
<Steps>
|
||||
### Create a LINE Official Account
|
||||
|
||||
Open [entry.line.biz](https://entry.line.biz/form/entry/unverified) and sign in with your LINE Business ID. Fill in the account name, business category, and region, then submit. Confirm the new account appears in [LINE Official Account Manager](https://manager.line.biz/).
|
||||
|
||||
### Enable the Messaging API in Official Account Manager
|
||||
|
||||
Open the new account → **Settings → Messaging API** → click **Enable Messaging API**. You will be asked to:
|
||||
|
||||
- Register developer information (first-time only).
|
||||
- Pick a **Provider** that will own this channel in the Developers Console — reuse an existing one, or create a fresh one (e.g. "LobeHub").
|
||||
|
||||
> **Heads-up:** the provider assignment is **permanent**. If you manage multiple unrelated services, give each one its own provider.
|
||||
|
||||
### Find the channel in the Developers Console
|
||||
|
||||
Sign in to [LINE Developers Console](https://developers.line.biz/console/) with the same LINE Business ID, open the provider you just chose, and the Messaging API channel will appear automatically.
|
||||
|
||||
### Note the channel identifiers
|
||||
|
||||
Open the **Basic settings** tab and copy the **Channel secret** — LobeHub uses it to verify webhook signatures in Step 4.
|
||||
|
||||
> **Note:** the **"Your user ID"** field on the same tab is **your own** LINE user ID, **not** the bot's destination user ID. Both have the identical `U` + 32 hex format, but LobeHub needs the bot's, which is resolved automatically from the Channel Access Token in Step 4.
|
||||
|
||||
Then open the **Messaging API** tab and note:
|
||||
|
||||
- **Bot basic ID** — the `@xxxx` short ID users will search for.
|
||||
</Steps>
|
||||
|
||||
## Step 2: Issue a Channel Access Token
|
||||
|
||||
<Steps>
|
||||
### Open the Messaging API tab
|
||||
|
||||
Scroll to the bottom of the **Messaging API** tab. You will see a **Channel access token** section.
|
||||
|
||||
### Issue a long-lived token
|
||||
|
||||
Click **Issue** under "Channel access token (long-lived)". Copy the token immediately — LINE only shows it once.
|
||||
|
||||
> **Important:** The Channel Access Token and Channel Secret are sensitive credentials. Never commit them to source control or share them in screenshots.
|
||||
</Steps>
|
||||
|
||||
## Step 3: Disable LINE's Built-in Auto-reply and Greeting
|
||||
|
||||
By default the LINE Official Account Manager auto-replies to user messages and sends a greeting on first contact. These compete with LobeHub's responses, so they must be turned off.
|
||||
|
||||
<Steps>
|
||||
### Open the LINE Official Account Manager
|
||||
|
||||
In the **Messaging API** tab, click the **LINE Official Account Manager** link to open the management UI for the channel's Official Account.
|
||||
|
||||
### Switch the response modes
|
||||
|
||||
Go to **Settings → Messaging API** (or **Response settings**) and set:
|
||||
|
||||
- **Greeting message:** Disabled
|
||||
- **Auto-response messages:** Disabled
|
||||
- **Webhooks:** Enabled
|
||||
|
||||
This leaves your bot to handle every inbound message itself.
|
||||
</Steps>
|
||||
|
||||
## Step 4: Configure LINE in LobeHub
|
||||
|
||||
<Steps>
|
||||
### Open Channel Settings
|
||||
|
||||
In LobeHub, navigate to your agent's settings, then select the **Channels** tab. Click **LINE** from the platform list.
|
||||
|
||||
### Fill in the credentials
|
||||
|
||||
Recommended order — paste the token first so LobeHub can auto-fill the Destination User ID:
|
||||
|
||||
1. **Channel Access Token** — paste the long-lived token issued in Step 2.
|
||||
2. **Destination User ID** — click **Fetch from LINE** next to this field. LobeHub calls `GET /v2/bot/info` with the token you just pasted and fills in the bot's `userId` (33 chars, starts with `U`) for you. You can also type/paste it manually if you already have it.
|
||||
3. **Channel Secret** — paste the Channel secret from the **Basic settings** tab.
|
||||
|
||||
> **Why the auto-fetch?** The LINE Developers Console does **not** display the bot's destination user ID anywhere — `/v2/bot/info` is the only way to read it. The **Fetch from LINE** button removes the manual `curl` step.
|
||||
>
|
||||
> <details>
|
||||
> <summary>Manual alternative (if the button is unavailable)</summary>
|
||||
>
|
||||
> ```bash
|
||||
> curl -H "Authorization: Bearer <YOUR_CHANNEL_ACCESS_TOKEN>" \
|
||||
> https://api.line.me/v2/bot/info
|
||||
> ```
|
||||
>
|
||||
> Copy the `userId` field from the response into the **Destination User ID** field.
|
||||
> </details>
|
||||
|
||||
### Save Configuration
|
||||
|
||||
Click **Save Configuration**. LobeHub will encrypt your credentials, call `GET /v2/bot/info` once to verify the token works and that the bot user ID matches your Destination User ID, and surface a **Webhook URL** for the next step.
|
||||
|
||||
> **Note:** Unlike Telegram, the LINE Messaging API does not allow programmatic webhook registration. LobeHub cannot wire the URL for you — you must paste it in the LINE Developers Console yourself in Step 5.
|
||||
</Steps>
|
||||
|
||||
## Step 5: Wire the Webhook in the LINE Developers Console
|
||||
|
||||
<Steps>
|
||||
### Copy the Webhook URL
|
||||
|
||||
In LobeHub's LINE channel detail page, copy the **Webhook URL** displayed under the credentials section. It looks like `https://app.lobehub.com/api/agent/webhooks/line/<your-destination-user-id>`.
|
||||
|
||||
### Paste it in the LINE Developers Console
|
||||
|
||||
Back in the **Messaging API** tab of your channel:
|
||||
|
||||
- **Webhook URL:** paste the LobeHub Webhook URL.
|
||||
- Click **Update**.
|
||||
- Click **Verify**. LINE sends a signed `POST` with `events: []` to LobeHub, which responds 200 if the Channel Secret matches.
|
||||
- Toggle **Use webhook** to **ON**.
|
||||
</Steps>
|
||||
|
||||
## Step 6: Test the Connection
|
||||
|
||||
<Steps>
|
||||
### Add the bot as a friend
|
||||
|
||||
Open the **Messaging API** tab in the LINE Developers Console and scan the bot's **QR code** with your phone, or search for the **Bot basic ID** (e.g. `@abc1234x`) in LINE.
|
||||
|
||||
### Send a real message
|
||||
|
||||
Send any message to the bot in LINE. Within a few seconds your LobeHub agent should reply.
|
||||
|
||||
### Run Test Connection (optional)
|
||||
|
||||
Click **Test Connection** in LobeHub's channel settings to re-verify the token and the bot identity match. Errors are surfaced with the exact LINE error message.
|
||||
</Steps>
|
||||
|
||||
## Adding the Bot to Group Chats
|
||||
|
||||
To use the bot in LINE group chats or multi-person rooms:
|
||||
|
||||
1. Add the bot as a friend (Step 6).
|
||||
2. Create a group or room and invite the bot, **or** invite the bot to an existing group from the bot's profile screen (`...` → **Invite**).
|
||||
3. Mention the bot or send a message — the bot will reply in the group or room.
|
||||
|
||||
> **Note:** Allowing your bot to join groups and rooms requires enabling **"Allow bot to join group chats"** in the LINE Official Account Manager (**Response settings**). It is off by default.
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
| Field | Required | Description |
|
||||
| ------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **Destination User ID** | Yes | The bot's user ID (`U` + 32 hex chars). LobeHub auto-fills this via the **Fetch from LINE** button (calls `GET /v2/bot/info` with the access token); the LINE Developers Console UI does not display the value. Used as the bot identity and webhook path segment. |
|
||||
| **Channel Access Token** | Yes | Long-lived token issued from the **Messaging API** tab. Used as the bearer header on every LINE API call. |
|
||||
| **Channel Secret** | Yes | From the **Basic settings** tab. Used to verify `X-Line-Signature` on every inbound webhook delivery. |
|
||||
|
||||
## Feature Notes
|
||||
|
||||
LINE's Messaging API has a few specifics that LobeHub maps as follows:
|
||||
|
||||
- **Markdown** — LINE renders text messages as **plain text** only. LobeHub strips Markdown markup before sending so emphasis / heading / list markers are removed.
|
||||
- **Message editing** — the Messaging API does not support editing sent messages, so LobeHub only sends the **final reply**, not per-step progress edits.
|
||||
- **Typing indicator** — the loading animation is shown in 1:1 user chats only. Group and multi-person room threads silently no-op.
|
||||
- **Reactions** — LINE bots cannot send message reactions today, so the 👀 / ✏️ status reactions used on Discord and Slack are not surfaced.
|
||||
- **Outbound** — LobeHub uses the **push API** (`/v2/bot/message/push`) rather than the reply API, because the reply token expires in \~60s while agent generation can take longer. Push messages count against your channel's monthly quota for paid plans; the free Developer Trial is unlimited.
|
||||
- **Attachments** — inbound images, video, audio, and files are downloaded on demand from the LINE data domain and forwarded to the model. Outbound replies are text-only today.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"Verify" fails in the LINE Developers Console.** The Channel Secret in LobeHub must match the value shown on the LINE Developers Console **Basic settings** tab exactly. Re-paste it, save, and try again.
|
||||
- **`Authentication failed.` on Save / Test Connection.** Your Channel Access Token is invalid or expired. Re-issue the long-lived token in the Messaging API tab and paste the new value into LobeHub.
|
||||
- **`Channel access token belongs to bot Uxxx, not Uyyy`.** The Destination User ID does not match the token. Easiest fix: clear the field and click **Fetch from LINE** to re-pull the correct `userId`. The `Uxxx` shown in the error is also the userId the token actually belongs to — you can paste it in directly. (Manual check: `curl -H "Authorization: Bearer <token>" https://api.line.me/v2/bot/info`.)
|
||||
- **Webhook delivery is rejected with `401 Invalid signature`.** The Channel Secret in LobeHub doesn't match the one in LINE. Update LobeHub with the correct Channel Secret.
|
||||
- **Bot doesn't respond.** Check that:
|
||||
1. **Use webhook** is toggled **ON** in the Messaging API tab.
|
||||
2. **Auto-response messages** and **Greeting message** are disabled in the LINE Official Account Manager.
|
||||
3. The user has added the bot as a friend (LINE will not deliver messages from non-friends).
|
||||
- **Bot doesn't respond in groups.** Make sure **"Allow bot to join group chats"** is enabled in the Official Account Manager's **Response settings**, then re-invite the bot to the group.
|
||||
@@ -0,0 +1,197 @@
|
||||
---
|
||||
title: 将 LobeHub 连接到 LINE
|
||||
description: >-
|
||||
学习如何通过 LINE Messaging API 将 LINE 机器人连接到 LobeHub
|
||||
代理,使您的 AI 助手能够在 LINE 私信和群组对话中与用户互动。
|
||||
tags:
|
||||
- LINE
|
||||
- 消息渠道
|
||||
- 机器人设置
|
||||
- 集成
|
||||
---
|
||||
|
||||
# 将 LobeHub 连接到 LINE
|
||||
|
||||
通过将 LINE 渠道连接到您的 LobeHub 代理,用户可以通过 LINE 私信、群组聊天以及多人聊天室与 AI 助手互动。集成使用官方的 **LINE Messaging API**,LINE 与 LobeHub 之间没有第三方中转。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 一个拥有有效订阅的 LobeHub 账户
|
||||
- 一个 [LINE Business ID](https://account.line.biz/signup)(可用 LINE 账号或邮箱注册)
|
||||
- 一个 **LINE Official Account**(Messaging API channel 必须挂在 Official Account 下)
|
||||
|
||||
> **重要变化(2024-09-04 起):** LINE 已不再允许直接在 LINE Developers Console 中创建 Messaging API channel。必须先建 LINE Official Account,再在 Official Account Manager 中启用 Messaging API,对应的 channel 才会在 Developers Console 中自动出现。
|
||||
|
||||
## 第一步:创建 LINE Official Account 并启用 Messaging API
|
||||
|
||||
<Steps>
|
||||
### 创建 LINE Official Account
|
||||
|
||||
打开 [entry.line.biz](https://entry.line.biz/form/entry/unverified),用 LINE Business ID 登录,按表单填写账号名称、行业、地区等信息提交。完成后到 [LINE Official Account Manager](https://manager.line.biz/) 确认账号已经出现在列表中。
|
||||
|
||||
### 在 Account Manager 中启用 Messaging API
|
||||
|
||||
进入这个 Official Account → **Settings → Messaging API** → 点击 **Enable Messaging API**。系统会让你:
|
||||
|
||||
- 填写开发者信息(首次启用时)。
|
||||
- 选择一个 **Provider**(用来在 Developers Console 中分组 channel)—— 已有的可以复用,没有就新建一个,例如 "LobeHub"。
|
||||
|
||||
> **注意:** Provider 一旦绑定就 **不能修改**。如果同时管理多个不相关业务,建议为每个业务用独立的 Provider。
|
||||
|
||||
### 在 Developers Console 中找到 channel
|
||||
|
||||
用同一个 LINE Business ID 登录 [LINE Developers Console](https://developers.line.biz/console/),选中刚才绑的 Provider,对应的 Messaging API channel 会自动出现。
|
||||
|
||||
### 记录 channel 的关键标识
|
||||
|
||||
打开 **Basic settings** 选项卡,复制 **Channel secret** —— 用于第四步校验 Webhook 签名。
|
||||
|
||||
> **注意:** 同一选项卡里的 **"Your user ID"** 是**你账号自己的** LINE user ID,**不是** bot 的 destination user ID。两者格式完全一样(都以 `U` 开头共 33 位),但 LobeHub 需要的是 bot 的,会在第四步根据 Channel Access Token 自动解析。
|
||||
|
||||
然后切到 **Messaging API** 选项卡,记下:
|
||||
|
||||
- **Bot basic ID** —— 用户搜索机器人时使用的 `@xxxx` 短 ID。
|
||||
</Steps>
|
||||
|
||||
## 第二步:签发 Channel Access Token
|
||||
|
||||
<Steps>
|
||||
### 打开 Messaging API 选项卡
|
||||
|
||||
滚到 **Messaging API** 选项卡底部的 **Channel access token** 区域。
|
||||
|
||||
### 签发长期 Token
|
||||
|
||||
在 "Channel access token (long-lived)" 处点击 **Issue**。立即复制 Token —— LINE 只展示一次。
|
||||
|
||||
> **重要提示:** Channel Access Token 与 Channel Secret 都是敏感凭据,切勿提交到代码仓库或在截图中泄露。
|
||||
</Steps>
|
||||
|
||||
## 第三步:关闭 LINE 自带的自动回复与欢迎语
|
||||
|
||||
LINE Official Account Manager 默认会自动回复用户消息并在第一次接触时发送欢迎语,会与 LobeHub 的回复发生冲突,需要关闭。
|
||||
|
||||
<Steps>
|
||||
### 打开 LINE Official Account Manager
|
||||
|
||||
在 **Messaging API** 选项卡中,点击 **LINE Official Account Manager** 链接进入对应 Official Account 的管理界面。
|
||||
|
||||
### 切换 response 模式
|
||||
|
||||
打开 **设置 → Messaging API**(或 **Response settings**),调整为:
|
||||
|
||||
- **Greeting message:** Disabled
|
||||
- **Auto-response messages:** Disabled
|
||||
- **Webhooks:** Enabled
|
||||
|
||||
这样所有入站消息都会交给你的机器人处理。
|
||||
</Steps>
|
||||
|
||||
## 第四步:在 LobeHub 中配置 LINE
|
||||
|
||||
<Steps>
|
||||
### 打开渠道设置
|
||||
|
||||
在 LobeHub 中,进入您的代理设置,选择 **渠道** 标签。在平台列表中点击 **LINE**。
|
||||
|
||||
### 填写凭据
|
||||
|
||||
推荐顺序 —— 先粘贴 Token,LobeHub 会帮你自动填入 Destination User ID:
|
||||
|
||||
1. **Channel Access Token** —— 粘贴第二步签发的长期 Token。
|
||||
2. **Destination User ID** —— 点击该字段旁的 **从 LINE 获取** 按钮。LobeHub 会用刚才填入的 Token 调用 `GET /v2/bot/info`,自动取出 bot 的 `userId`(以 `U` 开头共 33 位)并填入。如果你已经拿到这个值,也可以手动粘贴。
|
||||
3. **Channel Secret** —— 粘贴 **Basic settings** 选项卡的 Channel secret。
|
||||
|
||||
> **为什么需要自动获取?** LINE Developers Console 界面**不展示** bot 的 destination user ID,唯一的获取方式就是调用 `/v2/bot/info`。**从 LINE 获取** 按钮会把这一步 `curl` 替你做掉。
|
||||
>
|
||||
> <details>
|
||||
> <summary>手动备选方案(按钮不可用时)</summary>
|
||||
>
|
||||
> ```bash
|
||||
> curl -H "Authorization: Bearer <你的 channel access token>" \
|
||||
> https://api.line.me/v2/bot/info
|
||||
> ```
|
||||
>
|
||||
> 把返回 JSON 中的 `userId` 字段复制到 **Destination User ID** 即可。
|
||||
> </details>
|
||||
|
||||
### 保存配置
|
||||
|
||||
点击 **保存配置**。LobeHub 会加密您的凭据,调用一次 `GET /v2/bot/info` 验证 Token 可用、且返回的 bot user ID 与 Destination User ID 一致,并在凭据下方显示 **Webhook URL** 供下一步使用。
|
||||
|
||||
> **注意:** 与 Telegram 不同,LINE Messaging API 不支持程序化注册 Webhook,LobeHub 无法替您在 LINE Developers Console 中填写 URL,需要您在第五步中自行粘贴。
|
||||
</Steps>
|
||||
|
||||
## 第五步:在 LINE Developers Console 配置 Webhook
|
||||
|
||||
<Steps>
|
||||
### 复制 Webhook URL
|
||||
|
||||
在 LobeHub 的 LINE 渠道详情页中,复制凭据区域下方显示的 **Webhook URL**。形如 `https://app.lobehub.com/api/agent/webhooks/line/<your-destination-user-id>`。
|
||||
|
||||
### 粘贴到 LINE Developers Console
|
||||
|
||||
回到 channel 的 **Messaging API** 选项卡:
|
||||
|
||||
- **Webhook URL:** 粘贴 LobeHub 的 Webhook URL。
|
||||
- 点击 **Update**。
|
||||
- 点击 **Verify**。LINE 会向 LobeHub 发送一个签名后的 `POST`(`events: []`),Channel Secret 匹配时 LobeHub 返回 200。
|
||||
- 将 **Use webhook** 切到 **ON**。
|
||||
</Steps>
|
||||
|
||||
## 第六步:测试连接
|
||||
|
||||
<Steps>
|
||||
### 添加机器人为好友
|
||||
|
||||
在 LINE Developers Console 的 **Messaging API** 选项卡,使用手机扫描机器人的 **QR code**;也可以直接在 LINE 中搜索 **Bot basic ID**(例如 `@abc1234x`)。
|
||||
|
||||
### 发送一条真实消息
|
||||
|
||||
在 LINE 里向机器人发送任意消息。几秒内 LobeHub 代理就会回复。
|
||||
|
||||
### 运行测试连接(可选)
|
||||
|
||||
在 LobeHub 渠道设置中点击 **测试连接**,再次校验 Token 与 bot 身份是否匹配,错误信息会透传 LINE 返回的具体内容。
|
||||
</Steps>
|
||||
|
||||
## 在群聊中使用机器人
|
||||
|
||||
要在 LINE 群聊或多人聊天室使用机器人:
|
||||
|
||||
1. 先按第六步将机器人加为好友。
|
||||
2. 创建群组或聊天室并邀请机器人;也可以从机器人个人页(`...` → **Invite**)邀请到已存在的群组。
|
||||
3. 在群里 @ 机器人或直接发送消息,机器人会在群组或聊天室中回复。
|
||||
|
||||
> **注意:** 允许机器人加入群组与聊天室需要在 LINE Official Account Manager 的 **Response settings** 中启用 **"Allow bot to join group chats"**,默认是关闭的。
|
||||
|
||||
## 配置参考
|
||||
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| ------------------------ | ---- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Destination User ID** | 是 | 机器人的用户 ID(`U` + 32 位十六进制)。LobeHub 会通过 **从 LINE 获取** 按钮(背后调用 `GET /v2/bot/info`)自动填入;LINE Developers Console 界面不展示这个值。同时作为机器人标识与 Webhook 路径。 |
|
||||
| **Channel Access Token** | 是 | **Messaging API** 选项卡中签发的长期 Token,用作每个 LINE API 调用的 bearer 头。 |
|
||||
| **Channel Secret** | 是 | **Basic settings** 选项卡中的 Channel secret,用于校验每条入站 Webhook 的 `X-Line-Signature`。 |
|
||||
|
||||
## 能力说明
|
||||
|
||||
LINE Messaging API 有一些平台层面的限制,LobeHub 的对接行为如下:
|
||||
|
||||
- **Markdown** —— LINE 文本消息按 **纯文本** 渲染。LobeHub 在发送前会通过 `stripMarkdown` 去掉强调、标题、列表等标记。
|
||||
- **消息编辑** —— Messaging API 不支持编辑已发送消息,因此 LobeHub 只发送 **最终回复**,不会逐步刷新中间进度。
|
||||
- **输入提示动画** —— 仅对 1:1 用户聊天有效,群组与多人聊天室静默 no-op。
|
||||
- **表情反应** —— LINE 机器人当前不能发送消息反应,Discord/Slack 上使用的 👀 / ✏️ 状态反应不会显示。
|
||||
- **出站** —— LobeHub 使用 **push API**(`/v2/bot/message/push`)而非 reply API,因为 reply token 60 秒就过期,而 agent 生成回复可能更慢。Push 消息会消耗付费方案的月度配额,免费的 Developer Trial 实际不限量。
|
||||
- **附件** —— 入站的图片、视频、音频、文件会按需通过 LINE 数据子域下载并送给模型;出站消息当前仅支持文本。
|
||||
|
||||
## 故障排除
|
||||
|
||||
- **LINE Developers Console 的 "Verify" 失败。** LobeHub 中的 Channel Secret 必须与 **Basic settings** 选项卡显示的值完全一致。重新粘贴、保存后再试。
|
||||
- **保存或测试连接时出现 `Authentication failed.`。** Channel Access Token 失效或被撤销。在 Messaging API 选项卡里重新签发长期 Token,并把新值粘贴到 LobeHub。
|
||||
- **`Channel access token belongs to bot Uxxx, not Uyyy`。** Destination User ID 与 Token 不匹配。最简方式:清空字段后点击 **从 LINE 获取**,让 LobeHub 重新拉到正确的 `userId`。错误消息里的 `Uxxx` 也是 Token 实际归属的 bot userId,直接复制粘贴也可以。(手动核对:`curl -H "Authorization: Bearer <token>" https://api.line.me/v2/bot/info`。)
|
||||
- **Webhook 投递返回 `401 Invalid signature`。** LobeHub 中的 Channel Secret 与 LINE 的不一致,更新为正确的 Channel Secret。
|
||||
- **机器人不回复。** 请依次确认:
|
||||
1. Messaging API 选项卡中的 **Use webhook** 已切到 **ON**。
|
||||
2. LINE Official Account Manager 中的 **Auto-response messages** 与 **Greeting message** 都已禁用。
|
||||
3. 用户已经把机器人加为好友(非好友消息 LINE 不会推送)。
|
||||
- **群聊中机器人不回复。** 确认 LINE Official Account Manager 的 **Response settings** 中已启用 **"Allow bot to join group chats"**,然后将机器人重新邀请进群组。
|
||||
@@ -2,8 +2,8 @@
|
||||
title: Channels Overview
|
||||
description: >-
|
||||
Connect your LobeHub agents to external messaging platforms like Discord,
|
||||
Slack, Telegram, QQ, WeChat, Feishu, and Lark, allowing users to interact with
|
||||
AI assistants directly in their favorite chat apps.
|
||||
Slack, Telegram, LINE, QQ, WeChat, Feishu, and Lark, allowing users to
|
||||
interact with AI assistants directly in their favorite chat apps.
|
||||
tags:
|
||||
- Channels
|
||||
- Message Channels
|
||||
@@ -11,6 +11,7 @@ tags:
|
||||
- Discord
|
||||
- Slack
|
||||
- Telegram
|
||||
- LINE
|
||||
- QQ
|
||||
- WeChat
|
||||
- Feishu
|
||||
@@ -32,6 +33,7 @@ Channels allow you to connect your LobeHub agents to external messaging platform
|
||||
| [Discord](/docs/usage/channels/discord) | Connect to Discord servers for channel chat and direct messages |
|
||||
| [Slack](/docs/usage/channels/slack) | Connect to Slack for channel and direct message conversations |
|
||||
| [Telegram](/docs/usage/channels/telegram) | Connect to Telegram for private and group conversations |
|
||||
| [LINE](/docs/usage/channels/line) | Connect to LINE Messaging API for direct and group chats |
|
||||
| [QQ](/docs/usage/channels/qq) | Connect to QQ for group chats and direct messages |
|
||||
| [WeChat (微信)](/docs/usage/channels/wechat) | Connect to WeChat via iLink Bot for private and group chats (requires an active subscription) |
|
||||
| [Feishu (飞书)](/docs/usage/channels/feishu) | Connect to Feishu for team collaboration (Chinese version) |
|
||||
@@ -42,7 +44,7 @@ Channels allow you to connect your LobeHub agents to external messaging platform
|
||||
Each channel integration works by linking a bot account on the target platform to a LobeHub agent. When a user sends a message to the bot, LobeHub processes it through the agent and sends the response back to the same conversation.
|
||||
|
||||
- **Per-agent configuration** — Each agent can have its own set of channel connections, so different agents can serve different platforms or communities.
|
||||
- **Multiple channels simultaneously** — A single agent can be connected to Discord, Slack, Telegram, QQ, WeChat, Feishu, and Lark at the same time. LobeHub routes messages to the correct agent automatically.
|
||||
- **Multiple channels simultaneously** — A single agent can be connected to Discord, Slack, Telegram, LINE, QQ, WeChat, Feishu, and Lark at the same time. LobeHub routes messages to the correct agent automatically.
|
||||
- **Secure credential storage** — All bot tokens and app secrets are encrypted before being stored.
|
||||
|
||||
## Getting Started
|
||||
@@ -52,6 +54,7 @@ Each channel integration works by linking a bot account on the target platform t
|
||||
- [Discord](/docs/usage/channels/discord)
|
||||
- [Slack](/docs/usage/channels/slack)
|
||||
- [Telegram](/docs/usage/channels/telegram)
|
||||
- [LINE](/docs/usage/channels/line)
|
||||
- [QQ](/docs/usage/channels/qq)
|
||||
- [WeChat (微信)](/docs/usage/channels/wechat)
|
||||
- [Feishu (飞书)](/docs/usage/channels/feishu)
|
||||
@@ -63,13 +66,13 @@ If you do not see **WeChat** in the channel list, check that your account has an
|
||||
|
||||
Text messages are supported across all platforms. Some features vary by platform:
|
||||
|
||||
| Feature | Discord | Slack | Telegram | QQ | WeChat | Feishu | Lark |
|
||||
| ---------------------- | ------- | ----- | -------- | --- | ------ | ------- | ------- |
|
||||
| Text messages | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
|
||||
| Direct messages | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
|
||||
| Group chats | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
|
||||
| Reactions | Yes | Yes | Yes | No | No | Partial | Partial |
|
||||
| Image/file attachments | Yes | Yes | Yes | Yes | No | Yes | Yes |
|
||||
| Feature | Discord | Slack | Telegram | LINE | QQ | WeChat | Feishu | Lark |
|
||||
| ---------------------- | ------- | ----- | -------- | ------- | --- | ------ | ------- | ------- |
|
||||
| Text messages | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
|
||||
| Direct messages | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
|
||||
| Group chats | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
|
||||
| Reactions | Yes | Yes | Yes | No | No | No | Partial | Partial |
|
||||
| Image/file attachments | Yes | Yes | Yes | Inbound | Yes | No | Yes | Yes |
|
||||
|
||||
## Allowed Users (global)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: 渠道概览
|
||||
description: >-
|
||||
将 LobeHub 代理连接到外部消息平台,如 Discord、Slack、Telegram、QQ、微信、飞书和
|
||||
将 LobeHub 代理连接到外部消息平台,如 Discord、Slack、Telegram、LINE、QQ、微信、飞书和
|
||||
Lark,让用户可以直接在他们喜欢的聊天应用中与 AI 助手互动。
|
||||
tags:
|
||||
- 渠道
|
||||
@@ -10,6 +10,7 @@ tags:
|
||||
- Discord
|
||||
- Slack
|
||||
- Telegram
|
||||
- LINE
|
||||
- QQ
|
||||
- 微信
|
||||
- 飞书
|
||||
@@ -26,22 +27,23 @@ tags:
|
||||
|
||||
## 支持的平台
|
||||
|
||||
| 平台 | 描述 |
|
||||
| ----------------------------------------- | ---------------------------------- |
|
||||
| [Discord](/docs/usage/channels/discord) | 连接到 Discord 服务器,用于频道聊天和私信 |
|
||||
| [Slack](/docs/usage/channels/slack) | 连接到 Slack,用于频道和私信对话 |
|
||||
| [Telegram](/docs/usage/channels/telegram) | 连接到 Telegram,用于私人和群组对话 |
|
||||
| [QQ](/docs/usage/channels/qq) | 连接到 QQ,用于群聊和私信 |
|
||||
| [微信](/docs/usage/channels/wechat) | 通过 iLink Bot 连接到微信,用于私聊和群聊(需要有效订阅) |
|
||||
| [飞书](/docs/usage/channels/feishu) | 连接到飞书,用于团队协作(中国版) |
|
||||
| [Lark](/docs/usage/channels/lark) | 连接到 Lark,用于团队协作(国际版) |
|
||||
| 平台 | 描述 |
|
||||
| ----------------------------------------- | -------------------------------------- |
|
||||
| [Discord](/docs/usage/channels/discord) | 连接到 Discord 服务器,用于频道聊天和私信 |
|
||||
| [Slack](/docs/usage/channels/slack) | 连接到 Slack,用于频道和私信对话 |
|
||||
| [Telegram](/docs/usage/channels/telegram) | 连接到 Telegram,用于私人和群组对话 |
|
||||
| [LINE](/docs/usage/channels/line) | 通过 LINE Messaging API 连接到 LINE,支持私聊和群聊 |
|
||||
| [QQ](/docs/usage/channels/qq) | 连接到 QQ,用于群聊和私信 |
|
||||
| [微信](/docs/usage/channels/wechat) | 通过 iLink Bot 连接到微信,用于私聊和群聊(需要有效订阅) |
|
||||
| [飞书](/docs/usage/channels/feishu) | 连接到飞书,用于团队协作(中国版) |
|
||||
| [Lark](/docs/usage/channels/lark) | 连接到 Lark,用于团队协作(国际版) |
|
||||
|
||||
## 工作原理
|
||||
|
||||
每个渠道集成都通过将目标平台上的机器人账户与 LobeHub 代理连接来实现。当用户向机器人发送消息时,LobeHub 会通过代理处理消息并将响应发送回同一对话。
|
||||
|
||||
- **按代理配置** — 每个代理可以拥有自己的一组渠道连接,因此不同的代理可以服务于不同的平台或社区。
|
||||
- **同时支持多个渠道** — 单个代理可以同时连接到 Discord、Slack、Telegram、QQ、微信、飞书和 Lark。LobeHub 会自动将消息路由到正确的代理。
|
||||
- **同时支持多个渠道** — 单个代理可以同时连接到 Discord、Slack、Telegram、LINE、QQ、微信、飞书和 Lark。LobeHub 会自动将消息路由到正确的代理。
|
||||
- **安全的凭据存储** — 所有机器人令牌和应用密钥在存储前都会被加密。
|
||||
|
||||
## 快速开始
|
||||
@@ -51,6 +53,7 @@ tags:
|
||||
- [Discord](/docs/usage/channels/discord)
|
||||
- [Slack](/docs/usage/channels/slack)
|
||||
- [Telegram](/docs/usage/channels/telegram)
|
||||
- [LINE](/docs/usage/channels/line)
|
||||
- [QQ](/docs/usage/channels/qq)
|
||||
- [微信](/docs/usage/channels/wechat)
|
||||
- [飞书](/docs/usage/channels/feishu)
|
||||
@@ -62,13 +65,13 @@ tags:
|
||||
|
||||
所有平台均支持文本消息。某些功能因平台而异:
|
||||
|
||||
| 功能 | Discord | Slack | Telegram | QQ | 微信 | 飞书 | Lark |
|
||||
| --------- | ------- | ----- | -------- | -- | -- | ---- | ---- |
|
||||
| 文本消息 | 是 | 是 | 是 | 是 | 是 | 是 | 是 |
|
||||
| 私人消息 | 是 | 是 | 是 | 是 | 是 | 是 | 是 |
|
||||
| 群组聊天 | 是 | 是 | 是 | 是 | 是 | 是 | 是 |
|
||||
| 表情反应 | 是 | 是 | 是 | 否 | 否 | 部分支持 | 部分支持 |
|
||||
| 图片 / 文件附件 | 是 | 是 | 是 | 是 | 否 | 是 | 是 |
|
||||
| 功能 | Discord | Slack | Telegram | LINE | QQ | 微信 | 飞书 | Lark |
|
||||
| --------- | ------- | ----- | -------- | ---- | -- | -- | ---- | ---- |
|
||||
| 文本消息 | 是 | 是 | 是 | 是 | 是 | 是 | 是 | 是 |
|
||||
| 私人消息 | 是 | 是 | 是 | 是 | 是 | 是 | 是 | 是 |
|
||||
| 群组聊天 | 是 | 是 | 是 | 是 | 是 | 是 | 是 | 是 |
|
||||
| 表情反应 | 是 | 是 | 是 | 否 | 否 | 否 | 部分支持 | 部分支持 |
|
||||
| 图片 / 文件附件 | 是 | 是 | 是 | 仅入站 | 是 | 否 | 是 | 是 |
|
||||
|
||||
## 允许的用户(全局)
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
@journey @home @chat-input
|
||||
Feature: Home 页面默认 Chat Input 发送链路
|
||||
作为用户,我希望首次从 Home 页面发送默认消息时,可以稳定进入新建 Topic 对话页面
|
||||
|
||||
Background:
|
||||
Given 用户已登录系统
|
||||
|
||||
@HOME-CHAT-COLD-001 @P0
|
||||
Scenario: 首次打开 Agent 路由且无缓存时,Home 默认输入发送后应跳转到新建 Topic
|
||||
Given 用户在冷启动 Home 页面并延迟 Agent 路由加载
|
||||
When 用户在输入框中输入 "cold route home message"
|
||||
And 用户按 Enter 从 Home 默认输入发送
|
||||
Then 页面应该跳转到新建 Topic 对话页面
|
||||
And 用户消息 "cold route home message" 应该保留在对话中
|
||||
@@ -1,47 +0,0 @@
|
||||
@journey @home @starter
|
||||
Feature: Home 页面 Starter 快捷创建功能
|
||||
作为用户,我希望在 Home 页面可以通过 Starter 快捷创建 Agent、Group 或文档,并跳转到对应页面
|
||||
|
||||
Background:
|
||||
Given 用户已登录系统
|
||||
|
||||
# ============================================
|
||||
# 创建 Agent 后侧边栏刷新
|
||||
# ============================================
|
||||
|
||||
@HOME-STARTER-AGENT-001 @P0
|
||||
Scenario: 通过 Home 页面创建 Agent 后返回首页侧边栏应显示新创建的 Agent
|
||||
Given 用户在 Home 页面
|
||||
When 用户点击创建 Agent 按钮
|
||||
And 用户在输入框中输入 "E2E Test Agent"
|
||||
And 用户按 Enter 发送
|
||||
Then 页面应该跳转到 Agent 的 profile 页面
|
||||
When 用户返回 Home 页面
|
||||
Then 新创建的 Agent 应该在侧边栏中显示
|
||||
|
||||
# ============================================
|
||||
# 创建 Group 后侧边栏刷新
|
||||
# ============================================
|
||||
|
||||
@HOME-STARTER-GROUP-001 @P0
|
||||
Scenario: 通过 Home 页面创建 Group 后返回首页侧边栏应显示新创建的 Group
|
||||
Given 用户在 Home 页面
|
||||
When 用户点击创建 Group 按钮
|
||||
And 用户在输入框中输入 "E2E Test Group"
|
||||
And 用户按 Enter 发送
|
||||
Then 页面应该跳转到 Group 的 profile 页面
|
||||
When 用户返回 Home 页面
|
||||
Then 新创建的 Group 应该在侧边栏中显示
|
||||
|
||||
# ============================================
|
||||
# 创建文档并跳转到写作页面
|
||||
# ============================================
|
||||
|
||||
@HOME-STARTER-WRITE-001 @P0
|
||||
Scenario: 通过 Home 页面快捷创建文档并跳转到写作页面
|
||||
Given 用户在 Home 页面
|
||||
When 用户点击写作按钮
|
||||
And 用户在输入框中输入 "帮我写一篇关于人工智能的文章"
|
||||
And 用户按 Enter 发送创建文档
|
||||
Then 页面应该跳转到文档编辑页面
|
||||
And Page Agent 应该收到用户的提示词
|
||||
@@ -0,0 +1,137 @@
|
||||
import { After, Given, Then, When } from '@cucumber/cucumber';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
import { llmMockManager } from '../../mocks/llm';
|
||||
import type { CustomWorld } from '../../support/world';
|
||||
import { WAIT_TIMEOUT } from '../../support/world';
|
||||
|
||||
const COLD_ROUTE_SCRIPT_DELAY = 2500;
|
||||
|
||||
const delay = (ms: number) =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
|
||||
const focusHomeChatInput = async (world: CustomWorld): Promise<void> => {
|
||||
const candidates = [
|
||||
world.page.locator('[data-testid="chat-input"] textarea'),
|
||||
world.page.locator('[data-testid="chat-input"] [contenteditable="true"]'),
|
||||
world.page.getByRole('textbox'),
|
||||
world.page.locator('[data-testid="chat-input"]'),
|
||||
];
|
||||
|
||||
for (const locator of candidates) {
|
||||
const count = await locator.count();
|
||||
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const item = locator.nth(index);
|
||||
const visible = await item.isVisible().catch(() => false);
|
||||
if (!visible) continue;
|
||||
|
||||
await item.click({ force: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Could not find a visible Home chat input to focus');
|
||||
};
|
||||
|
||||
Given(
|
||||
'用户在冷启动 Home 页面并延迟 Agent 路由加载',
|
||||
{ timeout: 45_000 },
|
||||
async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 设置快速 LLM mock...');
|
||||
llmMockManager.clearResponses();
|
||||
llmMockManager.setConfig({
|
||||
responseDelay: 0,
|
||||
streamChunkSize: 1024,
|
||||
streamDelay: 0,
|
||||
});
|
||||
llmMockManager.setResponse('cold route home message', 'cold route response');
|
||||
await llmMockManager.setup(this.page);
|
||||
|
||||
console.log(' 📍 Step: 注册冷路由脚本延迟...');
|
||||
this.testContext.delayColdAgentScripts = false;
|
||||
|
||||
await this.page.route('**/*', async (route) => {
|
||||
const request = route.request();
|
||||
const url = request.url();
|
||||
const shouldDelay =
|
||||
this.testContext.delayColdAgentScripts === true &&
|
||||
request.resourceType() === 'script' &&
|
||||
(url.includes('/_next/static/') ||
|
||||
url.includes('/src/routes/(main)/agent') ||
|
||||
url.includes('/src/routes/%28main%29/agent'));
|
||||
|
||||
if (shouldDelay) {
|
||||
console.log(` ⏳ Delaying cold agent script: ${url}`);
|
||||
await delay(COLD_ROUTE_SCRIPT_DELAY);
|
||||
}
|
||||
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
console.log(' 📍 Step: 导航到 Home 页面...');
|
||||
await this.page.goto('/');
|
||||
|
||||
const chatInputContainer = this.page.locator('[data-testid="chat-input"]').first();
|
||||
await expect(chatInputContainer).toBeVisible({ timeout: WAIT_TIMEOUT });
|
||||
|
||||
console.log(' ✅ 已进入冷启动 Home 页面');
|
||||
},
|
||||
);
|
||||
|
||||
When(
|
||||
'用户在输入框中输入 {string}',
|
||||
{ timeout: 30_000 },
|
||||
async function (this: CustomWorld, text: string) {
|
||||
console.log(` 📍 Step: 在 Home 输入框中输入 "${text}"...`);
|
||||
|
||||
await focusHomeChatInput(this);
|
||||
await this.page.keyboard.type(text, { delay: 20 });
|
||||
|
||||
console.log(' ✅ 已输入 Home 默认消息');
|
||||
},
|
||||
);
|
||||
|
||||
When('用户按 Enter 从 Home 默认输入发送', { timeout: 45_000 }, async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 启用冷路由延迟并发送默认 Home 消息...');
|
||||
|
||||
await this.page.waitForTimeout(200);
|
||||
this.testContext.delayColdAgentScripts = true;
|
||||
|
||||
await this.page.keyboard.press('Enter');
|
||||
|
||||
await this.page.waitForURL(/\/agent\/[^/?#]+/, { timeout: WAIT_TIMEOUT });
|
||||
|
||||
console.log(' ✅ 已触发 Home 默认发送');
|
||||
});
|
||||
|
||||
Then('页面应该跳转到新建 Topic 对话页面', { timeout: 45_000 }, async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 验证 URL 进入新建 Topic...');
|
||||
|
||||
await this.page.waitForURL(/\/agent\/[^/?#]+\/[^/?#]+/, { timeout: 30_000 });
|
||||
|
||||
const currentUrl = this.page.url();
|
||||
expect(currentUrl).toMatch(/\/agent\/[^/?#]+\/[^/?#]+/);
|
||||
|
||||
console.log(` ✅ 已跳转到 Topic 页面: ${currentUrl}`);
|
||||
});
|
||||
|
||||
Then(
|
||||
'用户消息 {string} 应该保留在对话中',
|
||||
{ timeout: 45_000 },
|
||||
async function (this: CustomWorld, message: string) {
|
||||
console.log(` 📍 Step: 验证用户消息仍在对话中: ${message}`);
|
||||
|
||||
await expect(this.page.getByText(message).first()).toBeVisible({ timeout: WAIT_TIMEOUT });
|
||||
|
||||
console.log(' ✅ 用户消息已保留在对话中');
|
||||
},
|
||||
);
|
||||
|
||||
After({ tags: '@chat-input' }, async function (this: CustomWorld) {
|
||||
llmMockManager.resetConfig();
|
||||
llmMockManager.clearResponses();
|
||||
this.testContext.delayColdAgentScripts = false;
|
||||
});
|
||||
@@ -1,315 +0,0 @@
|
||||
/* eslint-disable no-console */
|
||||
/**
|
||||
* Home Starter Steps
|
||||
*
|
||||
* Step definitions for Home page Starter E2E tests
|
||||
* - Create Agent from Home input
|
||||
* - Create Group from Home input
|
||||
* - Create Document (Write) from Home input
|
||||
* - Verify Agent/Group appears in sidebar after returning to Home
|
||||
* - Verify Document page navigation and Page Agent interaction
|
||||
*/
|
||||
import { Given, Then, When } from '@cucumber/cucumber';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
import { llmMockManager, presetResponses } from '../../mocks/llm';
|
||||
import { type CustomWorld, WAIT_TIMEOUT } from '../../support/world';
|
||||
|
||||
// Store created IDs for verification
|
||||
let createdAgentId: string | null = null;
|
||||
let createdGroupId: string | null = null;
|
||||
let createdDocumentId: string | null = null;
|
||||
|
||||
// ============================================
|
||||
// Given Steps
|
||||
// ============================================
|
||||
|
||||
Given('用户在 Home 页面', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 设置 LLM mock...');
|
||||
// Setup LLM mock before navigation (for agent/group/page builder message)
|
||||
llmMockManager.setResponse('E2E Test Agent', presetResponses.greeting);
|
||||
llmMockManager.setResponse('E2E Test Group', presetResponses.greeting);
|
||||
llmMockManager.setResponse(
|
||||
'帮我写一篇关于人工智能的文章',
|
||||
'好的,我来帮你写一篇关于人工智能的文章。\n\n# 人工智能:改变世界的技术\n\n人工智能(AI)是当今最具变革性的技术之一...',
|
||||
);
|
||||
await llmMockManager.setup(this.page);
|
||||
|
||||
console.log(' 📍 Step: 导航到 Home 页面...');
|
||||
await this.page.goto('/');
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 15_000 });
|
||||
await this.page.waitForTimeout(1000);
|
||||
|
||||
// Reset IDs for each test
|
||||
createdAgentId = null;
|
||||
createdGroupId = null;
|
||||
createdDocumentId = null;
|
||||
|
||||
console.log(' ✅ 已进入 Home 页面');
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// When Steps
|
||||
// ============================================
|
||||
|
||||
When('用户点击创建 Agent 按钮', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 点击创建 Agent 按钮...');
|
||||
|
||||
// Find the "Create Agent" button by text (supports both English and Chinese)
|
||||
const createAgentButton = this.page
|
||||
.getByRole('button', { name: /create agent|创建智能体/i })
|
||||
.first();
|
||||
|
||||
await expect(createAgentButton).toBeVisible({ timeout: WAIT_TIMEOUT });
|
||||
await createAgentButton.click();
|
||||
|
||||
// Wait for mode switch animation and ChatInput scroll-into-view to settle
|
||||
await this.page.waitForTimeout(800);
|
||||
|
||||
console.log(' ✅ 已点击创建 Agent 按钮');
|
||||
});
|
||||
|
||||
When('用户点击创建 Group 按钮', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 点击创建 Group 按钮...');
|
||||
|
||||
// Find the "Create Group" button by text (supports both English and Chinese)
|
||||
const createGroupButton = this.page
|
||||
.getByRole('button', { name: /create group|创建群组/i })
|
||||
.first();
|
||||
|
||||
await expect(createGroupButton).toBeVisible({ timeout: WAIT_TIMEOUT });
|
||||
await createGroupButton.click();
|
||||
|
||||
// Wait for mode switch animation and ChatInput scroll-into-view to settle
|
||||
await this.page.waitForTimeout(800);
|
||||
|
||||
console.log(' ✅ 已点击创建 Group 按钮');
|
||||
});
|
||||
|
||||
When('用户点击写作按钮', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 点击写作按钮...');
|
||||
|
||||
// Find the "Write" button by text (supports both English and Chinese)
|
||||
const writeButton = this.page.getByRole('button', { name: /write|写作/i }).first();
|
||||
|
||||
await expect(writeButton).toBeVisible({ timeout: WAIT_TIMEOUT });
|
||||
await writeButton.click();
|
||||
|
||||
// Wait for mode switch animation and ChatInput scroll-into-view to settle
|
||||
await this.page.waitForTimeout(800);
|
||||
|
||||
console.log(' ✅ 已点击写作按钮');
|
||||
});
|
||||
|
||||
When('用户在输入框中输入 {string}', async function (this: CustomWorld, message: string) {
|
||||
console.log(` 📍 Step: 在输入框中输入 "${message}"...`);
|
||||
|
||||
// The chat input is a contenteditable editor, need to click first then type.
|
||||
// Target the contenteditable element INSIDE the ChatInput container directly,
|
||||
// since clicking the container might hit the action bar/footer area instead.
|
||||
const chatInputContainer = this.page.locator('[data-testid="chat-input"]').first();
|
||||
await expect(chatInputContainer).toBeVisible({ timeout: WAIT_TIMEOUT });
|
||||
|
||||
const editor = chatInputContainer.locator('[contenteditable="true"]').first();
|
||||
await editor.click();
|
||||
await this.page.waitForTimeout(300);
|
||||
await this.page.keyboard.type(message, { delay: 30 });
|
||||
|
||||
console.log(` ✅ 已输入 "${message}"`);
|
||||
});
|
||||
|
||||
When('用户按 Enter 发送', { timeout: 30_000 }, async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 按 Enter 发送...');
|
||||
|
||||
// Wait for editor's debounced onChange (100ms default) to sync inputMessage to store.
|
||||
// The send() function reads directly from the editor as a fallback, but this wait
|
||||
// ensures maximum reliability.
|
||||
await this.page.waitForTimeout(200);
|
||||
|
||||
// Listen for navigation to capture the agent/group ID
|
||||
const navigationPromise = this.page.waitForURL(/\/(agent|group)\/.*\/profile/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await this.page.keyboard.press('Enter');
|
||||
|
||||
// Wait for navigation to profile page
|
||||
await navigationPromise;
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 15_000 });
|
||||
|
||||
// Extract agent/group ID from URL
|
||||
const currentUrl = this.page.url();
|
||||
|
||||
const agentMatch = currentUrl.match(/\/agent\/([^/]+)/);
|
||||
if (agentMatch) {
|
||||
createdAgentId = agentMatch[1];
|
||||
console.log(` 📍 Created agent ID: ${createdAgentId}`);
|
||||
}
|
||||
|
||||
const groupMatch = currentUrl.match(/\/group\/([^/]+)/);
|
||||
if (groupMatch) {
|
||||
createdGroupId = groupMatch[1];
|
||||
console.log(` 📍 Created group ID: ${createdGroupId}`);
|
||||
}
|
||||
|
||||
console.log(' ✅ 已发送消息');
|
||||
});
|
||||
|
||||
When('用户按 Enter 发送创建文档', { timeout: 30_000 }, async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 按 Enter 发送创建文档...');
|
||||
|
||||
// Wait for editor's debounced onChange (100ms default) to sync inputMessage to store
|
||||
await this.page.waitForTimeout(200);
|
||||
|
||||
// Listen for navigation to capture the document ID
|
||||
const navigationPromise = this.page.waitForURL(/\/page\/[^/]+/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await this.page.keyboard.press('Enter');
|
||||
|
||||
// Wait for navigation to page
|
||||
await navigationPromise;
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 15_000 });
|
||||
|
||||
// Extract document ID from URL
|
||||
const currentUrl = this.page.url();
|
||||
const pageMatch = currentUrl.match(/\/page\/([^/?]+)/);
|
||||
if (pageMatch) {
|
||||
createdDocumentId = pageMatch[1];
|
||||
console.log(` 📍 Created document ID: ${createdDocumentId}`);
|
||||
}
|
||||
|
||||
console.log(' ✅ 已发送并创建文档');
|
||||
});
|
||||
|
||||
When('用户返回 Home 页面', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 返回 Home 页面...');
|
||||
|
||||
await this.page.goto('/');
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 15_000 });
|
||||
await this.page.waitForTimeout(1000);
|
||||
|
||||
console.log(' ✅ 已返回 Home 页面');
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Then Steps
|
||||
// ============================================
|
||||
|
||||
Then('页面应该跳转到 Agent 的 profile 页面', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 验证页面跳转到 Agent profile 页面...');
|
||||
|
||||
// Check current URL matches /agent/{id}/profile pattern
|
||||
const currentUrl = this.page.url();
|
||||
expect(currentUrl).toMatch(/\/agent\/[^/]+\/profile/);
|
||||
|
||||
console.log(' ✅ 已跳转到 Agent profile 页面');
|
||||
});
|
||||
|
||||
Then('页面应该跳转到 Group 的 profile 页面', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 验证页面跳转到 Group profile 页面...');
|
||||
|
||||
// Check current URL matches /group/{id}/profile pattern
|
||||
const currentUrl = this.page.url();
|
||||
expect(currentUrl).toMatch(/\/group\/[^/]+\/profile/);
|
||||
|
||||
console.log(' ✅ 已跳转到 Group profile 页面');
|
||||
});
|
||||
|
||||
Then('新创建的 Agent 应该在侧边栏中显示', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 验证 Agent 在侧边栏中显示...');
|
||||
|
||||
// Wait for sidebar to be visible and data to load
|
||||
await this.page.waitForTimeout(1500);
|
||||
|
||||
// Check if the agent appears in sidebar by its link (primary assertion)
|
||||
// This proves that refreshAgentList() was called and the sidebar was updated
|
||||
if (!createdAgentId) {
|
||||
throw new Error('Agent ID was not captured during creation');
|
||||
}
|
||||
|
||||
const agentLink = this.page.locator(`a[href="/agent/${createdAgentId}"]`).first();
|
||||
await expect(agentLink).toBeVisible({ timeout: WAIT_TIMEOUT });
|
||||
console.log(` ✅ 找到 Agent 链接: /agent/${createdAgentId}`);
|
||||
|
||||
// Get the aria-label or text content to verify it's the correct agent
|
||||
const ariaLabel = await agentLink.getAttribute('aria-label');
|
||||
console.log(` 📍 Agent aria-label: ${ariaLabel}`);
|
||||
|
||||
console.log(' ✅ Agent 已在侧边栏中显示');
|
||||
});
|
||||
|
||||
Then('新创建的 Group 应该在侧边栏中显示', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 验证 Group 在侧边栏中显示...');
|
||||
|
||||
// Wait for sidebar to be visible and data to load
|
||||
await this.page.waitForTimeout(1500);
|
||||
|
||||
// Check if the group appears in sidebar by its link (primary assertion)
|
||||
// This proves that refreshAgentList() was called and the sidebar was updated
|
||||
if (!createdGroupId) {
|
||||
throw new Error('Group ID was not captured during creation');
|
||||
}
|
||||
|
||||
const groupLink = this.page.locator(`a[href="/group/${createdGroupId}"]`).first();
|
||||
await expect(groupLink).toBeVisible({ timeout: WAIT_TIMEOUT });
|
||||
console.log(` ✅ 找到 Group 链接: /group/${createdGroupId}`);
|
||||
|
||||
// Get the aria-label or text content to verify it's the correct group
|
||||
const ariaLabel = await groupLink.getAttribute('aria-label');
|
||||
console.log(` 📍 Group aria-label: ${ariaLabel}`);
|
||||
|
||||
console.log(' ✅ Group 已在侧边栏中显示');
|
||||
});
|
||||
|
||||
Then('页面应该跳转到文档编辑页面', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 验证页面跳转到文档编辑页面...');
|
||||
|
||||
// Check current URL matches /page/{id} pattern
|
||||
const currentUrl = this.page.url();
|
||||
expect(currentUrl).toMatch(/\/page\/[^/?]+/);
|
||||
|
||||
if (!createdDocumentId) {
|
||||
throw new Error('Document ID was not captured during creation');
|
||||
}
|
||||
|
||||
console.log(` ✅ 已跳转到文档编辑页面: /page/${createdDocumentId}`);
|
||||
});
|
||||
|
||||
Then('Page Agent 应该收到用户的提示词', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 验证 Page Agent 收到用户的提示词...');
|
||||
|
||||
// Wait for the page to fully load and Page Agent panel to appear
|
||||
await this.page.waitForTimeout(2000);
|
||||
|
||||
// Look for the user message in the chat panel (Page Agent Copilot)
|
||||
// The message should appear in the chat list
|
||||
const userMessage = this.page.locator('text=帮我写一篇关于人工智能的文章').first();
|
||||
|
||||
// The message might be in the chat panel on the right side
|
||||
const messageVisible = await userMessage.isVisible().catch(() => false);
|
||||
|
||||
if (messageVisible) {
|
||||
console.log(' ✅ 找到用户发送的提示词');
|
||||
} else {
|
||||
// Alternative: check if there's any chat content indicating the message was sent
|
||||
console.log(' ⚠️ 用户消息可能在聊天面板中,但未直接可见');
|
||||
}
|
||||
|
||||
// Verify that the Page Agent responded (mock response should appear)
|
||||
// Wait a bit longer for the mock LLM response
|
||||
await this.page.waitForTimeout(3000);
|
||||
|
||||
// Look for AI response content
|
||||
const aiResponse = this.page.locator('text=人工智能').first();
|
||||
const responseVisible = await aiResponse.isVisible().catch(() => false);
|
||||
|
||||
if (responseVisible) {
|
||||
console.log(' ✅ Page Agent 已响应用户的提示词');
|
||||
} else {
|
||||
console.log(' ⚠️ Page Agent 响应可能正在生成或在其他位置');
|
||||
}
|
||||
|
||||
console.log(' ✅ Page Agent 验证完成');
|
||||
});
|
||||
@@ -29,7 +29,7 @@ async function waitForPageWorkspaceReady(world: CustomWorld): Promise<void> {
|
||||
}
|
||||
|
||||
const readyCandidates = [
|
||||
world.page.locator('button:has(svg.lucide-square-pen)').first(),
|
||||
world.page.locator(':is(button, [role="button"]):has(svg.lucide-square-pen)').first(),
|
||||
world.page.locator('input[placeholder*="Search"], input[placeholder*="搜索"]').first(),
|
||||
world.page.locator('a[href^="/page/"]').first(),
|
||||
];
|
||||
@@ -50,7 +50,7 @@ async function clickNewPageButton(world: CustomWorld): Promise<void> {
|
||||
await waitForPageWorkspaceReady(world);
|
||||
|
||||
const candidates = [
|
||||
world.page.locator('button:has(svg.lucide-square-pen)').first(),
|
||||
world.page.locator(':is(button, [role="button"]):has(svg.lucide-square-pen)').first(),
|
||||
world.page
|
||||
.locator('svg.lucide-square-pen')
|
||||
.first()
|
||||
|
||||
@@ -114,9 +114,11 @@ async function waitForPageWorkspaceReady(world: CustomWorld): Promise<void> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Any of these means the page workspace is ready for interactions
|
||||
// Any of these means the page workspace is ready for interactions.
|
||||
// The new-page button is rendered by `@lobehub/ui` ActionIcon as a
|
||||
// `<div role="button">` rather than a native `<button>`, so match either.
|
||||
const readyCandidates = [
|
||||
world.page.locator('button:has(svg.lucide-square-pen)').first(),
|
||||
world.page.locator(':is(button, [role="button"]):has(svg.lucide-square-pen)').first(),
|
||||
world.page.locator('input[placeholder*="Search"], input[placeholder*="搜索"]').first(),
|
||||
world.page.locator('a[href^="/page/"]').first(),
|
||||
];
|
||||
@@ -137,7 +139,7 @@ async function clickNewPageButton(world: CustomWorld): Promise<void> {
|
||||
await waitForPageWorkspaceReady(world);
|
||||
|
||||
const candidates = [
|
||||
world.page.locator('button:has(svg.lucide-square-pen)').first(),
|
||||
world.page.locator(':is(button, [role="button"]):has(svg.lucide-square-pen)').first(),
|
||||
world.page
|
||||
.locator('svg.lucide-square-pen')
|
||||
.first()
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
// Test user credentials - these are used for e2e testing only
|
||||
@@ -92,6 +94,39 @@ export async function seedTestUser(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTestSession(): Promise<string | null> {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
|
||||
if (!databaseUrl) {
|
||||
console.log('⚠️ DATABASE_URL not set, cannot create test session');
|
||||
return null;
|
||||
}
|
||||
|
||||
await seedTestUser();
|
||||
|
||||
const { default: pg } = await import('pg');
|
||||
const client = new pg.Client({ connectionString: databaseUrl });
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||
const sessionId = randomBytes(9).toString('base64url');
|
||||
const sessionToken = randomBytes(24).toString('base64url');
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO auth_sessions (id, token, user_id, expires_at, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $5)`,
|
||||
[sessionId, sessionToken, TEST_USER.id, expiresAt.toISOString(), now.toISOString()],
|
||||
);
|
||||
|
||||
return sessionToken;
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up test user data after tests
|
||||
*/
|
||||
|
||||
+57
-2
@@ -1,4 +1,13 @@
|
||||
{
|
||||
"channel.allowFrom": "المستخدمون المسموح لهم",
|
||||
"channel.allowFromAdd": "إضافة مستخدم",
|
||||
"channel.allowFromEmpty": "لم تتم إضافة أي مستخدم بعد — يمكن لأي شخص التفاعل مع الروبوت.",
|
||||
"channel.allowFromHint": "فقط المستخدمون المدرجون يمكنهم التفاعل مع الروبوت؛ يتم تضمين 'معرّف المستخدم على المنصة' الخاص بك تلقائيًا.",
|
||||
"channel.allowFromIdLabel": "معرّف المستخدم",
|
||||
"channel.allowFromIdPlaceholder": "معرّف المستخدم على المنصة",
|
||||
"channel.allowFromNameLabel": "ملاحظة",
|
||||
"channel.allowFromNamePlaceholder": "مثال: آليس (تذكيرك)",
|
||||
"channel.allowListRemove": "إزالة",
|
||||
"channel.appSecret": "سر التطبيق",
|
||||
"channel.appSecretHint": "سر التطبيق لتطبيق الروبوت الخاص بك. سيتم تشفيره وتخزينه بأمان.",
|
||||
"channel.appSecretPlaceholder": "الصق سر التطبيق هنا",
|
||||
@@ -14,8 +23,10 @@
|
||||
"channel.charLimitHint": "الحد الأقصى لعدد الأحرف لكل رسالة",
|
||||
"channel.concurrency": "وضع التزامن",
|
||||
"channel.concurrencyDebounce": "إزالة الارتداد",
|
||||
"channel.concurrencyDebounceHint": "معالجة آخر رسالة فقط في الدفعة (يتم تجاهل الرسائل السابقة)",
|
||||
"channel.concurrencyHint": "يقوم الوضع التتابعي بمعالجة الرسائل واحدة تلو الأخرى؛ بينما ينتظر وضع إزالة الارتداد انتهاء دفعة الرسائل قبل المعالجة",
|
||||
"channel.concurrencyQueue": "قائمة الانتظار",
|
||||
"channel.concurrencyQueueHint": "معالجة الرسائل واحدة تلو الأخرى",
|
||||
"channel.connectFailed": "فشل اتصال الروبوت",
|
||||
"channel.connectQueued": "تم وضع اتصال الروبوت في قائمة الانتظار. سيبدأ قريبًا.",
|
||||
"channel.connectStarting": "الروبوت قيد التشغيل. يرجى الانتظار لحظة.",
|
||||
@@ -25,7 +36,9 @@
|
||||
"channel.connectionMode": "وضع الاتصال",
|
||||
"channel.connectionModeHint": "يُفضَّل استخدام WebSocket للروبوتات الجديدة. استخدم Webhook إذا كان روبوتك يحتوي بالفعل على عنوان URL مُعدّ لرد النداء على منصة QQ المفتوحة.",
|
||||
"channel.connectionModeWebSocket": "WebSocket",
|
||||
"channel.connectionModeWebSocketHint": "موصى به للروبوتات الجديدة",
|
||||
"channel.connectionModeWebhook": "Webhook",
|
||||
"channel.connectionModeWebhookHint": "استخدمه إذا كان لدى روبوتك عنوان URL لردّ النداء مُعدّ",
|
||||
"channel.copied": "تم النسخ إلى الحافظة",
|
||||
"channel.copy": "نسخ",
|
||||
"channel.credentials": "بيانات الاعتماد",
|
||||
@@ -45,13 +58,16 @@
|
||||
"channel.displayToolCalls": "عرض استدعاءات الأدوات",
|
||||
"channel.displayToolCallsHint": "عرض تفاصيل استدعاء الأدوات أثناء استجابات الذكاء الاصطناعي. عند التعطيل، يتم عرض الاستجابة النهائية فقط لتجربة أكثر نظافة.",
|
||||
"channel.dm": "الرسائل المباشرة",
|
||||
"channel.dmEnabled": "تمكين الرسائل المباشرة",
|
||||
"channel.dmEnabledHint": "السماح للروبوت بتلقي الرسائل المباشرة والرد عليها",
|
||||
"channel.dmPolicy": "سياسة الرسائل المباشرة",
|
||||
"channel.dmPolicyAllowlist": "القائمة المسموح بها",
|
||||
"channel.dmPolicyAllowlistHint": "فقط المستخدمون المدرجون يمكنهم إرسال رسائل خاصة إلى الروبوت",
|
||||
"channel.dmPolicyDisabled": "معطل",
|
||||
"channel.dmPolicyDisabledHint": "رفض جميع الرسائل الخاصة",
|
||||
"channel.dmPolicyHint": "التحكم في من يمكنه إرسال الرسائل المباشرة إلى الروبوت",
|
||||
"channel.dmPolicyOpen": "مفتوح",
|
||||
"channel.dmPolicyOpenHint": "قبول الرسائل الخاصة من أي شخص",
|
||||
"channel.dmPolicyPairing": "الاقتران",
|
||||
"channel.dmPolicyPairingHint": "يحتاج الغرباء إلى استخدام /approve لإرسال رسالة خاصة",
|
||||
"channel.documentation": "التوثيق",
|
||||
"channel.enabled": "مفعّل",
|
||||
"channel.encryptKey": "مفتاح التشفير",
|
||||
@@ -63,6 +79,22 @@
|
||||
"channel.feishu.description": "قم بتوصيل هذا المساعد بـ Feishu للدردشة الخاصة والجماعية.",
|
||||
"channel.feishu.webhookMigrationDesc": "يوفّر وضع WebSocket تسليمًا فوريًا للأحداث دون الحاجة إلى عنوان URL عام لرد النداء. للانتقال، قم بتغيير وضع الاتصال إلى WebSocket في الإعدادات المتقدمة. لا يلزم أي إعداد إضافي على منصة Feishu/Lark المفتوحة.",
|
||||
"channel.feishu.webhookMigrationTitle": "النظر في الترقية إلى وضع WebSocket",
|
||||
"channel.groupAllowFrom": "القنوات المسموح بها",
|
||||
"channel.groupAllowFromAdd": "إضافة قناة",
|
||||
"channel.groupAllowFromEmpty": "لم تتم إضافة أي قنوات بعد — لن يرد الروبوت في أي مكان.",
|
||||
"channel.groupAllowFromHint": "معرّفات القنوات / المجموعات / الدردشات التي يمكن للروبوت الرد فيها.",
|
||||
"channel.groupAllowFromIdLabel": "معرّف القناة",
|
||||
"channel.groupAllowFromIdPlaceholder": "معرّف القناة / المجموعة / الدردشة",
|
||||
"channel.groupAllowFromNameLabel": "ملاحظة",
|
||||
"channel.groupAllowFromNamePlaceholder": "مثال: #general (تذكيرك)",
|
||||
"channel.groupPolicy": "سياسة المجموعات",
|
||||
"channel.groupPolicyAllowlist": "قائمة السماح",
|
||||
"channel.groupPolicyAllowlistHint": "الرد فقط في القنوات المدرجة",
|
||||
"channel.groupPolicyDisabled": "معطّل",
|
||||
"channel.groupPolicyDisabledHint": "تجاهل جميع رسائل المجموعات",
|
||||
"channel.groupPolicyHint": "أماكن رد الروبوت في المجموعات والقنوات والمواضيع",
|
||||
"channel.groupPolicyOpen": "مفتوح",
|
||||
"channel.groupPolicyOpenHint": "الرد في أي مجموعة أو قناة أو موضوع",
|
||||
"channel.historyLimit": "حد رسائل السجل",
|
||||
"channel.historyLimitHint": "العدد الافتراضي للرسائل التي يتم جلبها عند قراءة سجل القناة",
|
||||
"channel.importConfig": "استيراد التكوين",
|
||||
@@ -70,6 +102,19 @@
|
||||
"channel.importInvalidFormat": "تنسيق ملف التكوين غير صالح",
|
||||
"channel.importSuccess": "تم استيراد التكوين بنجاح",
|
||||
"channel.lark.description": "قم بتوصيل هذا المساعد بـ Lark للدردشة الخاصة والجماعية.",
|
||||
"channel.line.channelAccessToken": "رمز الوصول للقناة",
|
||||
"channel.line.channelAccessTokenHint": "رمز طويل الأمد يتم إصداره ضمن علامة تبويب واجهة برمجة تطبيقات المراسلة. سيتم تشفير الرمز وتخزينه بأمان.",
|
||||
"channel.line.channelSecret": "سر القناة",
|
||||
"channel.line.channelSecretHint": "من علامة تبويب الإعدادات الأساسية. مطلوب - يُستخدم للتحقق من توقيع X-Line-Signature على كل طلب ويب وارد.",
|
||||
"channel.line.description": "قم بتوصيل هذا المساعد بواجهة برمجة تطبيقات المراسلة الخاصة بـ LINE للمحادثات المباشرة والجماعية.",
|
||||
"channel.line.destinationUserId": "معرّف المستخدم الوجهة",
|
||||
"channel.line.destinationUserIdHint": "معرّف المستخدم الوجهة الخاص بالروبوت (يبدأ بـ `U`، إجمالي 33 حرفًا). لا يعرض وحدة تحكم مطوري LINE هذه القيمة. قم بإصدار رمز الوصول للقناة أدناه أولاً، ثم انقر على \"Fetch from LINE\" لملء هذا الحقل تلقائيًا. ملاحظة: \"معرّف المستخدم الخاص بك\" في الإعدادات الأساسية هو معرّف المستخدم الشخصي الخاص بك في LINE، وليس معرّف الروبوت.",
|
||||
"channel.line.destinationUserIdPlaceholder": "مثال: U1234567890abcdef1234567890abcdef",
|
||||
"channel.line.fetchBotInfo": "جلب من LINE",
|
||||
"channel.line.fetchBotInfoFailed": "فشل في جلب معلومات الروبوت",
|
||||
"channel.line.fetchBotInfoMissingToken": "أدخل رمز الوصول للقناة أولاً، ثم انقر على \"Fetch from LINE\".",
|
||||
"channel.line.fetchBotInfoSuccess": "تم جلب معرّف المستخدم الوجهة",
|
||||
"channel.line.webhookManualSetup": "لا يسمح LINE بالتسجيل البرمجي للويب هوك. انسخ هذا الرابط إلى وحدة تحكم مطوري LINE (واجهة برمجة تطبيقات المراسلة → رابط الويب هوك)، انقر على \"تحقق\"، وقم بتمكين \"استخدام الويب هوك\".",
|
||||
"channel.openPlatform": "منصة مفتوحة",
|
||||
"channel.platforms": "المنصات",
|
||||
"channel.publicKey": "المفتاح العام",
|
||||
@@ -93,6 +138,8 @@
|
||||
"channel.secretTokenPlaceholder": "السر الاختياري للتحقق من الويب هوك",
|
||||
"channel.serverId": "معرف الخادم / النقابة الافتراضي",
|
||||
"channel.serverIdHint": "معرف الخادم أو النقابة الافتراضي الخاص بك على هذه المنصة. يستخدمه الذكاء الاصطناعي لإدراج القنوات دون الحاجة للسؤال.",
|
||||
"channel.serverIdHint.discord": "فعّل وضع المطوّر (الإعدادات → متقدم)، ثم انقر بزر الفأرة الأيمن على أيقونة الخادم → انسخ معرّف الخادم.",
|
||||
"channel.serverIdHint.slack": "معرّف مساحة العمل (يبدأ بـ T). اعثر عليه تحت الإعدادات والإدارة → إعدادات مساحة العمل، أو في رابط مساحة العمل.",
|
||||
"channel.settings": "الإعدادات المتقدمة",
|
||||
"channel.settingsResetConfirm": "هل أنت متأكد أنك تريد إعادة تعيين الإعدادات المتقدمة إلى الوضع الافتراضي؟",
|
||||
"channel.settingsResetDefault": "إعادة إلى الوضع الافتراضي",
|
||||
@@ -120,6 +167,14 @@
|
||||
"channel.updateFailed": "فشل في تحديث الحالة",
|
||||
"channel.userId": "معرف المستخدم الخاص بك على المنصة",
|
||||
"channel.userIdHint": "معرف المستخدم الخاص بك على هذه المنصة. يمكن للذكاء الاصطناعي استخدامه لإرسال رسائل مباشرة إليك.",
|
||||
"channel.userIdHint.discord": "فعّل وضع المطوّر (الإعدادات → متقدم)، ثم انقر بزر الفأرة الأيمن على صورتك الشخصية → انسخ معرّف المستخدم.",
|
||||
"channel.userIdHint.feishu": "افتح تطبيقك على منصة Feishu / Lark Open Platform → الأذونات، ثم ابحث عن المعرّف المفتوح الخاص بك.",
|
||||
"channel.userIdHint.line": "افتح وحدة تحكم مطوري LINE → قناتك → علامة تبويب الإعدادات الأساسية، ونسخ \"معرّف المستخدم الخاص بك\" (يبدأ بحرف U، 33 حرفًا).",
|
||||
"channel.userIdHint.qq": "رقم QQ الخاص بك، يظهر في صفحة ملفك الشخصي.",
|
||||
"channel.userIdHint.slack": "افتح ملفك الشخصي في Slack → ⋮ المزيد → انسخ معرّف العضو (يبدأ بـ U).",
|
||||
"channel.userIdHint.telegram": "أرسل أي رسالة إلى @userinfobot على تيليغرام — سيرد عليك بمعرّف المستخدم الرقمي الخاص بك.",
|
||||
"channel.userIdMissingDesc": "بدونه، لا يمكن لأدوات الذكاء الاصطناعي الوصول إليك عبر التذكيرات، كما سيفشل اعتماد الاقتران. قم بإدخاله في الإعدادات المتقدمة.",
|
||||
"channel.userIdMissingTitle": "أضف معرّف المستخدم على منصتك",
|
||||
"channel.validationError": "يرجى ملء معرف التطبيق والرمز",
|
||||
"channel.verificationToken": "رمز التحقق",
|
||||
"channel.verificationTokenHint": "اختياري. يُستخدم للتحقق من مصدر أحداث الويب هوك.",
|
||||
|
||||
@@ -33,6 +33,10 @@
|
||||
"authModal.signIn": "تسجيل الدخول مرة أخرى",
|
||||
"authModal.signingIn": "جارٍ تسجيل الدخول...",
|
||||
"authModal.title": "انتهت الجلسة",
|
||||
"betterAuth.captcha.continue": "استمر",
|
||||
"betterAuth.captcha.description": "أكمل التحقق الأمني أدناه. سنواصل عملية التسجيل أو تسجيل الدخول تلقائيًا.",
|
||||
"betterAuth.captcha.pendingDescription": "لم يكتمل التحقق. يرجى محاولة التحدي مرة أخرى.",
|
||||
"betterAuth.captcha.title": "مطلوب التحقق الأمني",
|
||||
"betterAuth.errors.confirmPasswordRequired": "يرجى تأكيد كلمة المرور",
|
||||
"betterAuth.errors.emailExists": "هذا البريد الإلكتروني مسجل بالفعل. يرجى تسجيل الدخول بدلاً من ذلك",
|
||||
"betterAuth.errors.emailInvalid": "يرجى إدخال بريد إلكتروني أو اسم مستخدم صالح",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user