mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-15 12:10:16 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d38d59e8e | |||
| 41c71655b6 |
@@ -1,8 +1,6 @@
|
||||
---
|
||||
name: add-provider-doc
|
||||
description: Guide for adding new AI provider documentation. Use when adding documentation for a new AI provider (like OpenAI, Anthropic, etc.), including usage docs, environment variables, Docker config, and image resources. Triggers on provider documentation tasks.
|
||||
disable-model-invocation: true
|
||||
argument-hint: '[provider-name]'
|
||||
---
|
||||
|
||||
# Adding New AI Provider Documentation
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
---
|
||||
name: add-setting-env
|
||||
description: Guide for adding environment variables to configure user settings. Use when implementing server-side environment variables that control default values for user settings. Triggers on env var configuration or setting default value tasks.
|
||||
disable-model-invocation: true
|
||||
argument-hint: '[setting-name]'
|
||||
---
|
||||
|
||||
# Adding Environment Variable for User Settings
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
---
|
||||
name: bot
|
||||
description: 'Bot platform architecture (Discord, Slack, Telegram, Feishu/Lark, QQ, WeChat). Use when working on inbound webhooks, Chat SDK message routing, agent execution from chat platforms, queue-mode callbacks, gateway lifecycle (websocket/polling), bot provider CRUD/credentials, or platform-specific clients/adapters/schemas. Triggers on bot, channel, webhook, mention, Chat SDK, agent bot provider, gateway, bot-callback, qstash bot.'
|
||||
---
|
||||
|
||||
# Bot System
|
||||
|
||||
> **Last updated: 2026-04-08.** Implementation evolves quickly — this doc is a map, not the source of truth. Always read the key files below to verify behavior, especially per-platform quirks. Update this doc when the architecture changes.
|
||||
|
||||
LobeChat agents can answer inside external chat platforms. Inbound messages flow through the Chat SDK (`chat` npm package), get routed to the right agent by `(platform, applicationId)`, executed via `AiAgentService`, and replied back through a per-platform `PlatformClient`. There are **two execution modes** (in-memory vs queue/QStash) and **three connection modes** (`webhook`, `websocket`, `polling`).
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
| Platform | id | Default mode | Markdown | Edit | Notes |
|
||||
| -------- | ---------- | ------------------------------- | ----------------- | ------ | -------------------------------------------------------------------------------------- |
|
||||
| Discord | `discord` | `websocket` | yes | yes | Persistent gateway via Chat SDK adapter; reaction-thread quirks; native slash commands |
|
||||
| Slack | `slack` | `websocket` (Socket Mode) | yes (mrkdwn) | yes | Multi-mode — user can pick `webhook` per provider |
|
||||
| Telegram | `telegram` | `webhook` | yes (HTML) | yes | `setMyCommands` menu via `registerBotCommands` |
|
||||
| Feishu | `feishu` | `websocket` (Lark SDK WSClient) | **no** (stripped) | yes | Multi-mode; shared client with Lark |
|
||||
| Lark | `lark` | `websocket` | **no** | yes | Same client/schema as Feishu, different domain |
|
||||
| QQ | `qq` | `websocket` | **no** | **no** | All replies are final-only |
|
||||
| WeChat | `wechat` | `polling` (iLink long-poll) | **no** | **no** | 10-minute gateway window |
|
||||
|
||||
`supportsMarkdown=false` ⇒ outbound markdown is stripped to plain text via `stripMarkdown` and the AI is told not to use markdown. `supportsMessageEdit=false` ⇒ no progress edits — only the final reply is sent.
|
||||
|
||||
**Multi-mode connection** — Slack/Feishu/Lark/QQ 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.
|
||||
|
||||
`POST /api/agent/webhooks/bot-callback` (`src/server/agent-hono/handlers/botCallback.ts`) verifies the QStash signature via the `qstashAuth` middleware 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`** (`src/server/agent-hono/handlers/gatewayCron.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`** (`src/server/agent-hono/handlers/gatewayStart.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 (mounted via `src/app/(backend)/api/agent/[[...route]]/route.ts` → `src/server/agent-hono`):
|
||||
src/server/agent-hono/handlers/platformWebhook.ts — inbound catch-all (POST /webhooks/:platform/:appId?)
|
||||
src/server/agent-hono/handlers/botCallback.ts — qstash bot callback
|
||||
src/server/agent-hono/handlers/gatewayCron.ts — cron gateway (10min window)
|
||||
src/server/agent-hono/handlers/gatewayStart.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.
|
||||
@@ -19,11 +19,11 @@ A builtin tool is a package the agent runtime can call. It ships **five faces**:
|
||||
|
||||
## Read These First
|
||||
|
||||
| Question | Doc |
|
||||
| ------------------------------------------------------------------------------------ | --------------------------------------------- |
|
||||
| Where do files live? What does each face do? Wiring? | [architecture.md](references/architecture.md) |
|
||||
| How do I name the tool, design APIs, write the manifest, executor, ExecutionRuntime? | [tool-design.md](references/tool-design.md) |
|
||||
| How do I build Inspector / Render / Placeholder / Streaming / Intervention / Portal? | [ui.md](references/ui.md) |
|
||||
| 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) |
|
||||
|
||||
---
|
||||
|
||||
@@ -109,7 +109,7 @@ Before opening the PR:
|
||||
- [ ] 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](references/architecture.md#registry-wiring)).
|
||||
- [ ] 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.
|
||||
|
||||
+1
-1
@@ -213,7 +213,7 @@ The runtime hands every executor method an optional `BuiltinToolContext` as the
|
||||
| `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 (lobe-agent todos, etc.) |
|
||||
| `stepContext` | Cross-message runtime state (GTD todos, etc.) |
|
||||
| `registerAfterCompletion(cb)` | Defer side-effects past message-update race |
|
||||
| `groupOrchestration` | Group orchestration callbacks |
|
||||
|
||||
@@ -8,7 +8,6 @@ description: >
|
||||
(4) Send interactive cards or stream AI responses to chat platforms.
|
||||
Triggers on "chat sdk", "chat bot", "slack bot", "teams bot", "discord bot", "@chat-adapter",
|
||||
building bots that work across multiple chat platforms.
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# Chat SDK
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,244 +0,0 @@
|
||||
# Walkthrough: Adding a New Feature End-to-End
|
||||
|
||||
This is a worked example of the canonical 6-step recipe applied to a new entity (`Dataset`), showing a variant of the main skill's pattern: **a list keyed by a parent id** (`datasetMap[benchmarkId]`), useful when the same shape appears under different parents.
|
||||
|
||||
If you only need the canonical (single-array) pattern, the main `SKILL.md` already shows it for `Benchmark`. Read this file when you need the parent-keyed Map variant, or when you want a checklist-style walkthrough.
|
||||
|
||||
## Step 1: Add Service methods
|
||||
|
||||
```typescript
|
||||
class AgentEvalService {
|
||||
async listDatasets(benchmarkId: string) {
|
||||
return lambdaClient.agentEval.listDatasets.query({ benchmarkId });
|
||||
}
|
||||
async getDataset(id: string) {
|
||||
return lambdaClient.agentEval.getDataset.query({ id });
|
||||
}
|
||||
async createDataset(params: CreateDatasetParams) {
|
||||
return lambdaClient.agentEval.createDataset.mutate(params);
|
||||
}
|
||||
// updateDataset / deleteDataset follow the same shape
|
||||
}
|
||||
```
|
||||
|
||||
## Step 2: Reducer (optimistic updates)
|
||||
|
||||
```typescript
|
||||
// src/store/eval/slices/dataset/reducer.ts
|
||||
export type DatasetDispatch =
|
||||
| { type: 'addDataset'; value: Dataset }
|
||||
| { type: 'updateDataset'; id: string; value: Partial<Dataset> }
|
||||
| { type: 'deleteDataset'; id: string };
|
||||
|
||||
export const datasetReducer = (state: Dataset[] = [], payload: DatasetDispatch): Dataset[] =>
|
||||
produce(state, (draft) => {
|
||||
switch (payload.type) {
|
||||
case 'addDataset':
|
||||
draft.unshift(payload.value);
|
||||
break;
|
||||
case 'updateDataset': {
|
||||
const i = draft.findIndex((item) => item.id === payload.id);
|
||||
if (i !== -1) draft[i] = { ...draft[i], ...payload.value };
|
||||
break;
|
||||
}
|
||||
case 'deleteDataset': {
|
||||
const i = draft.findIndex((item) => item.id === payload.id);
|
||||
if (i !== -1) draft.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Step 3: Store slice
|
||||
|
||||
```typescript
|
||||
// src/store/eval/slices/dataset/initialState.ts
|
||||
export interface DatasetData {
|
||||
currentPage: number;
|
||||
hasMore: boolean;
|
||||
isLoading: boolean;
|
||||
items: Dataset[];
|
||||
pageSize: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface DatasetSliceState {
|
||||
// Map keyed by benchmarkId — multiple parent contexts share the slice
|
||||
datasetMap: Record<string, DatasetData>;
|
||||
// Single item for modal display
|
||||
datasetDetail: Dataset | null;
|
||||
isLoadingDatasetDetail: boolean;
|
||||
loadingDatasetIds: string[];
|
||||
}
|
||||
|
||||
export const datasetInitialState: DatasetSliceState = {
|
||||
datasetMap: {},
|
||||
datasetDetail: null,
|
||||
isLoadingDatasetDetail: false,
|
||||
loadingDatasetIds: [],
|
||||
};
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/store/eval/slices/dataset/action.ts
|
||||
const FETCH_DATASETS_KEY = 'FETCH_DATASETS';
|
||||
const FETCH_DATASET_DETAIL_KEY = 'FETCH_DATASET_DETAIL';
|
||||
|
||||
export const createDatasetSlice: StateCreator<EvalStore, any, [], DatasetAction> = (set, get) => ({
|
||||
// Cache key includes benchmarkId so each parent has its own SWR entry
|
||||
useFetchDatasets: (benchmarkId) =>
|
||||
useClientDataSWR(
|
||||
benchmarkId ? [FETCH_DATASETS_KEY, benchmarkId] : null,
|
||||
() => agentEvalService.listDatasets(benchmarkId!),
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
set({
|
||||
datasetMap: {
|
||||
...get().datasetMap,
|
||||
[benchmarkId!]: {
|
||||
currentPage: 1,
|
||||
hasMore: false,
|
||||
isLoading: false,
|
||||
items: data,
|
||||
pageSize: data.length,
|
||||
total: data.length,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
),
|
||||
|
||||
useFetchDatasetDetail: (id) =>
|
||||
useClientDataSWR(
|
||||
id ? [FETCH_DATASET_DETAIL_KEY, id] : null,
|
||||
() => agentEvalService.getDataset(id!),
|
||||
{
|
||||
onSuccess: (data) => set({ datasetDetail: data, isLoadingDatasetDetail: false }),
|
||||
},
|
||||
),
|
||||
|
||||
refreshDatasets: (benchmarkId) => mutate([FETCH_DATASETS_KEY, benchmarkId]),
|
||||
refreshDatasetDetail: (id) => mutate([FETCH_DATASET_DETAIL_KEY, id]),
|
||||
|
||||
// CREATE with optimistic update — note the temp id pattern
|
||||
createDataset: async (params) => {
|
||||
const tmpId = Date.now().toString();
|
||||
const { benchmarkId } = params;
|
||||
|
||||
get().internal_dispatchDataset(
|
||||
{ type: 'addDataset', value: { ...params, id: tmpId, createdAt: Date.now() } as any },
|
||||
benchmarkId,
|
||||
);
|
||||
get().internal_updateDatasetLoading(tmpId, true);
|
||||
|
||||
try {
|
||||
const result = await agentEvalService.createDataset(params);
|
||||
await get().refreshDatasets(benchmarkId);
|
||||
return result;
|
||||
} finally {
|
||||
get().internal_updateDatasetLoading(tmpId, false);
|
||||
}
|
||||
},
|
||||
|
||||
// UPDATE / DELETE follow the same optimistic + refresh pattern as BenchmarkSlice
|
||||
// (see the main SKILL.md)
|
||||
|
||||
// Internal — dispatch reducer scoped to a parent
|
||||
internal_dispatchDataset: (payload, benchmarkId) => {
|
||||
const currentData = get().datasetMap[benchmarkId];
|
||||
const nextItems = datasetReducer(currentData?.items, payload);
|
||||
|
||||
// Skip set when nothing changed — avoids unnecessary re-renders
|
||||
if (isEqual(nextItems, currentData?.items)) return;
|
||||
|
||||
set({
|
||||
datasetMap: {
|
||||
...get().datasetMap,
|
||||
[benchmarkId]: {
|
||||
...currentData,
|
||||
currentPage: currentData?.currentPage ?? 1,
|
||||
hasMore: currentData?.hasMore ?? false,
|
||||
isLoading: false,
|
||||
items: nextItems,
|
||||
pageSize: currentData?.pageSize ?? nextItems.length,
|
||||
total: currentData?.total ?? nextItems.length,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
internal_updateDatasetLoading: (id, loading) => {
|
||||
set((state) => ({
|
||||
loadingDatasetIds: loading
|
||||
? [...state.loadingDatasetIds, id]
|
||||
: state.loadingDatasetIds.filter((i) => i !== id),
|
||||
}));
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Step 4: Wire into the store
|
||||
|
||||
```typescript
|
||||
// src/store/eval/store.ts
|
||||
export type EvalStore = EvalStoreState & BenchmarkAction & DatasetAction & RunAction;
|
||||
|
||||
const createStore: StateCreator<EvalStore, [['zustand/devtools', never]]> = (set, get, store) => ({
|
||||
...initialState,
|
||||
...createBenchmarkSlice(set, get, store),
|
||||
...createDatasetSlice(set, get, store),
|
||||
...createRunSlice(set, get, store),
|
||||
});
|
||||
|
||||
// src/store/eval/initialState.ts
|
||||
export const initialState: EvalStoreState = {
|
||||
...benchmarkInitialState,
|
||||
...datasetInitialState,
|
||||
...runInitialState,
|
||||
};
|
||||
```
|
||||
|
||||
## Step 5: Selectors (optional but recommended)
|
||||
|
||||
```typescript
|
||||
export const datasetSelectors = {
|
||||
getDatasetData: (benchmarkId: string) => (s: EvalStore) => s.datasetMap[benchmarkId],
|
||||
getDatasets: (benchmarkId: string) => (s: EvalStore) => s.datasetMap[benchmarkId]?.items ?? [],
|
||||
isLoadingDataset: (id: string) => (s: EvalStore) => s.loadingDatasetIds.includes(id),
|
||||
};
|
||||
```
|
||||
|
||||
## Step 6: Use in component
|
||||
|
||||
```tsx
|
||||
// List scoped to a parent
|
||||
const DatasetList = ({ benchmarkId }: { benchmarkId: string }) => {
|
||||
const useFetchDatasets = useEvalStore((s) => s.useFetchDatasets);
|
||||
const datasets = useEvalStore(datasetSelectors.getDatasets(benchmarkId));
|
||||
const datasetData = useEvalStore(datasetSelectors.getDatasetData(benchmarkId));
|
||||
|
||||
useFetchDatasets(benchmarkId);
|
||||
|
||||
if (datasetData?.isLoading) return <Loading />;
|
||||
return (
|
||||
<div>
|
||||
<h2>Total: {datasetData?.total ?? 0}</h2>
|
||||
<List data={datasets} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Single item for modal — conditional fetching pattern
|
||||
const DatasetImportModal = ({ open, datasetId }: Props) => {
|
||||
const useFetchDatasetDetail = useEvalStore((s) => s.useFetchDatasetDetail);
|
||||
const dataset = useEvalStore((s) => s.datasetDetail);
|
||||
const isLoading = useEvalStore((s) => s.isLoadingDatasetDetail);
|
||||
|
||||
// Only fetch when modal is open AND id present
|
||||
useFetchDatasetDetail(open && datasetId ? datasetId : undefined);
|
||||
|
||||
return <Modal open={open}>{isLoading ? <Loading /> : <div>{dataset?.name}</div>}</Modal>;
|
||||
};
|
||||
```
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
name: db-migrations
|
||||
description: 'Use when generating or regenerating Drizzle migration files, changing database schema tables or columns, resolving migration sequence conflicts after rebase, reviewing migration SQL for idempotent patterns, or renaming migration files.'
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# Database Migrations Guide
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: debug-package
|
||||
description: "Guide for the `debug` npm package and LobeHub log namespaces (lobe-server:*, lobe-desktop:*, lobe-client:*, lobe-*-router:*). Use whenever adding a `debug(...)` logger, picking a namespace for new server/desktop/client/router code, troubleshooting why DEBUG=lobe-* logs don't show up, or when the user asks to 'add logging', 'add a logger', 'instrument this', 'trace this call', 'why isn't my log printing', or mentions `debug(`, `DEBUG=`, `localStorage.debug`, or log format specifiers like %O / %o / %s / %d in a LobeHub codebase."
|
||||
name: debug
|
||||
description: Debug package usage guide. Use when adding debug logging, understanding log namespaces, or implementing debugging features. Triggers on debug logging requests or logging implementation.
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
name: drizzle
|
||||
description: "Drizzle ORM schema authoring and query style for LobeHub (postgres, strict mode). Use when editing anything under `src/database/schemas/`, defining `pgTable` columns/indexes/junction tables, spreading `...timestamps`, generating `createInsertSchema`/`$inferSelect`/`$inferInsert` types, writing `db.select().from(...).leftJoin(...)` queries, or deciding when to split a relational `with:` into two queries. Triggers on `pgTable`, `db.select`, `db.query`, `eq()`/`and()`/`inArray()`, `uniqueIndex`, `primaryKey`, `references({ onDelete })`, 'add a column', 'new table', 'foreign key', 'junction table', 'schema field'. For migration files specifically, see the `db-migrations` skill."
|
||||
user-invocable: false
|
||||
description: Drizzle ORM schema and database guide. Use when working with database schemas (src/database/schemas/*), defining tables, creating migrations, or database model code. Triggers on Drizzle schema definition, database migrations, or ORM usage questions.
|
||||
---
|
||||
|
||||
# Drizzle ORM Schema Style Guide
|
||||
@@ -126,7 +125,11 @@ The relational API generates complex lateral joins with `json_build_array` that
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
const [result] = await this.db.select().from(agents).where(eq(agents.id, id)).limit(1);
|
||||
const [result] = await this.db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(eq(agents.id, id))
|
||||
.limit(1);
|
||||
return result;
|
||||
|
||||
// ❌ Bad: relational API
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
name: hotkey
|
||||
description: "Adding or editing keyboard shortcuts in LobeHub. Use when registering a new hotkey, changing a key combo, scoping a shortcut to chat vs global, or wiring a hotkey hook + tooltip. Covers the 5-step flow: add to `HotkeyEnum` in `src/types/hotkey.ts`, register in `HOTKEYS_REGISTRATION` (`src/const/hotkeys.ts`) with `combineKeys([Key.Mod, …])`, add i18n in `src/locales/default/hotkey.ts`, expose via `useHotkeyById` in `src/hooks/useHotkeys/`, and render `<Tooltip hotkey={…}>`. Triggers on `HotkeyEnum`, `HOTKEYS_REGISTRATION`, `useHotkeyById`, `combineKeys`, `Key.Mod`/`Key.Shift`, 'add a hotkey', 'add a shortcut', '加快捷键', '快捷键', 'Cmd+K', 'keyboard shortcut', 'hotkey scope', 'hotkey conflict'."
|
||||
user-invocable: false
|
||||
description: Guide for adding keyboard shortcuts. Use when implementing new hotkeys, registering shortcuts, or working with keyboard interactions. Triggers on hotkey implementation or keyboard shortcut tasks.
|
||||
---
|
||||
|
||||
# Adding Keyboard Shortcuts Guide
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
name: i18n
|
||||
description: "LobeHub internationalization with react-i18next. Use when adding any user-facing string in `.tsx`/`.ts` files, creating or renaming a key under `src/locales/default/{namespace}.ts`, deciding the `{feature}.{context}.{action}` flat-key pattern, wiring a new namespace into `src/locales/default/index.ts`, or translating zh-CN/en-US JSON for dev preview. Triggers on `useTranslation`, `t('foo.bar')`, `i18next.t`, `{{variable}}` interpolation, hardcoded UI strings (zh or en) that should be extracted, 'add i18n', '加 i18n key', '翻译', 'locale key', 'namespace', 'pnpm i18n'."
|
||||
user-invocable: false
|
||||
description: Internationalization guide using react-i18next. Use when adding translations, creating i18n keys, or working with localized text in React components (.tsx files). Triggers on translation tasks, locale management, or i18n implementation.
|
||||
---
|
||||
|
||||
# LobeHub Internationalization Guide
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
---
|
||||
name: linear
|
||||
description: "Linear issue management. Use when the user mentions LOBE-xxx issue IDs (e.g. LOBE-4540), says 'linear' / 'linear issue' / 'link linear', or when creating PRs that reference Linear issues. Covers retrieving issues, updating status, adding completion comments, and creating sub-issue trees."
|
||||
user-invocable: false
|
||||
description: "Linear issue management. MUST USE when: (1) user mentions LOBE-xxx issue IDs (e.g. LOBE-4540), (2) user says 'linear', 'linear issue', 'link linear', (3) creating PRs that reference Linear issues. Provides workflows for retrieving issues, updating status, and adding comments."
|
||||
---
|
||||
|
||||
# Linear Issue Management
|
||||
|
||||
Before using Linear workflows, search for `linear` MCP tools. If not found, treat as not installed.
|
||||
|
||||
## PR Creation with Linear Issues
|
||||
## ⚠️ CRITICAL: PR Creation with Linear Issues
|
||||
|
||||
A PR that fixes a Linear issue has **two separate jobs to do**, and both matter:
|
||||
**When creating a PR that references Linear issues (LOBE-xxx), you MUST:**
|
||||
|
||||
1. **`Fixes LOBE-xxx` in the PR body** — Linear watches GitHub for these magic keywords and auto-links the PR and auto-closes the issue on merge. This is the machine-readable side.
|
||||
2. **A completion comment on the Linear issue** — gives the reviewer/PM/teammate landing in Linear a human-readable summary of what changed and why, without forcing them to click through to GitHub and read a diff.
|
||||
1. Create the PR with magic keywords (`Fixes LOBE-xxx`)
|
||||
2. **IMMEDIATELY after PR creation**, add completion comments to ALL referenced Linear issues
|
||||
3. Do NOT consider the task complete until Linear comments are added
|
||||
|
||||
If you only do step 1, Linear watchers (often non-engineers) hit the issue and see no context. So pair PR creation with the Linear comment as part of the same task — finish both before considering the work done.
|
||||
This is NON-NEGOTIABLE. Skipping Linear comments is a workflow violation.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Retrieve issue details** before starting: `mcp__linear-server__get_issue`
|
||||
2. **Read images** — issue descriptions often contain screenshots with critical context (mockups, error states, before/after). Use `mcp__linear-server__extract_images` so you actually see them; reading raw markdown alone misses what the reporter was looking at.
|
||||
3. **Check for sub-issues**: `mcp__linear-server__list_issues` with `parentId` filter
|
||||
4. **Mark as In Progress** at the moment you start planning or implementing — this signals to teammates the issue is owned, so they don't double-pick it up.
|
||||
2. **Read images**: If the issue description contains images, MUST use `mcp__linear-server__extract_images` to read image content for full context
|
||||
3. **Check for sub-issues**: Use `mcp__linear-server__list_issues` with `parentId` filter
|
||||
4. **Mark as In Progress**: When starting to plan or implement an issue, immediately update status to **"In Progress"** via `mcp__linear-server__update_issue`
|
||||
5. **Update issue status** when completing: `mcp__linear-server__update_issue`
|
||||
6. **Add completion comment** (see [format below](#completion-comment-format))
|
||||
6. **Add completion comment** (REQUIRED): `mcp__linear-server__create_comment`
|
||||
|
||||
## Creating Issues
|
||||
|
||||
When creating issues with `mcp__linear-server__create_issue`, add the `claude code` label. Reason: the label is how the team filters/audits AI-generated issues; without it those issues vanish into the general backlog and the team loses visibility into AI contribution patterns.
|
||||
When creating issues with `mcp__linear-server__create_issue`, **MUST add the `claude code` label**.
|
||||
|
||||
## Language
|
||||
|
||||
Match the issue language to the conversation that produced it — if you're discussing in 中文,write the issue in 中文;if discussing in English, write it in English. Reason: the issue is a continuation of the conversation, and forcing a language switch creates translation friction for the collaborator who started the thread.
|
||||
Issue titles, descriptions, and comments **MUST follow the language of the current conversation**, not default to English.
|
||||
|
||||
Specifics:
|
||||
|
||||
- 中文 conversation → 中文 body; technical terms (file paths, identifiers, library names, commands, error messages) stay in English.
|
||||
- English conversation → English body.
|
||||
- Conversation in 中文 → issue body in 中文;technical terms (file paths, identifiers, library names, commands, error messages) stay in English.
|
||||
- Conversation in English → issue body in English.
|
||||
- Code blocks, file paths, and quoted strings always stay in their original form regardless of surrounding language.
|
||||
- This applies equally to **updates** — when editing an existing issue (description **and titles**), preserve the language of the conversation that triggered the edit; don't switch the issue language mid-refactor.
|
||||
- This applies equally to **updates** — when editing an existing issue (description **and titles**), preserve the language of the conversation that triggered the edit; do not switch the issue language during a refactor (Chinese → English or vice versa).
|
||||
|
||||
Rationale: the issue is a continuation of the conversation. Forcing English when the discussion is in Chinese creates translation friction for the collaborator who came from that thread.
|
||||
|
||||
## Creating Sub-issue Trees
|
||||
|
||||
When breaking a parent issue into a tree of sub-issues (e.g., task decomposition for LOBE-xxx), follow these rules — they work around real limitations of the Linear MCP tools.
|
||||
|
||||
### 1. Prefix titles with an ordering index
|
||||
### 1. ALWAYS prefix titles with an ordering index
|
||||
|
||||
The Linear Sub-issues panel orders children by `sortOrder`, which **defaults to newest-first** (most recently created appears on top). Neither parallel nor serial creation produces the intended top-to-bottom reading order, and the MCP `save_issue` tool does **not expose a `sortOrder` parameter** — you can't set order at create time.
|
||||
The Linear Sub-issues panel displays children by `sortOrder`, which **defaults to newest-first** (most recently created appears on top). Neither parallel nor serial creation will produce the intended top-to-bottom reading order, and the MCP `save_issue` tool does **not expose a `sortOrder` parameter** — you cannot set order at create time.
|
||||
|
||||
Workaround: encode execution order in the title itself:
|
||||
**Workaround**: encode execution order in the title itself:
|
||||
|
||||
```plaintext
|
||||
[1] [db] add schema fields
|
||||
@@ -100,7 +100,7 @@ The implementer may open only the sub-issue, not the parent — don't rely on co
|
||||
|
||||
## Completion Comment Format
|
||||
|
||||
Each completed issue gets a comment summarizing the work, so reviewers and future readers don't have to reconstruct it from the PR diff:
|
||||
Every completed issue MUST have a comment summarizing work done:
|
||||
|
||||
```markdown
|
||||
## Changes Summary
|
||||
@@ -116,28 +116,34 @@ Each completed issue gets a comment summarizing the work, so reviewers and futur
|
||||
- ...
|
||||
```
|
||||
|
||||
This gives team visibility, code-review context, and a paper trail for future reference.
|
||||
This is critical for:
|
||||
|
||||
## PR Association
|
||||
- Team visibility
|
||||
- Code review context
|
||||
- Future reference
|
||||
|
||||
When creating PRs for Linear issues, include magic keywords in the PR body:
|
||||
## PR Association (REQUIRED)
|
||||
|
||||
When creating PRs for Linear issues, include magic keywords in PR body:
|
||||
|
||||
- `Fixes LOBE-123`
|
||||
- `Closes LOBE-123`
|
||||
- `Resolves LOBE-123`
|
||||
|
||||
These trigger Linear's auto-link + auto-close on merge.
|
||||
|
||||
## Per-Issue Completion Rule
|
||||
|
||||
When working on multiple issues, close out **each one before starting the next** — don't batch all the Linear updates to the end. Batching is where comments get forgotten and issues stay stuck in "In Progress" days after the PR shipped.
|
||||
|
||||
For each issue:
|
||||
When working on multiple issues, update EACH issue IMMEDIATELY after completing it:
|
||||
|
||||
1. Complete implementation
|
||||
2. Run `bun run type-check`
|
||||
3. Run related tests
|
||||
4. Create PR if needed
|
||||
5. Update status to **"In Review"** (not "Done" — "Done" is for after the PR merges)
|
||||
6. Add the completion comment
|
||||
7. Move to the next issue
|
||||
5. Update status to **"In Review"** (NOT "Done")
|
||||
6. **Add completion comment immediately**
|
||||
7. Move to next issue
|
||||
|
||||
**Note:** Status → "In Review" when PR created. "Done" only after PR merged.
|
||||
|
||||
**❌ Wrong:** Complete all → Create PR → Forget Linear comments
|
||||
|
||||
**✅ Correct:** Complete → Create PR → Add Linear comments → Task done
|
||||
|
||||
@@ -76,9 +76,7 @@ find_project_pids() {
|
||||
port_pid=$(lsof -ti tcp:"$CDP_PORT" -sTCP:LISTEN 2>/dev/null || true)
|
||||
pids="$pids $port_pid"
|
||||
|
||||
# `|| true` because `grep -v '^$'` exits 1 when input has no non-empty
|
||||
# lines, which (with pipefail + set -e) silently kills the caller.
|
||||
echo "$pids" | tr ' ' '\n' | sort -u | grep -v '^$' | tr '\n' ' ' || true
|
||||
echo "$pids" | tr ' ' '\n' | sort -u | grep -v '^$' | tr '\n' ' '
|
||||
}
|
||||
|
||||
# Wait for the CDP HTTP endpoint to respond, with a deadline + early bail-out
|
||||
@@ -148,7 +146,7 @@ do_stop() {
|
||||
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' ' ' || true)
|
||||
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."
|
||||
@@ -272,17 +270,10 @@ do_start() {
|
||||
# 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.
|
||||
# macOS doesn't ship setsid by default — fall back to plain bash; cleanup
|
||||
# still works via `expand_descendants` walking the process tree.
|
||||
local launch_cmd="
|
||||
setsid bash -c "
|
||||
cd '$PROJECT_ROOT/apps/desktop'
|
||||
exec npx electron-vite dev -- --remote-debugging-port=$CDP_PORT
|
||||
"
|
||||
if command -v setsid >/dev/null 2>&1; then
|
||||
setsid bash -c "$launch_cmd" >> "$ELECTRON_LOG" 2>&1 < /dev/null &
|
||||
else
|
||||
bash -c "$launch_cmd" >> "$ELECTRON_LOG" 2>&1 < /dev/null &
|
||||
fi
|
||||
" >> "$ELECTRON_LOG" 2>&1 < /dev/null &
|
||||
local launcher_pid=$!
|
||||
echo "$launcher_pid" > "$PIDFILE"
|
||||
echo "[electron-dev] Launcher PID (session leader): $launcher_pid"
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
---
|
||||
name: microcopy
|
||||
description: UI copy and microcopy guidelines. Use when writing UI text, buttons, error messages, empty states, onboarding, or any user-facing copy. Triggers on i18n translation, UI text writing, or copy improvement tasks. Supports both Chinese and English.
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# LobeHub UI Microcopy Guidelines
|
||||
|
||||
This file is the quick-reference summary. For full prompt-style guidelines with extensive examples (anti-patterns, tone matrices, scenario walk-throughs), load the language-specific reference:
|
||||
|
||||
- **中文文案** — [`references/zh.md`](./references/zh.md)
|
||||
- **English copy** — [`references/en.md`](./references/en.md)
|
||||
|
||||
Brand: **Where Agents Collaborate** - Focus on collaborative agent system, not just "generation".
|
||||
|
||||
## Fixed Terminology
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: modal
|
||||
description: "LobeHub imperative-modal conventions. Use whenever creating, editing, opening, or migrating a modal/dialog/popup — prefer `createModal` / `confirmModal` / `useModalContext` from `@lobehub/ui/base-ui` (headless) over the legacy root `@lobehub/ui` `createModal` (antd Modal props) and over any declarative `open` state + `<Modal />` pattern. Covers required `ModalHost` mounting, the `Content` + `index.tsx` file layout, `content` vs `children` slot, i18n inside `createModal()` (`import { t } from 'i18next'`), and migration notes. Triggers on `createModal`, `confirmModal`, `useModalContext`, `ModalHost`, `antd Modal`, `<Modal open>`, 'open a modal', 'popup', 'dialog', 'confirm dialog', '弹框', '弹窗', '确认框', 'migrate to base-ui'."
|
||||
description: MUST use when creating, editing, or writing modal dialogs or imperative modals. Prefer createModal / useModalContext / confirmModal from @lobehub/ui/base-ui; root @lobehub/ui is legacy (antd Modal). Covers patterns, ModalHost, and migration notes.
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
name: project-overview
|
||||
description: Complete project architecture and structure guide. Use when exploring the codebase, understanding project organization, finding files, or needing comprehensive architectural context. Triggers on architecture questions, directory navigation, or project overview needs.
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# LobeHub Project Overview
|
||||
|
||||
@@ -1,95 +1,94 @@
|
||||
---
|
||||
name: react
|
||||
description: 'Use when writing or editing any `.tsx` under `src/**`. Triggers: createStaticStyles, createStyles, cssVar, antd-style, Flexbox, Center, Select, Modal, Drawer, Button, Tooltip, DropdownMenu, Popover, Switch, ScrollArea, Link, useNavigate, react-router-dom, next/link, desktopRouter, componentMap.desktop, .desktop.tsx, new component, new page, edit layout, add styles, zustand selector, @lobehub/ui, antd import.'
|
||||
user-invocable: false
|
||||
description: React component development guide. Use when working with React components (.tsx files), creating UI, using @lobehub/ui components, implementing routing, or building frontend features. Triggers on React component creation, modification, layout implementation, or navigation tasks.
|
||||
---
|
||||
|
||||
# React Component Writing Guide
|
||||
|
||||
## Styling
|
||||
- Use antd-style for complex styles; for simple cases, use inline `style` attribute
|
||||
- **Prefer `createStaticStyles` with `cssVar.*`** (zero-runtime) — module-level, no hook call required
|
||||
- Only fall back to `createStyles` + `token` when styles genuinely need runtime computation (dynamic props, JS color fns like `readableColor`/`chroma`)
|
||||
- See `.cursor/docs/createStaticStyles_migration_guide.md` for full pattern
|
||||
- Use `Flexbox` and `Center` from `@lobehub/ui` for layouts (see `references/layout-kit.md`)
|
||||
- Component priority: `src/components` > `@lobehub/ui/base-ui` > `@lobehub/ui` > custom implementation
|
||||
- Always prefer `@lobehub/ui/base-ui` primitives (Select, Modal, DropdownMenu, Popover, Switch, ScrollArea…) over antd equivalents
|
||||
- Fall back to `@lobehub/ui` higher-level components when base-ui has no match
|
||||
- Only implement a custom component as a last resort — never reach for antd directly
|
||||
- Use selectors to access zustand store data
|
||||
|
||||
| Scenario | Approach |
|
||||
| ---------------------------------------------------------- | -------------------------------------------------------------- |
|
||||
| Most cases | `createStaticStyles` + `cssVar.*` (zero-runtime, module-level) |
|
||||
| Simple one-off | Inline `style` attribute |
|
||||
| Truly dynamic (JS color fns like `readableColor`/`chroma`) | `createStyles` + `token` — **last resort** |
|
||||
## @lobehub/ui Components
|
||||
|
||||
## Component Priority
|
||||
If unsure about component usage, search existing code in this project. Most components extend antd with additional props.
|
||||
|
||||
1. **`src/components`** — project-specific reusable components
|
||||
2. **`@lobehub/ui/base-ui`** — headless primitives (Select, Modal, DropdownMenu, Popover, Switch, ScrollArea…)
|
||||
3. **`@lobehub/ui`** — higher-level components (ActionIcon, Markdown, DragPage…)
|
||||
4. **Custom implementation** — last resort; never reach for antd directly
|
||||
Reference: `node_modules/@lobehub/ui/es/index.mjs` for all available components.
|
||||
|
||||
If unsure about available components, search existing code or check `node_modules/@lobehub/ui/es/index.mjs`.
|
||||
**Common Components:**
|
||||
|
||||
### Common @lobehub/ui Components
|
||||
|
||||
| Category | Components |
|
||||
| ------------ | ------------------------------------------------------------------------------- |
|
||||
| General | ActionIcon, ActionIconGroup, Block, Button, Icon |
|
||||
| Data Display | Avatar, Collapse, Empty, Highlighter, Markdown, Tag, Tooltip |
|
||||
| Data Entry | CodeEditor, CopyButton, EditableText, Form, FormModal, Input, SearchBar, Select |
|
||||
| Feedback | Alert, Drawer, Modal |
|
||||
| Layout | Center, DraggablePanel, Flexbox, Grid, Header, MaskShadow |
|
||||
| Navigation | Burger, Dropdown, Menu, SideNav, Tabs |
|
||||
|
||||
## Layout
|
||||
|
||||
Use `Flexbox` and `Center` from `@lobehub/ui`. See `references/layout-kit.md` for full props and examples.
|
||||
|
||||
- Use `gap` instead of `margin` for spacing between flex children
|
||||
- Use `flex={1}` to fill available space
|
||||
- Nest Flexbox for complex layouts; set `overflow: 'auto'` for scrollable regions
|
||||
|
||||
## Navigation
|
||||
|
||||
**For SPA pages, use `react-router-dom`, NOT `next/link`.**
|
||||
|
||||
```tsx
|
||||
// ❌ Wrong
|
||||
import Link from 'next/link';
|
||||
|
||||
// ✅ Correct
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
```
|
||||
|
||||
Access navigate from stores: `useGlobalStore.getState().navigate?.('/settings');`
|
||||
|
||||
## Desktop File Sync Rule
|
||||
|
||||
Files with a `.desktop.ts(x)` variant must be edited **in sync**. Drift causes blank pages in Electron.
|
||||
|
||||
| Base file (web) | Desktop file (Electron) |
|
||||
| -------------------------- | ---------------------------------- |
|
||||
| `desktopRouter.config.tsx` | `desktopRouter.config.desktop.tsx` |
|
||||
| `componentMap.ts` | `componentMap.desktop.ts` |
|
||||
|
||||
**After editing any `.ts`/`.tsx`:** glob for `<filename>.desktop.{ts,tsx}` in the same directory. If found, apply the equivalent sync-import change.
|
||||
- General: ActionIcon, ActionIconGroup, Block, Button, Icon
|
||||
- Data Display: Avatar, Collapse, Empty, Highlighter, Markdown, Tag, Tooltip
|
||||
- Data Entry: CodeEditor, CopyButton, EditableText, Form, FormModal, Input, SearchBar, Select
|
||||
- Feedback: Alert, Drawer, Modal
|
||||
- Layout: Center, DraggablePanel, Flexbox, Grid, Header, MaskShadow
|
||||
- Navigation: Burger, Dropdown, Menu, SideNav, Tabs
|
||||
|
||||
## Routing Architecture
|
||||
|
||||
| Route Type | Use Case | Implementation |
|
||||
| ------------------ | ---------- | -------------------------------------------------- |
|
||||
| Next.js App Router | Auth pages | `src/app/[variants]/(auth)/` |
|
||||
| React Router DOM | Main SPA | `desktopRouter.config.tsx` + `.desktop.tsx` (pair) |
|
||||
Hybrid routing: Next.js App Router (static pages) + React Router DOM (main SPA).
|
||||
|
||||
Router utilities:
|
||||
| Route Type | Use Case | Implementation |
|
||||
| ------------------ | --------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| Next.js App Router | Auth pages (login, signup, oauth) | `src/app/[variants]/(auth)/` |
|
||||
| React Router DOM | Main SPA (chat, settings) | `desktopRouter.config.tsx` + `desktopRouter.config.desktop.tsx` (must match) |
|
||||
|
||||
### Key Files
|
||||
|
||||
- Entry: `src/spa/entry.web.tsx` (web), `src/spa/entry.mobile.tsx`, `src/spa/entry.desktop.tsx`
|
||||
- Desktop router (pair — **always edit both** when changing routes): `src/spa/router/desktopRouter.config.tsx` (dynamic imports) and `src/spa/router/desktopRouter.config.desktop.tsx` (sync imports). Drift can cause unregistered routes / blank screen.
|
||||
- Mobile router: `src/spa/router/mobileRouter.config.tsx`
|
||||
- Router utilities: `src/utils/router.tsx`
|
||||
|
||||
### `.desktop.{ts,tsx}` File Sync Rule
|
||||
|
||||
**CRITICAL**: Some files have a `.desktop.ts(x)` variant that Electron uses instead of the base file. When editing a base file, **always check** if a `.desktop` counterpart exists and update it in sync. Drift causes blank pages or missing features in Electron.
|
||||
|
||||
Known pairs that must stay in sync:
|
||||
|
||||
| Base file (web, dynamic imports) | Desktop file (Electron, sync imports) |
|
||||
| ----------------------------------------------------- | ------------------------------------------------------------- |
|
||||
| `src/spa/router/desktopRouter.config.tsx` | `src/spa/router/desktopRouter.config.desktop.tsx` |
|
||||
| `src/routes/(main)/settings/features/componentMap.ts` | `src/routes/(main)/settings/features/componentMap.desktop.ts` |
|
||||
|
||||
**How to check**: After editing any `.ts` / `.tsx` file, run `Glob` for `<filename>.desktop.{ts,tsx}` in the same directory. If a match exists, update it with the equivalent sync-import change.
|
||||
|
||||
### Router Utilities
|
||||
|
||||
```tsx
|
||||
import { dynamicElement, redirectElement, ErrorBoundary } from '@/utils/router';
|
||||
|
||||
element: dynamicElement(() => import('./chat'), 'Desktop > Chat');
|
||||
element: redirectElement('/settings/profile');
|
||||
errorElement: <ErrorBoundary />;
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
### Navigation
|
||||
|
||||
| Mistake | Fix |
|
||||
| ---------------------------------------- | ------------------------------------------------------ |
|
||||
| Using `next/link` in SPA | Use `react-router-dom` `Link` |
|
||||
| Using antd directly | Use `@lobehub/ui/base-ui` first, then `@lobehub/ui` |
|
||||
| `createStyles` for static styles | Use `createStaticStyles` + `cssVar` |
|
||||
| Editing only `desktopRouter.config.tsx` | Must edit both `.tsx` and `.desktop.tsx` |
|
||||
| Using `margin` for flex spacing | Use `gap` prop on Flexbox |
|
||||
| Accessing zustand store without selector | Use selectors to access store data (see zustand skill) |
|
||||
**Important**: For SPA pages, use `Link` from `react-router-dom`, NOT `next/link`.
|
||||
|
||||
```tsx
|
||||
// ❌ Wrong
|
||||
import Link from 'next/link';
|
||||
<Link href="/">Home</Link>;
|
||||
|
||||
// ✅ Correct
|
||||
import { Link } from 'react-router-dom';
|
||||
<Link to="/">Home</Link>;
|
||||
|
||||
// In components
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
const navigate = useNavigate();
|
||||
navigate('/chat');
|
||||
|
||||
// From stores
|
||||
const navigate = useGlobalStore.getState().navigate;
|
||||
navigate?.('/settings');
|
||||
```
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
name: review-checklist
|
||||
description: 'Common recurring mistakes in LobeHub code review — console leftovers, missing return await, hardcoded secrets, hardcoded i18n strings, desktop router pair drift, antd vs @lobehub/ui, non-idempotent migrations, cloud impact red flags. Use as a quick checklist when reviewing PRs, diffs, or branch changes.'
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# Review Checklist
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
name: spa-routes
|
||||
description: MUST use when editing src/routes/ segments, src/spa/router/desktopRouter.config.tsx or desktopRouter.config.desktop.tsx (always change both together), mobileRouter.config.tsx, or when moving UI/logic between routes and src/features/.
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# SPA Routes and Features Guide
|
||||
@@ -85,10 +84,10 @@ Each feature should:
|
||||
|
||||
## 3a. Desktop router pair (`desktopRouter.config` × 2)
|
||||
|
||||
| File | Role |
|
||||
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `desktopRouter.config.tsx` | Dynamic imports via `dynamicElement` / `dynamicLayout` — code-splitting; used by `entry.web.tsx` and `entry.desktop.tsx`. |
|
||||
| `desktopRouter.config.desktop.tsx` | Same route tree with **synchronous** imports — kept for Electron / local parity and predictable bundling. |
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `desktopRouter.config.tsx` | Dynamic imports via `dynamicElement` / `dynamicLayout` — code-splitting; used by `entry.web.tsx` and `entry.desktop.tsx`. |
|
||||
| `desktopRouter.config.desktop.tsx` | Same route tree with **synchronous** imports — kept for Electron / local parity and predictable bundling. |
|
||||
|
||||
Anything that changes the tree (new segment, renamed `path`, moved layout, new child route) must be reflected in **both** files in one PR or commit. Remove routes from both when deleting.
|
||||
|
||||
|
||||
@@ -1,91 +1,257 @@
|
||||
---
|
||||
name: store-data-structures
|
||||
description: Zustand store data structure patterns for LobeHub. Covers List vs Detail data structures, Map + Reducer patterns, type definitions, and when to use each pattern. Use when designing store state, choosing data structures, or implementing list/detail pages.
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# LobeHub Store Data Structures
|
||||
|
||||
How to structure data in Zustand stores for fast list rendering, multi-detail caching, and ergonomic optimistic updates.
|
||||
This guide covers how to structure data in Zustand stores for optimal performance and user experience.
|
||||
|
||||
## Core Principles
|
||||
|
||||
### ✅ DO
|
||||
|
||||
1. **Separate List and Detail** — different structures for list pages and detail pages
|
||||
2. **Use Map for Details** — cache multiple detail pages with `Record<string, Detail>`
|
||||
3. **Use Array for Lists** — simple arrays for list display
|
||||
4. **Types from `@lobechat/types`** — never use `@lobechat/database` types in stores
|
||||
5. **Distinguish List and Detail types** — List types may have computed UI fields
|
||||
1. **Separate List and Detail** - Use different structures for list pages and detail pages
|
||||
2. **Use Map for Details** - Cache multiple detail pages with `Record<string, Detail>`
|
||||
3. **Use Array for Lists** - Simple arrays for list display
|
||||
4. **Types from @lobechat/types** - Never use `@lobechat/database` types in stores
|
||||
5. **Distinguish List and Detail types** - List types may have computed UI fields
|
||||
|
||||
### ❌ DON'T
|
||||
|
||||
1. **Don't use a single detail object** — can't cache multiple pages
|
||||
2. **Don't mix List and Detail types** — they have different purposes
|
||||
3. **Don't use database types** — use types from `@lobechat/types`
|
||||
4. **Don't use Map for lists** — simple arrays are sufficient
|
||||
1. **Don't use single detail object** - Can't cache multiple pages
|
||||
2. **Don't mix List and Detail types** - They have different purposes
|
||||
3. **Don't use database types** - Use types from `@lobechat/types`
|
||||
4. **Don't use Map for lists** - Simple arrays are sufficient
|
||||
|
||||
---
|
||||
|
||||
## Type Definitions
|
||||
|
||||
Each entity gets its own file under `@lobechat/types/`. Each file exports two types:
|
||||
Types should be organized by entity in separate files:
|
||||
|
||||
- **Detail type** — full entity, including heavy fields (rubrics, content, editor state, …)
|
||||
- **List item type** — a **subset** that excludes heavy fields, may add computed UI fields (counts, timestamps formatted for display)
|
||||
```
|
||||
@lobechat/types/src/eval/
|
||||
├── benchmark.ts # Benchmark types
|
||||
├── agentEvalDataset.ts # Dataset types
|
||||
├── agentEvalRun.ts # Run types
|
||||
└── index.ts # Re-exports
|
||||
```
|
||||
|
||||
**Important:** the List type is a **subset**, not an `extends` of Detail. Extending pulls the heavy fields right back in.
|
||||
### Example: Benchmark Types
|
||||
|
||||
> See [`references/types.md`](./references/types.md) for full worked examples (Benchmark, Document) and the heavy-field exclusion checklist.
|
||||
```typescript
|
||||
// packages/types/src/eval/benchmark.ts
|
||||
import type { EvalBenchmarkRubric } from './rubric';
|
||||
|
||||
// ============================================
|
||||
// Detail Type - Full entity (for detail pages)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Full benchmark entity with all fields including heavy data
|
||||
*/
|
||||
export interface AgentEvalBenchmark {
|
||||
createdAt: Date;
|
||||
description?: string | null;
|
||||
id: string;
|
||||
identifier: string;
|
||||
isSystem: boolean;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
name: string;
|
||||
referenceUrl?: string | null;
|
||||
rubrics: EvalBenchmarkRubric[]; // Heavy field
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// List Type - Lightweight (for list display)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Lightweight benchmark item - excludes heavy fields
|
||||
* May include computed statistics for UI
|
||||
*/
|
||||
export interface AgentEvalBenchmarkListItem {
|
||||
createdAt: Date;
|
||||
description?: string | null;
|
||||
id: string;
|
||||
identifier: string;
|
||||
isSystem: boolean;
|
||||
name: string;
|
||||
// Note: rubrics NOT included (heavy field)
|
||||
|
||||
// Computed statistics for UI display
|
||||
datasetCount?: number;
|
||||
runCount?: number;
|
||||
testCaseCount?: number;
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Document Types (with heavy content)
|
||||
|
||||
```typescript
|
||||
// packages/types/src/document.ts
|
||||
|
||||
/**
|
||||
* Full document entity - includes heavy content fields
|
||||
*/
|
||||
export interface Document {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
content: string; // Heavy field - full markdown content
|
||||
editorData: any; // Heavy field - editor state
|
||||
metadata?: Record<string, unknown>;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight document item - excludes heavy content
|
||||
*/
|
||||
export interface DocumentListItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
// Note: content and editorData NOT included
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
// Computed statistics
|
||||
wordCount?: number;
|
||||
lastEditedBy?: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
|
||||
- **Detail types** include ALL fields from database (full entity)
|
||||
- **List types** are **subsets** that exclude heavy/large fields
|
||||
- List types may add computed statistics for UI (e.g., `testCaseCount`)
|
||||
- **Each entity gets its own file** (not mixed together)
|
||||
- **All types** exported from `@lobechat/types`, NOT `@lobechat/database`
|
||||
|
||||
**Heavy fields to exclude from List:**
|
||||
|
||||
- Large text content (`content`, `editorData`, `fullDescription`)
|
||||
- Complex objects (`rubrics`, `config`, `metrics`)
|
||||
- Binary data (`image`, `file`)
|
||||
- Large arrays (`messages`, `items`)
|
||||
|
||||
---
|
||||
|
||||
## When to Use Map vs Array
|
||||
|
||||
### Use Map + Reducer — for Detail Data
|
||||
### Use Map + Reducer (for Detail Data)
|
||||
|
||||
✅ Detail page data caching — multiple detail pages cached simultaneously
|
||||
✅ Optimistic updates — update UI before API responds
|
||||
✅ Per-item loading states — track which items are being updated
|
||||
✅ Multi-page navigation — user can switch between details without refetching
|
||||
✅ **Detail page data caching** - Cache multiple detail pages simultaneously
|
||||
✅ **Optimistic updates** - Update UI before API responds
|
||||
✅ **Per-item loading states** - Track which items are being updated
|
||||
✅ **Multiple pages open** - User can navigate between details without refetching
|
||||
|
||||
**Structure:**
|
||||
|
||||
```typescript
|
||||
benchmarkDetailMap: Record<string, AgentEvalBenchmark>;
|
||||
```
|
||||
|
||||
Examples: benchmark detail pages, dataset detail pages, user profiles.
|
||||
**Example:** Benchmark detail pages, Dataset detail pages, User profiles
|
||||
|
||||
### Use Simple Array — for List Data
|
||||
### Use Simple Array (for List Data)
|
||||
|
||||
✅ List display — lists, tables, cards
|
||||
✅ Refresh as a whole — entire list refreshes together
|
||||
✅ No per-item updates — no need to mutate individual rows in place
|
||||
✅ Simple data flow — fewer moving parts
|
||||
✅ **List display** - Lists, tables, cards
|
||||
✅ **Read-only or refresh-as-whole** - Entire list refreshes together
|
||||
✅ **No per-item updates** - No need to update individual items
|
||||
✅ **Simple data flow** - Easier to understand and maintain
|
||||
|
||||
**Structure:**
|
||||
|
||||
```typescript
|
||||
benchmarkList: AgentEvalBenchmarkListItem[];
|
||||
benchmarkList: AgentEvalBenchmarkListItem[]
|
||||
```
|
||||
|
||||
Examples: benchmark list, dataset list, user list.
|
||||
**Example:** Benchmark list, Dataset list, User list
|
||||
|
||||
---
|
||||
|
||||
## State Structure Pattern
|
||||
|
||||
### Complete Example
|
||||
|
||||
```typescript
|
||||
// packages/types/src/eval/benchmark.ts
|
||||
import type { EvalBenchmarkRubric } from './rubric';
|
||||
|
||||
/**
|
||||
* Full benchmark entity (for detail pages)
|
||||
*/
|
||||
export interface AgentEvalBenchmark {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
identifier: string;
|
||||
rubrics: EvalBenchmarkRubric[]; // Heavy field
|
||||
metadata?: Record<string, unknown> | null;
|
||||
isSystem: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight benchmark (for list display)
|
||||
* Excludes heavy fields like rubrics
|
||||
*/
|
||||
export interface AgentEvalBenchmarkListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
identifier: string;
|
||||
isSystem: boolean;
|
||||
createdAt: Date;
|
||||
// Note: rubrics excluded
|
||||
|
||||
// Computed statistics
|
||||
testCaseCount?: number;
|
||||
datasetCount?: number;
|
||||
runCount?: number;
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/store/eval/slices/benchmark/initialState.ts
|
||||
import type { AgentEvalBenchmark, AgentEvalBenchmarkListItem } from '@lobechat/types';
|
||||
|
||||
export interface BenchmarkSliceState {
|
||||
// List — simple array
|
||||
// ============================================
|
||||
// List Data - Simple Array
|
||||
// ============================================
|
||||
/**
|
||||
* List of benchmarks for list page display
|
||||
* May include computed fields like testCaseCount
|
||||
*/
|
||||
benchmarkList: AgentEvalBenchmarkListItem[];
|
||||
benchmarkListInit: boolean;
|
||||
|
||||
// Detail — map for multi-entity caching
|
||||
// ============================================
|
||||
// Detail Data - Map for Caching
|
||||
// ============================================
|
||||
/**
|
||||
* Map of benchmark details keyed by ID
|
||||
* Caches detail page data for multiple benchmarks
|
||||
* Enables optimistic updates and per-item loading
|
||||
*/
|
||||
benchmarkDetailMap: Record<string, AgentEvalBenchmark>;
|
||||
loadingBenchmarkDetailIds: string[]; // per-item loading
|
||||
|
||||
// Mutation states (drive form-level UI)
|
||||
/**
|
||||
* Track which benchmark details are being loaded/updated
|
||||
* For showing spinners on specific items
|
||||
*/
|
||||
loadingBenchmarkDetailIds: string[];
|
||||
|
||||
// ============================================
|
||||
// Mutation States
|
||||
// ============================================
|
||||
isCreatingBenchmark: boolean;
|
||||
isUpdatingBenchmark: boolean;
|
||||
isDeletingBenchmark: boolean;
|
||||
@@ -106,51 +272,180 @@ export const benchmarkInitialState: BenchmarkSliceState = {
|
||||
|
||||
## Reducer Pattern (for Detail Map)
|
||||
|
||||
When the Detail Map needs optimistic updates (i.e. the user edits a row and the UI should reflect it before the server confirms), wire a typed reducer instead of inlining `set` calls. This keeps mutations testable and the dispatch surface small.
|
||||
### Why Use Reducer?
|
||||
|
||||
> See [`references/reducer.md`](./references/reducer.md) for the full discriminated-union action types, the `produce`-based reducer, and the `internal_dispatch*` slice methods that connect them to Zustand.
|
||||
- **Immutable updates** - Immer ensures immutability
|
||||
- **Type-safe actions** - TypeScript discriminated unions
|
||||
- **Testable** - Pure functions easy to test
|
||||
- **Reusable** - Same reducer for optimistic updates and server data
|
||||
|
||||
### Reducer Structure
|
||||
|
||||
```typescript
|
||||
// src/store/eval/slices/benchmark/reducer.ts
|
||||
import { produce } from 'immer';
|
||||
import type { AgentEvalBenchmark } from '@lobechat/types';
|
||||
|
||||
// ============================================
|
||||
// Action Types
|
||||
// ============================================
|
||||
|
||||
type SetBenchmarkDetailAction = {
|
||||
id: string;
|
||||
type: 'setBenchmarkDetail';
|
||||
value: AgentEvalBenchmark;
|
||||
};
|
||||
|
||||
type UpdateBenchmarkDetailAction = {
|
||||
id: string;
|
||||
type: 'updateBenchmarkDetail';
|
||||
value: Partial<AgentEvalBenchmark>;
|
||||
};
|
||||
|
||||
type DeleteBenchmarkDetailAction = {
|
||||
id: string;
|
||||
type: 'deleteBenchmarkDetail';
|
||||
};
|
||||
|
||||
export type BenchmarkDetailDispatch =
|
||||
| SetBenchmarkDetailAction
|
||||
| UpdateBenchmarkDetailAction
|
||||
| DeleteBenchmarkDetailAction;
|
||||
|
||||
// ============================================
|
||||
// Reducer Function
|
||||
// ============================================
|
||||
|
||||
export const benchmarkDetailReducer = (
|
||||
state: Record<string, AgentEvalBenchmark> = {},
|
||||
payload: BenchmarkDetailDispatch,
|
||||
): Record<string, AgentEvalBenchmark> => {
|
||||
switch (payload.type) {
|
||||
case 'setBenchmarkDetail': {
|
||||
return produce(state, (draft) => {
|
||||
draft[payload.id] = payload.value;
|
||||
});
|
||||
}
|
||||
|
||||
case 'updateBenchmarkDetail': {
|
||||
return produce(state, (draft) => {
|
||||
if (draft[payload.id]) {
|
||||
draft[payload.id] = { ...draft[payload.id], ...payload.value };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
case 'deleteBenchmarkDetail': {
|
||||
return produce(state, (draft) => {
|
||||
delete draft[payload.id];
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Internal Dispatch Methods
|
||||
|
||||
```typescript
|
||||
// In action.ts
|
||||
export interface BenchmarkAction {
|
||||
// ... other methods ...
|
||||
|
||||
// Internal methods - not for direct UI use
|
||||
internal_dispatchBenchmarkDetail: (payload: BenchmarkDetailDispatch) => void;
|
||||
internal_updateBenchmarkDetailLoading: (id: string, loading: boolean) => void;
|
||||
}
|
||||
|
||||
export const createBenchmarkSlice: StateCreator<...> = (set, get) => ({
|
||||
// ... other methods ...
|
||||
|
||||
// Internal - Dispatch to reducer
|
||||
internal_dispatchBenchmarkDetail: (payload) => {
|
||||
const currentMap = get().benchmarkDetailMap;
|
||||
const nextMap = benchmarkDetailReducer(currentMap, payload);
|
||||
|
||||
// Only update if changed
|
||||
if (isEqual(nextMap, currentMap)) return;
|
||||
|
||||
set(
|
||||
{ benchmarkDetailMap: nextMap },
|
||||
false,
|
||||
`dispatchBenchmarkDetail/${payload.type}`,
|
||||
);
|
||||
},
|
||||
|
||||
// Internal - Update loading state
|
||||
internal_updateBenchmarkDetailLoading: (id, loading) => {
|
||||
set(
|
||||
(state) => {
|
||||
if (loading) {
|
||||
return { loadingBenchmarkDetailIds: [...state.loadingBenchmarkDetailIds, id] };
|
||||
}
|
||||
return {
|
||||
loadingBenchmarkDetailIds: state.loadingBenchmarkDetailIds.filter((i) => i !== id),
|
||||
};
|
||||
},
|
||||
false,
|
||||
'updateBenchmarkDetailLoading',
|
||||
);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Structure Comparison
|
||||
|
||||
### ❌ WRONG — Single Detail Object
|
||||
### ❌ WRONG - Single Detail Object
|
||||
|
||||
```typescript
|
||||
interface BenchmarkSliceState {
|
||||
// ❌ Can only cache one detail
|
||||
benchmarkDetail: AgentEvalBenchmark | null;
|
||||
|
||||
// ❌ Global loading state
|
||||
isLoadingBenchmarkDetail: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
Problems:
|
||||
**Problems:**
|
||||
|
||||
- Can only cache one detail page at a time
|
||||
- Switching between details forces refetch
|
||||
- Switching between details causes unnecessary refetches
|
||||
- No optimistic updates
|
||||
- No per-item loading states
|
||||
|
||||
### ✅ CORRECT — Separate List and Detail
|
||||
### ✅ CORRECT - Separate List and Detail
|
||||
|
||||
```typescript
|
||||
import type { AgentEvalBenchmark, AgentEvalBenchmarkListItem } from '@lobechat/types';
|
||||
|
||||
interface BenchmarkSliceState {
|
||||
// ✅ List data - simple array
|
||||
benchmarkList: AgentEvalBenchmarkListItem[];
|
||||
benchmarkListInit: boolean;
|
||||
|
||||
// ✅ Detail data - map for caching
|
||||
benchmarkDetailMap: Record<string, AgentEvalBenchmark>;
|
||||
|
||||
// ✅ Per-item loading
|
||||
loadingBenchmarkDetailIds: string[];
|
||||
|
||||
// ✅ Mutation states
|
||||
isCreatingBenchmark: boolean;
|
||||
isUpdatingBenchmark: boolean;
|
||||
isDeletingBenchmark: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
Benefits:
|
||||
**Benefits:**
|
||||
|
||||
- Cache multiple detail pages
|
||||
- Fast navigation between cached details
|
||||
- Optimistic updates via reducer
|
||||
- Optimistic updates with reducer
|
||||
- Per-item loading states
|
||||
- Clear separation of concerns
|
||||
|
||||
@@ -160,16 +455,22 @@ Benefits:
|
||||
|
||||
### Accessing List Data
|
||||
|
||||
```tsx
|
||||
```typescript
|
||||
const BenchmarkList = () => {
|
||||
// Simple array access
|
||||
const benchmarks = useEvalStore((s) => s.benchmarkList);
|
||||
const isInit = useEvalStore((s) => s.benchmarkListInit);
|
||||
|
||||
if (!isInit) return <Loading />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{benchmarks.map((b) => (
|
||||
<BenchmarkCard key={b.id} name={b.name} testCaseCount={b.testCaseCount} />
|
||||
{benchmarks.map(b => (
|
||||
<BenchmarkCard
|
||||
key={b.id}
|
||||
name={b.name}
|
||||
testCaseCount={b.testCaseCount} // Computed field
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -178,18 +479,22 @@ const BenchmarkList = () => {
|
||||
|
||||
### Accessing Detail Data
|
||||
|
||||
```tsx
|
||||
```typescript
|
||||
const BenchmarkDetail = () => {
|
||||
const { benchmarkId } = useParams<{ benchmarkId: string }>();
|
||||
|
||||
// Get from map
|
||||
const benchmark = useEvalStore((s) =>
|
||||
benchmarkId ? s.benchmarkDetailMap[benchmarkId] : undefined,
|
||||
);
|
||||
|
||||
// Check loading
|
||||
const isLoading = useEvalStore((s) =>
|
||||
benchmarkId ? s.loadingBenchmarkDetailIds.includes(benchmarkId) : false,
|
||||
);
|
||||
|
||||
if (!benchmark) return <Loading />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{benchmark.name}</h1>
|
||||
@@ -205,6 +510,7 @@ const BenchmarkDetail = () => {
|
||||
// src/store/eval/slices/benchmark/selectors.ts
|
||||
export const benchmarkSelectors = {
|
||||
getBenchmarkDetail: (id: string) => (s: EvalStore) => s.benchmarkDetailMap[id],
|
||||
|
||||
isLoadingBenchmarkDetail: (id: string) => (s: EvalStore) =>
|
||||
s.loadingBenchmarkDetailIds.includes(id),
|
||||
};
|
||||
@@ -218,7 +524,7 @@ const isLoading = useEvalStore(benchmarkSelectors.isLoadingBenchmarkDetail(bench
|
||||
|
||||
## Decision Tree
|
||||
|
||||
```text
|
||||
```
|
||||
Need to store data?
|
||||
│
|
||||
├─ Is it a LIST for display?
|
||||
@@ -241,40 +547,43 @@ Need to store data?
|
||||
|
||||
When designing store state structure:
|
||||
|
||||
- [ ] **Organize types by entity** in separate files (e.g. `benchmark.ts`, `agentEvalDataset.ts`)
|
||||
- [ ] **Organize types by entity** in separate files (e.g., `benchmark.ts`, `agentEvalDataset.ts`)
|
||||
- [ ] Create **Detail** type (full entity with all fields including heavy ones)
|
||||
- [ ] Create **ListItem** type:
|
||||
- [ ] Subset of Detail (exclude heavy fields)
|
||||
- [ ] Subset of Detail type (exclude heavy fields)
|
||||
- [ ] May include computed statistics for UI
|
||||
- [ ] **NOT** `extends` Detail
|
||||
- [ ] **NOT** extending Detail type (it's a subset, not extension)
|
||||
- [ ] Use **array** for list data: `xxxList: XxxListItem[]`
|
||||
- [ ] Use **Map** for detail data: `xxxDetailMap: Record<string, Xxx>`
|
||||
- [ ] Per-item loading: `loadingXxxDetailIds: string[]`
|
||||
- [ ] **Reducer** for detail map if optimistic updates needed (see [`references/reducer.md`](./references/reducer.md))
|
||||
- [ ] **Internal dispatch** and **loading** methods
|
||||
- [ ] **Selectors** for clean access (optional but recommended)
|
||||
- [ ] Document in comments which fields are excluded from List and why
|
||||
- [ ] Add per-item loading: `loadingXxxDetailIds: string[]`
|
||||
- [ ] Create **reducer** for detail map if optimistic updates needed
|
||||
- [ ] Add **internal dispatch** and **loading** methods
|
||||
- [ ] Create **selectors** for clean access (optional but recommended)
|
||||
- [ ] Document in comments:
|
||||
- [ ] What fields are excluded from List and why
|
||||
- [ ] What computed fields mean
|
||||
- [ ] What each Map is for
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **File organization** — one entity per file, not mixed
|
||||
2. **List is a subset** — ListItem excludes heavy fields, does not `extends` Detail
|
||||
3. **Clear naming** — `xxxList` for arrays, `xxxDetailMap` for maps
|
||||
4. **Consistent patterns** — all detail maps follow the same shape
|
||||
5. **Type safety** — never use `any`, always use proper types
|
||||
6. **Document exclusions** — comment which fields are excluded and why
|
||||
7. **Selectors** — encapsulate access patterns
|
||||
8. **Loading states** — per-item for details, global for mutations
|
||||
9. **Immutability** — use Immer in reducers
|
||||
1. **File organization** - One entity per file, not mixed together
|
||||
2. **List is subset** - ListItem excludes heavy fields, not extends Detail
|
||||
3. **Clear naming** - `xxxList` for arrays, `xxxDetailMap` for maps
|
||||
4. **Consistent patterns** - All detail maps follow same structure
|
||||
5. **Type safety** - Never use `any`, always use proper types
|
||||
6. **Document exclusions** - Comment which fields are excluded from List and why
|
||||
7. **Selectors** - Encapsulate access patterns
|
||||
8. **Loading states** - Per-item for details, global for lists
|
||||
9. **Immutability** - Use Immer in reducers
|
||||
|
||||
### Common Mistakes to Avoid
|
||||
|
||||
❌ **DON'T extend Detail in List:**
|
||||
|
||||
```typescript
|
||||
// Wrong — pulls heavy fields back in
|
||||
// Wrong - List should not extend Detail
|
||||
export interface BenchmarkListItem extends Benchmark {
|
||||
testCaseCount?: number;
|
||||
}
|
||||
@@ -283,6 +592,7 @@ export interface BenchmarkListItem extends Benchmark {
|
||||
✅ **DO create separate subset:**
|
||||
|
||||
```typescript
|
||||
// Correct - List is a subset with computed fields
|
||||
export interface BenchmarkListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -293,14 +603,14 @@ export interface BenchmarkListItem {
|
||||
|
||||
❌ **DON'T mix entities in one file:**
|
||||
|
||||
```text
|
||||
// Wrong — all entities in agentEvalEntities.ts
|
||||
```typescript
|
||||
// Wrong - all entities in agentEvalEntities.ts
|
||||
```
|
||||
|
||||
✅ **DO separate by entity:**
|
||||
|
||||
```text
|
||||
// Correct — separate files
|
||||
```typescript
|
||||
// Correct - separate files
|
||||
// benchmark.ts
|
||||
// agentEvalDataset.ts
|
||||
// agentEvalRun.ts
|
||||
@@ -310,5 +620,5 @@ export interface BenchmarkListItem {
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `data-fetching` — how to fetch and update this data
|
||||
- `zustand` — general Zustand patterns
|
||||
- `data-fetching` - How to fetch and update this data
|
||||
- `zustand` - General Zustand patterns
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
# Reducer Pattern (for Detail Map)
|
||||
|
||||
## Why Use a Reducer?
|
||||
|
||||
- **Immutable updates** — Immer makes immutability easy
|
||||
- **Type-safe actions** — discriminated union of action types prevents typos
|
||||
- **Testable** — pure function, easy to unit test
|
||||
- **Reusable** — same reducer powers optimistic updates and server-data writes
|
||||
|
||||
## Reducer Structure
|
||||
|
||||
```typescript
|
||||
// src/store/eval/slices/benchmark/reducer.ts
|
||||
import { produce } from 'immer';
|
||||
import type { AgentEvalBenchmark } from '@lobechat/types';
|
||||
|
||||
// Action types — discriminated union
|
||||
type SetBenchmarkDetailAction = {
|
||||
id: string;
|
||||
type: 'setBenchmarkDetail';
|
||||
value: AgentEvalBenchmark;
|
||||
};
|
||||
|
||||
type UpdateBenchmarkDetailAction = {
|
||||
id: string;
|
||||
type: 'updateBenchmarkDetail';
|
||||
value: Partial<AgentEvalBenchmark>;
|
||||
};
|
||||
|
||||
type DeleteBenchmarkDetailAction = {
|
||||
id: string;
|
||||
type: 'deleteBenchmarkDetail';
|
||||
};
|
||||
|
||||
export type BenchmarkDetailDispatch =
|
||||
| SetBenchmarkDetailAction
|
||||
| UpdateBenchmarkDetailAction
|
||||
| DeleteBenchmarkDetailAction;
|
||||
|
||||
export const benchmarkDetailReducer = (
|
||||
state: Record<string, AgentEvalBenchmark> = {},
|
||||
payload: BenchmarkDetailDispatch,
|
||||
): Record<string, AgentEvalBenchmark> => {
|
||||
switch (payload.type) {
|
||||
case 'setBenchmarkDetail': {
|
||||
return produce(state, (draft) => {
|
||||
draft[payload.id] = payload.value;
|
||||
});
|
||||
}
|
||||
|
||||
case 'updateBenchmarkDetail': {
|
||||
return produce(state, (draft) => {
|
||||
if (draft[payload.id]) {
|
||||
draft[payload.id] = { ...draft[payload.id], ...payload.value };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
case 'deleteBenchmarkDetail': {
|
||||
return produce(state, (draft) => {
|
||||
delete draft[payload.id];
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Internal Dispatch Methods
|
||||
|
||||
The slice exposes two `internal_*` methods so the reducer and the loading state stay encapsulated behind a stable contract:
|
||||
|
||||
```typescript
|
||||
// In action.ts
|
||||
export interface BenchmarkAction {
|
||||
// ... other methods ...
|
||||
|
||||
// Internal — not for direct UI use
|
||||
internal_dispatchBenchmarkDetail: (payload: BenchmarkDetailDispatch) => void;
|
||||
internal_updateBenchmarkDetailLoading: (id: string, loading: boolean) => void;
|
||||
}
|
||||
|
||||
export const createBenchmarkSlice: StateCreator<...> = (set, get) => ({
|
||||
// ... other methods ...
|
||||
|
||||
// Dispatch to reducer
|
||||
internal_dispatchBenchmarkDetail: (payload) => {
|
||||
const currentMap = get().benchmarkDetailMap;
|
||||
const nextMap = benchmarkDetailReducer(currentMap, payload);
|
||||
|
||||
// Skip set when nothing changed — avoids unnecessary re-renders
|
||||
if (isEqual(nextMap, currentMap)) return;
|
||||
|
||||
set(
|
||||
{ benchmarkDetailMap: nextMap },
|
||||
false,
|
||||
`dispatchBenchmarkDetail/${payload.type}`,
|
||||
);
|
||||
},
|
||||
|
||||
// Update loading state for a specific id
|
||||
internal_updateBenchmarkDetailLoading: (id, loading) => {
|
||||
set(
|
||||
(state) => ({
|
||||
loadingBenchmarkDetailIds: loading
|
||||
? [...state.loadingBenchmarkDetailIds, id]
|
||||
: state.loadingBenchmarkDetailIds.filter((i) => i !== id),
|
||||
}),
|
||||
false,
|
||||
'updateBenchmarkDetailLoading',
|
||||
);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The `internal_` prefix is a convention — UI components should call the public mutation methods (e.g. `updateBenchmark`), which in turn call `internal_dispatch*`. This keeps reducer dispatch shapes out of the component layer.
|
||||
@@ -1,101 +0,0 @@
|
||||
# Type Definitions in Detail
|
||||
|
||||
The skill body's Type Definitions section covers the rules; this file holds the full worked examples to keep SKILL.md lean.
|
||||
|
||||
## Organization
|
||||
|
||||
Types should be organized by entity in separate files (not mixed):
|
||||
|
||||
```text
|
||||
@lobechat/types/src/eval/
|
||||
├── benchmark.ts # Benchmark types
|
||||
├── agentEvalDataset.ts # Dataset types
|
||||
├── agentEvalRun.ts # Run types
|
||||
└── index.ts # Re-exports
|
||||
```
|
||||
|
||||
## Example: Benchmark Types
|
||||
|
||||
```typescript
|
||||
// packages/types/src/eval/benchmark.ts
|
||||
import type { EvalBenchmarkRubric } from './rubric';
|
||||
|
||||
/**
|
||||
* Full benchmark entity with all fields including heavy data.
|
||||
*/
|
||||
export interface AgentEvalBenchmark {
|
||||
createdAt: Date;
|
||||
description?: string | null;
|
||||
id: string;
|
||||
identifier: string;
|
||||
isSystem: boolean;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
name: string;
|
||||
referenceUrl?: string | null;
|
||||
rubrics: EvalBenchmarkRubric[]; // Heavy field
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight benchmark item — excludes heavy fields, may add computed stats.
|
||||
*/
|
||||
export interface AgentEvalBenchmarkListItem {
|
||||
createdAt: Date;
|
||||
description?: string | null;
|
||||
id: string;
|
||||
identifier: string;
|
||||
isSystem: boolean;
|
||||
name: string;
|
||||
// Note: rubrics NOT included (heavy field)
|
||||
|
||||
// Computed statistics for UI display
|
||||
datasetCount?: number;
|
||||
runCount?: number;
|
||||
testCaseCount?: number;
|
||||
}
|
||||
```
|
||||
|
||||
## Example: Document Types (with heavy content)
|
||||
|
||||
```typescript
|
||||
// packages/types/src/document.ts
|
||||
|
||||
/**
|
||||
* Full document entity — includes heavy content fields.
|
||||
*/
|
||||
export interface Document {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
content: string; // Heavy field — full markdown content
|
||||
editorData: any; // Heavy field — editor state
|
||||
metadata?: Record<string, unknown>;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight document item — excludes heavy content.
|
||||
*/
|
||||
export interface DocumentListItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
// Note: content and editorData NOT included
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
// Computed statistics
|
||||
wordCount?: number;
|
||||
lastEditedBy?: string;
|
||||
}
|
||||
```
|
||||
|
||||
## Heavy Fields to Exclude from List
|
||||
|
||||
- Large text content (`content`, `editorData`, `fullDescription`)
|
||||
- Complex objects (`rubrics`, `config`, `metrics`)
|
||||
- Binary data (`image`, `file`)
|
||||
- Large arrays (`messages`, `items`)
|
||||
|
||||
The reason these belong only on Detail: list pages render many rows, so pulling heavy fields blows up payload size and slows render. Detail pages render one entity, so the full payload is fine.
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
name: testing
|
||||
description: Testing guide using Vitest. Use when writing tests (.test.ts, .test.tsx), fixing failing tests, improving test coverage, or debugging test issues. Triggers on test creation, test debugging, mock setup, or test-related questions.
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# LobeHub Testing Guide
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
name: trpc-router
|
||||
description: TRPC router development guide. Use when creating or modifying TRPC routers (src/server/routers/**), adding procedures, or working with server-side API endpoints. Triggers on TRPC router creation, procedure implementation, or API endpoint tasks.
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# TRPC Router Guide
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
name: typescript
|
||||
description: "TypeScript code style and type-safety guide for LobeHub. Read before writing or editing any `.ts` / `.tsx` / `.mts` — covers `interface` vs `type`, `Record<PropertyKey, unknown>` over `any`/`object`, `as const satisfies`, `@ts-expect-error` over `@ts-ignore`, `import type` (`separate-type-imports`), `async`/`await` + `Promise.all`, `for…of` over indexed `for`, and the no-silent-`.catch(() => fallback)` rule. Also use when reviewing type quality, deciding module augmentation (`declare module`) over `namespace`, or designing extensible types (e.g. `PipelineContext.metadata`). Triggers on any TypeScript file edit, 'fix the type', 'why is this `any`', 'should this be interface or type', 'eslint type-import', 'ts-expect-error'."
|
||||
user-invocable: false
|
||||
description: TypeScript code style and optimization guidelines. MUST READ before writing or modifying any TypeScript code (.ts, .tsx, .mts files). Also use when reviewing code quality or implementing type-safe patterns. Triggers on any TypeScript file edit, code style discussions, or type safety questions.
|
||||
---
|
||||
|
||||
# TypeScript Code Style Guide
|
||||
@@ -29,16 +28,12 @@ user-invocable: false
|
||||
## Imports
|
||||
|
||||
- This project uses `simple-import-sort/imports` and `consistent-type-imports` (`fixStyle: 'separate-type-imports'`)
|
||||
|
||||
- **Separate type imports**: always use `import type { ... }` for type-only imports, NOT `import { type ... }` inline syntax
|
||||
|
||||
- When a file already has `import type { ... }` from a package and you need to add a value import, keep them as **two separate statements**:
|
||||
|
||||
```ts
|
||||
import type { ChatTopicBotContext } from '@lobechat/types';
|
||||
import { RequestTrigger } from '@lobechat/types';
|
||||
```
|
||||
|
||||
- Within each import statement, specifiers are sorted **alphabetically by name**
|
||||
|
||||
## Code Structure
|
||||
@@ -47,7 +42,6 @@ user-invocable: false
|
||||
- Use consistent, descriptive naming; avoid obscure abbreviations
|
||||
- Replace magic numbers/strings with well-named constants
|
||||
- Defer formatting to tooling
|
||||
- Prefer **named exports** over `export default` — keeps refactor renames and IDE auto-import in sync, and avoids the `default` re-naming drift you get with `import Foo from './foo'`. Reserve `export default` for files where the framework requires it (Next.js page/route/layout, React.lazy targets, config files like `vitest.config.ts`)
|
||||
|
||||
## UI and Theming
|
||||
|
||||
@@ -57,6 +51,7 @@ user-invocable: false
|
||||
|
||||
## Performance
|
||||
|
||||
- Prefer `for…of` loops over index-based `for` loops
|
||||
- Reuse existing utils in `packages/utils` or installed npm packages
|
||||
- Query only required columns from database
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+8
-22
@@ -1,20 +1,6 @@
|
||||
# Cloud Project Workflow Configuration
|
||||
|
||||
Cloud-specific workflow configurations and patterns for the lobehub-cloud project.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#overview)
|
||||
2. [Directory Structure](#directory-structure) — submodule + cloud layout
|
||||
3. [Cloud-Specific Patterns](#cloud-specific-patterns) — cloud-only workflows + re-export pattern
|
||||
4. [TypeScript Path Mappings](#typescript-path-mappings)
|
||||
5. [Workflow Class Location](#workflow-class-location) — cloud-only vs shared
|
||||
6. [Environment Variables](#environment-variables)
|
||||
7. [Best Practices](#best-practices) — decide cloud vs OSS, re-export rules, naming
|
||||
8. [Migration Guide](#migration-guide) — moving workflows from cloud to lobehub
|
||||
9. [Examples](#examples) — `welcome-placeholder`, `agent-eval-run`
|
||||
10. [Troubleshooting](#troubleshooting) — circular imports, 404s, type errors
|
||||
11. [Related Documentation](#related-documentation)
|
||||
This document covers cloud-specific workflow configurations and patterns for the lobehub-cloud project.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -29,7 +15,7 @@ The lobehub-cloud project extends the open-source lobehub codebase with cloud-sp
|
||||
|
||||
### Lobehub Submodule (Open-source)
|
||||
|
||||
```text
|
||||
```
|
||||
lobehub/
|
||||
└── src/
|
||||
├── app/(backend)/api/workflows/
|
||||
@@ -42,7 +28,7 @@ lobehub/
|
||||
|
||||
### Lobehub-cloud (Proprietary)
|
||||
|
||||
```text
|
||||
```
|
||||
lobehub-cloud/
|
||||
└── src/
|
||||
├── app/(backend)/api/workflows/
|
||||
@@ -74,7 +60,7 @@ lobehub-cloud/
|
||||
|
||||
**Structure**:
|
||||
|
||||
```text
|
||||
```
|
||||
lobehub-cloud/src/
|
||||
├── app/(backend)/api/workflows/
|
||||
│ └── feature-name/
|
||||
@@ -176,7 +162,7 @@ This allows cloud to override specific modules while using lobehub defaults.
|
||||
|
||||
Place workflow class in cloud:
|
||||
|
||||
```text
|
||||
```
|
||||
lobehub-cloud/src/server/workflows/featureName/index.ts
|
||||
```
|
||||
|
||||
@@ -184,7 +170,7 @@ lobehub-cloud/src/server/workflows/featureName/index.ts
|
||||
|
||||
Place workflow class in lobehub, re-export in cloud if needed:
|
||||
|
||||
```text
|
||||
```
|
||||
lobehub/src/server/workflows/featureName/index.ts
|
||||
```
|
||||
|
||||
@@ -259,7 +245,7 @@ For shared features:
|
||||
|
||||
Follow consistent naming across lobehub and cloud:
|
||||
|
||||
```text
|
||||
```
|
||||
# Both should use same structure
|
||||
lobehub/src/app/(backend)/api/workflows/feature-name/
|
||||
lobehub-cloud/src/app/(backend)/api/workflows/feature-name/
|
||||
@@ -320,7 +306,7 @@ import { Workflow } from 'lobehub/src/server/workflows/feature';
|
||||
|
||||
**Structure**:
|
||||
|
||||
```text
|
||||
```
|
||||
lobehub-cloud/
|
||||
├── src/app/(backend)/api/workflows/welcome-placeholder/
|
||||
│ ├── process-users/route.ts
|
||||
@@ -1,226 +0,0 @@
|
||||
# Best Practices & Common Pitfalls
|
||||
|
||||
Apply these once your scaffold from `implementation.md` is in place.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Error Handling](#1-error-handling)
|
||||
2. [Logging](#2-logging)
|
||||
3. [Return Values](#3-return-values)
|
||||
4. [flowControl Configuration](#4-flowcontrol-configuration)
|
||||
5. [context.run() Best Practices](#5-contextrun-best-practices)
|
||||
6. [Payload Validation](#6-payload-validation)
|
||||
7. [Database Connection](#7-database-connection)
|
||||
8. [Testing](#8-testing)
|
||||
9. [Common Pitfalls](#common-pitfalls)
|
||||
|
||||
---
|
||||
|
||||
## 1. Error Handling
|
||||
|
||||
```typescript
|
||||
export const { POST } = serve<Payload>(
|
||||
async (context) => {
|
||||
const { itemId } = context.requestPayload ?? {};
|
||||
|
||||
if (!itemId) {
|
||||
return { success: false, error: 'Missing itemId in payload' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await context.run('step-name', () => doWork(itemId));
|
||||
return { success: true, itemId, result };
|
||||
} catch (error) {
|
||||
console.error('[workflow:error]', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
},
|
||||
{ flowControl: { ... } },
|
||||
);
|
||||
```
|
||||
|
||||
## 2. Logging
|
||||
|
||||
Consistent prefixes make debugging much easier across QStash dashboards and grep:
|
||||
|
||||
```typescript
|
||||
console.log('[{workflow}:{layer}] Starting with payload:', payload);
|
||||
console.log('[{workflow}:{layer}] Processing items:', { count: items.length });
|
||||
console.log('[{workflow}:{layer}] Completed:', result);
|
||||
console.error('[{workflow}:{layer}:error]', error);
|
||||
```
|
||||
|
||||
## 3. Return Values
|
||||
|
||||
Pick the shape that matches the layer's purpose — entry points return statistics, execution layers return per-item results.
|
||||
|
||||
```typescript
|
||||
// Success
|
||||
return { success: true, itemId, result, message: 'Optional success message' };
|
||||
|
||||
// Error
|
||||
return { success: false, error: 'Error description', itemId };
|
||||
|
||||
// Statistics (entry point)
|
||||
return {
|
||||
success: true,
|
||||
totalEligible: 100,
|
||||
toProcess: 80,
|
||||
alreadyProcessed: 20,
|
||||
dryRun: true, // if applicable
|
||||
message: 'Summary message',
|
||||
};
|
||||
```
|
||||
|
||||
## 4. flowControl Configuration
|
||||
|
||||
Tune concurrency by layer — entry points are singletons, execution layers fan out.
|
||||
|
||||
```typescript
|
||||
// Layer 1: Entry — single instance to avoid duplicate processing
|
||||
flowControl: { key: '{workflow}.process', parallelism: 1, ratePerSecond: 1 }
|
||||
|
||||
// Layer 2: Pagination — moderate concurrency
|
||||
flowControl: { key: '{workflow}.paginate', parallelism: 20, ratePerSecond: 5 }
|
||||
|
||||
// Layer 3: Execution — higher concurrency for parallel item work
|
||||
flowControl: { key: '{workflow}.execute', parallelism: 10, ratePerSecond: 5 }
|
||||
```
|
||||
|
||||
**Why these defaults:**
|
||||
|
||||
- **Layer 1** always uses `parallelism: 1` so concurrent triggers don't both start the same batch.
|
||||
- **Layer 2** can fan out widely (10-20) since pagination is cheap.
|
||||
- **Layer 3** caps at 5-10 by default; raise/lower based on external API rate limits.
|
||||
|
||||
## 5. context.run() Best Practices
|
||||
|
||||
- Use descriptive step names with prefixes: `{workflow}:step-name`
|
||||
- Each step should be idempotent (safe to retry)
|
||||
- Don't nest `context.run()` calls — keep them flat
|
||||
- Use unique step names when processing multiple items:
|
||||
|
||||
```typescript
|
||||
// ✅ Unique step names
|
||||
await Promise.all(
|
||||
items.map((item) => context.run(`{workflow}:execute:${item.id}`, () => processItem(item))),
|
||||
);
|
||||
|
||||
// ❌ Same step name — Upstash de-dupes by step name and you'll lose data
|
||||
await Promise.all(items.map((item) => context.run(`{workflow}:execute`, () => processItem(item))));
|
||||
```
|
||||
|
||||
## 6. Payload Validation
|
||||
|
||||
Validate at the top so failures are explicit, not silent `undefined` cascades:
|
||||
|
||||
```typescript
|
||||
export const { POST } = serve<Payload>(
|
||||
async (context) => {
|
||||
const { itemId, configId } = context.requestPayload ?? {};
|
||||
|
||||
if (!itemId) return { success: false, error: 'Missing itemId in payload' };
|
||||
if (!configId) return { success: false, error: 'Missing configId in payload' };
|
||||
|
||||
// Proceed with work...
|
||||
},
|
||||
{ flowControl: { ... } },
|
||||
);
|
||||
```
|
||||
|
||||
## 7. Database Connection
|
||||
|
||||
Get the connection once per workflow — `getServerDB()` is async, repeating it inside each step adds latency:
|
||||
|
||||
```typescript
|
||||
export const { POST } = serve<Payload>(
|
||||
async (context) => {
|
||||
const db = await getServerDB();
|
||||
|
||||
const item = await context.run('get-item', () => itemModel.findById(db, itemId));
|
||||
const result = await context.run('save-result', () => resultModel.create(db, result));
|
||||
},
|
||||
{ flowControl: { ... } },
|
||||
);
|
||||
```
|
||||
|
||||
## 8. Testing
|
||||
|
||||
Integration tests should exercise both the dry-run statistics path and the full execution path:
|
||||
|
||||
```typescript
|
||||
describe('WorkflowName', () => {
|
||||
it('should process items successfully', async () => {
|
||||
const items = await createTestItems();
|
||||
await WorkflowClass.triggerProcessItems({ dryRun: false });
|
||||
await waitForCompletion();
|
||||
const results = await getResults();
|
||||
expect(results).toHaveLength(items.length);
|
||||
});
|
||||
|
||||
it('should support dryRun mode', async () => {
|
||||
const result = await WorkflowClass.triggerProcessItems({ dryRun: true });
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
dryRun: true,
|
||||
totalEligible: expect.any(Number),
|
||||
toProcess: expect.any(Number),
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### ❌ Reusing `context.run()` step names
|
||||
|
||||
```typescript
|
||||
// Bad — Upstash dedupes by step name
|
||||
await Promise.all(items.map((item) => context.run('process', () => process(item))));
|
||||
|
||||
// Good
|
||||
await Promise.all(items.map((item) => context.run(`process:${item.id}`, () => process(item))));
|
||||
```
|
||||
|
||||
### ❌ Skipping payload validation
|
||||
|
||||
```typescript
|
||||
// Bad — undefined cascades into a confusing failure later
|
||||
const { itemId } = context.requestPayload ?? {};
|
||||
const result = await process(itemId);
|
||||
|
||||
// Good — fail fast with a clear error
|
||||
if (!itemId) return { success: false, error: 'Missing itemId' };
|
||||
```
|
||||
|
||||
### ❌ Skipping the filter step
|
||||
|
||||
```typescript
|
||||
// Bad — duplicates work for items that were already processed
|
||||
const allItems = await getAllItems();
|
||||
await Promise.all(allItems.map((item) => triggerExecute(item)));
|
||||
|
||||
// Good — keeps the pipeline idempotent
|
||||
const allItems = await getAllItems();
|
||||
const itemsNeedingProcessing = await filterExisting(allItems);
|
||||
await Promise.all(itemsNeedingProcessing.map((item) => triggerExecute(item)));
|
||||
```
|
||||
|
||||
### ❌ Inconsistent logging
|
||||
|
||||
```typescript
|
||||
// Bad — different prefixes, mixed formats
|
||||
console.log('Starting workflow');
|
||||
log.info('Processing item:', itemId);
|
||||
console.log(`Done with ${itemId}`);
|
||||
|
||||
// Good — uniform prefix lets you grep by workflow+layer
|
||||
console.log('[workflow:layer] Starting with payload:', payload);
|
||||
console.log('[workflow:layer] Processing item:', { itemId });
|
||||
console.log('[workflow:layer] Completed:', { itemId, result });
|
||||
```
|
||||
@@ -1,91 +0,0 @@
|
||||
# Worked Examples
|
||||
|
||||
Two real workflows already in the codebase that follow this skill's pattern verbatim. Skim them when you want to see the pattern applied to concrete entities.
|
||||
|
||||
## Example 1: Welcome Placeholder
|
||||
|
||||
**Use case:** Generate AI-powered welcome placeholders for users.
|
||||
|
||||
**Structure:**
|
||||
|
||||
- Layer 1: `process-users` — entry point, checks eligible users
|
||||
- Layer 2: `paginate-users` — paginates through active users
|
||||
- Layer 3: `generate-user` — generates placeholders for ONE user
|
||||
|
||||
**Key features:**
|
||||
|
||||
- Filters users who already have cached placeholders in Redis
|
||||
- `paidOnly` flag to scope to subscribed users
|
||||
- `dryRun` mode for statistics
|
||||
- Fan-out for large user batches (`CHUNK_SIZE=20`)
|
||||
|
||||
**Layer 3 shape:**
|
||||
|
||||
```typescript
|
||||
export const { POST } = serve<GenerateUserPlaceholderPayload>(async (context) => {
|
||||
const { userId } = context.requestPayload ?? {};
|
||||
|
||||
const workflow = new WelcomePlaceholderWorkflow(db, userId);
|
||||
const placeholders = await context.run('generate', () => workflow.generate());
|
||||
|
||||
return { success: true, userId, placeholdersCount: placeholders.length };
|
||||
});
|
||||
```
|
||||
|
||||
**Files:**
|
||||
|
||||
- `/api/workflows/welcome-placeholder/process-users/route.ts`
|
||||
- `/api/workflows/welcome-placeholder/paginate-users/route.ts`
|
||||
- `/api/workflows/welcome-placeholder/generate-user/route.ts`
|
||||
- `/server/workflows/welcomePlaceholder/index.ts`
|
||||
|
||||
---
|
||||
|
||||
## Example 2: Agent Welcome
|
||||
|
||||
**Use case:** Generate welcome messages and open questions for AI agents.
|
||||
|
||||
**Structure:**
|
||||
|
||||
- Layer 1: `process-agents` — entry point, checks eligible agents
|
||||
- Layer 2: `paginate-agents` — paginates through active agents
|
||||
- Layer 3: `generate-agent` — generates welcome data for ONE agent
|
||||
|
||||
**Key features:**
|
||||
|
||||
- Filters agents who already have cached data in Redis
|
||||
- `paidOnly` flag for subscribed users' agents only
|
||||
- `dryRun` mode for statistics
|
||||
- Fan-out for large agent batches (`CHUNK_SIZE=20`)
|
||||
|
||||
**Layer 3 shape:**
|
||||
|
||||
```typescript
|
||||
export const { POST } = serve<GenerateAgentWelcomePayload>(async (context) => {
|
||||
const { agentId } = context.requestPayload ?? {};
|
||||
|
||||
const workflow = new AgentWelcomeWorkflow(db, agentId);
|
||||
const data = await context.run('generate', () => workflow.generate());
|
||||
|
||||
return { success: true, agentId, data };
|
||||
});
|
||||
```
|
||||
|
||||
**Files:**
|
||||
|
||||
- `/api/workflows/agent-welcome/process-agents/route.ts`
|
||||
- `/api/workflows/agent-welcome/paginate-agents/route.ts`
|
||||
- `/api/workflows/agent-welcome/generate-agent/route.ts`
|
||||
- `/server/workflows/agentWelcome/index.ts`
|
||||
|
||||
---
|
||||
|
||||
## What's identical, what differs
|
||||
|
||||
Both workflows are the **same pattern** — they only differ in:
|
||||
|
||||
- Entity type (users vs agents)
|
||||
- Business logic (placeholder generation vs welcome generation)
|
||||
- Data source (different database queries)
|
||||
|
||||
Everything else — the 3-layer split, dry-run handling, fan-out, filter-existing, flowControl tuning — is identical. That's the whole point: once you internalize the pattern, adding a new workflow is mostly entity-substitution.
|
||||
@@ -1,333 +0,0 @@
|
||||
# Implementation Patterns
|
||||
|
||||
Full code templates for the 3-layer architecture. Read this when actually writing workflow files.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Workflow Class](#workflow-class) — `src/server/workflows/{workflowName}/index.ts`
|
||||
2. [Layer 1: Entry Point](#layer-1-entry-point-process-) — `process-*` route
|
||||
3. [Layer 2: Pagination](#layer-2-pagination-paginate-) — `paginate-*` route
|
||||
4. [Layer 3: Execution](#layer-3-execution-execute--generate-) — `execute-*` / `generate-*` route
|
||||
|
||||
---
|
||||
|
||||
## Workflow Class
|
||||
|
||||
**Location:** `src/server/workflows/{workflowName}/index.ts`
|
||||
|
||||
```typescript
|
||||
import { Client } from '@upstash/workflow';
|
||||
import debug from 'debug';
|
||||
|
||||
const log = debug('lobe-server:workflows:{workflow-name}');
|
||||
|
||||
// Workflow paths
|
||||
const WORKFLOW_PATHS = {
|
||||
processItems: '/api/workflows/{workflow-name}/process-items',
|
||||
paginateItems: '/api/workflows/{workflow-name}/paginate-items',
|
||||
executeItem: '/api/workflows/{workflow-name}/execute-item',
|
||||
} as const;
|
||||
|
||||
// Payload types
|
||||
export interface ProcessItemsPayload {
|
||||
dryRun?: boolean;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface PaginateItemsPayload {
|
||||
cursor?: string;
|
||||
itemIds?: string[]; // For fanout chunks
|
||||
}
|
||||
|
||||
export interface ExecuteItemPayload {
|
||||
itemId: string;
|
||||
}
|
||||
|
||||
const getWorkflowUrl = (path: string): string => {
|
||||
const baseUrl = process.env.APP_URL;
|
||||
if (!baseUrl) throw new Error('APP_URL is required to trigger workflows');
|
||||
return new URL(path, baseUrl).toString();
|
||||
};
|
||||
|
||||
const getWorkflowClient = (): Client => {
|
||||
const token = process.env.QSTASH_TOKEN;
|
||||
if (!token) throw new Error('QSTASH_TOKEN is required to trigger workflows');
|
||||
|
||||
const config: ConstructorParameters<typeof Client>[0] = { token };
|
||||
if (process.env.QSTASH_URL) {
|
||||
(config as Record<string, unknown>).url = process.env.QSTASH_URL;
|
||||
}
|
||||
return new Client(config);
|
||||
};
|
||||
|
||||
export class {WorkflowName}Workflow {
|
||||
private static client: Client;
|
||||
|
||||
private static getClient(): Client {
|
||||
if (!this.client) this.client = getWorkflowClient();
|
||||
return this.client;
|
||||
}
|
||||
|
||||
static triggerProcessItems(payload: ProcessItemsPayload) {
|
||||
const url = getWorkflowUrl(WORKFLOW_PATHS.processItems);
|
||||
log('Triggering process-items workflow');
|
||||
return this.getClient().trigger({ body: payload, url });
|
||||
}
|
||||
|
||||
static triggerPaginateItems(payload: PaginateItemsPayload) {
|
||||
const url = getWorkflowUrl(WORKFLOW_PATHS.paginateItems);
|
||||
log('Triggering paginate-items workflow');
|
||||
return this.getClient().trigger({ body: payload, url });
|
||||
}
|
||||
|
||||
static triggerExecuteItem(payload: ExecuteItemPayload) {
|
||||
const url = getWorkflowUrl(WORKFLOW_PATHS.executeItem);
|
||||
log('Triggering execute-item workflow: %s', payload.itemId);
|
||||
return this.getClient().trigger({ body: payload, url });
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter items that need processing (e.g. check Redis cache, database state).
|
||||
* Return only the ones that actually need work — keeps the pipeline idempotent.
|
||||
*/
|
||||
static async filterItemsNeedingProcessing(itemIds: string[]): Promise<string[]> {
|
||||
if (itemIds.length === 0) return [];
|
||||
// Check existing state and return items that need processing
|
||||
return itemIds;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Layer 1: Entry Point (process-\*)
|
||||
|
||||
**Purpose:** Validates prerequisites, calculates statistics, supports dry-run mode.
|
||||
|
||||
```typescript
|
||||
import { serve } from '@upstash/workflow/nextjs';
|
||||
import { getServerDB } from '@/database/server';
|
||||
import { WorkflowClass, type ProcessPayload } from '@/server/workflows/{workflowName}';
|
||||
|
||||
export const { POST } = serve<ProcessPayload>(
|
||||
async (context) => {
|
||||
const { dryRun, force } = context.requestPayload ?? {};
|
||||
|
||||
console.log('[{workflow}:process] Starting with payload:', { dryRun, force });
|
||||
|
||||
const allItemIds = await context.run('{workflow}:get-all-items', async () => {
|
||||
const db = await getServerDB();
|
||||
// Query database for eligible items
|
||||
return items.map((item) => item.id);
|
||||
});
|
||||
|
||||
console.log('[{workflow}:process] Total eligible items:', allItemIds.length);
|
||||
|
||||
if (allItemIds.length === 0) {
|
||||
return { success: true, totalEligible: 0, message: 'No eligible items found' };
|
||||
}
|
||||
|
||||
const itemsNeedingProcessing = await context.run('{workflow}:filter-existing', () =>
|
||||
WorkflowClass.filterItemsNeedingProcessing(allItemIds),
|
||||
);
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
totalEligible: allItemIds.length,
|
||||
toProcess: itemsNeedingProcessing.length,
|
||||
alreadyProcessed: allItemIds.length - itemsNeedingProcessing.length,
|
||||
};
|
||||
|
||||
// Dry-run short-circuits before any side effects
|
||||
if (dryRun) {
|
||||
console.log('[{workflow}:process] Dry run mode, returning statistics only');
|
||||
return {
|
||||
...result,
|
||||
dryRun: true,
|
||||
message: `[DryRun] Would process ${itemsNeedingProcessing.length} items`,
|
||||
};
|
||||
}
|
||||
|
||||
if (itemsNeedingProcessing.length === 0) {
|
||||
return { ...result, message: 'All items already processed' };
|
||||
}
|
||||
|
||||
await context.run('{workflow}:trigger-paginate', () => WorkflowClass.triggerPaginateItems({}));
|
||||
|
||||
return {
|
||||
...result,
|
||||
message: `Triggered pagination for ${itemsNeedingProcessing.length} items`,
|
||||
};
|
||||
},
|
||||
{
|
||||
flowControl: {
|
||||
key: '{workflow}.process',
|
||||
parallelism: 1, // single instance — avoids duplicate processing
|
||||
ratePerSecond: 1,
|
||||
},
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Layer 2: Pagination (paginate-\*)
|
||||
|
||||
**Purpose:** Handles cursor-based pagination, implements fan-out for large batches.
|
||||
|
||||
```typescript
|
||||
import { serve } from '@upstash/workflow/nextjs';
|
||||
import { chunk } from 'es-toolkit/compat';
|
||||
import { getServerDB } from '@/database/server';
|
||||
import { WorkflowClass, type PaginatePayload } from '@/server/workflows/{workflowName}';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const CHUNK_SIZE = 20;
|
||||
|
||||
export const { POST } = serve<PaginatePayload>(
|
||||
async (context) => {
|
||||
const { cursor, itemIds: payloadItemIds } = context.requestPayload ?? {};
|
||||
|
||||
console.log('[{workflow}:paginate] Starting:', {
|
||||
cursor,
|
||||
itemIdsCount: payloadItemIds?.length ?? 0,
|
||||
});
|
||||
|
||||
// If specific itemIds were passed in (from a fanout chunk), process them directly
|
||||
if (payloadItemIds && payloadItemIds.length > 0) {
|
||||
await Promise.all(
|
||||
payloadItemIds.map((itemId) =>
|
||||
context.run(`{workflow}:execute:${itemId}`, () =>
|
||||
WorkflowClass.triggerExecuteItem({ itemId }),
|
||||
),
|
||||
),
|
||||
);
|
||||
return { success: true, processedItems: payloadItemIds.length };
|
||||
}
|
||||
|
||||
// Paginate through all items
|
||||
const itemBatch = await context.run('{workflow}:get-batch', async () => {
|
||||
const db = await getServerDB();
|
||||
const items = await db.query(...);
|
||||
if (!items.length) return { ids: [] };
|
||||
const last = items.at(-1);
|
||||
return {
|
||||
ids: items.map((item) => item.id),
|
||||
cursor: last ? last.id : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const batchItemIds = itemBatch.ids;
|
||||
const nextCursor = 'cursor' in itemBatch ? itemBatch.cursor : undefined;
|
||||
|
||||
if (batchItemIds.length === 0) {
|
||||
return { success: true, message: 'Pagination complete' };
|
||||
}
|
||||
|
||||
const itemIds = await context.run('{workflow}:filter-existing', () =>
|
||||
WorkflowClass.filterItemsNeedingProcessing(batchItemIds),
|
||||
);
|
||||
|
||||
if (itemIds.length > 0) {
|
||||
if (itemIds.length > CHUNK_SIZE) {
|
||||
// Fan out — recursively re-enter pagination with each chunk
|
||||
const chunks = chunk(itemIds, CHUNK_SIZE);
|
||||
console.log('[{workflow}:paginate] Fanout mode:', {
|
||||
chunks: chunks.length,
|
||||
chunkSize: CHUNK_SIZE,
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
chunks.map((ids, idx) =>
|
||||
context.run(`{workflow}:fanout:${idx + 1}/${chunks.length}`, () =>
|
||||
WorkflowClass.triggerPaginateItems({ itemIds: ids }),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Process this page directly
|
||||
await Promise.all(
|
||||
itemIds.map((itemId) =>
|
||||
context.run(`{workflow}:execute:${itemId}`, () =>
|
||||
WorkflowClass.triggerExecuteItem({ itemId }),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Tail-call into the next page
|
||||
if (nextCursor) {
|
||||
await context.run('{workflow}:next-page', () =>
|
||||
WorkflowClass.triggerPaginateItems({ cursor: nextCursor }),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
processedItems: itemIds.length,
|
||||
skippedItems: batchItemIds.length - itemIds.length,
|
||||
nextCursor: nextCursor ?? null,
|
||||
};
|
||||
},
|
||||
{
|
||||
flowControl: {
|
||||
key: '{workflow}.paginate',
|
||||
parallelism: 20,
|
||||
ratePerSecond: 5,
|
||||
},
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Layer 3: Execution (execute-\* / generate-\*)
|
||||
|
||||
**Purpose:** Performs the actual business logic for exactly ONE item.
|
||||
|
||||
```typescript
|
||||
import { serve } from '@upstash/workflow/nextjs';
|
||||
import { getServerDB } from '@/database/server';
|
||||
import { WorkflowClass, type ExecutePayload } from '@/server/workflows/{workflowName}';
|
||||
|
||||
export const { POST } = serve<ExecutePayload>(
|
||||
async (context) => {
|
||||
const { itemId } = context.requestPayload ?? {};
|
||||
|
||||
if (!itemId) {
|
||||
return { success: false, error: 'Missing itemId' };
|
||||
}
|
||||
|
||||
const db = await getServerDB();
|
||||
|
||||
const item = await context.run('{workflow}:get-item', async () => {
|
||||
// Query database for item
|
||||
return item;
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
return { success: false, error: 'Item not found' };
|
||||
}
|
||||
|
||||
const result = await context.run('{workflow}:process-item', async () => {
|
||||
const workflow = new WorkflowClass(db, itemId);
|
||||
return workflow.generate(); // or process(), execute(), etc.
|
||||
});
|
||||
|
||||
await context.run('{workflow}:save-result', async () => {
|
||||
const workflow = new WorkflowClass(db, itemId);
|
||||
return workflow.saveToRedis(result); // or saveToDatabase(), etc.
|
||||
});
|
||||
|
||||
return { success: true, itemId, result };
|
||||
},
|
||||
{
|
||||
flowControl: {
|
||||
key: '{workflow}.execute',
|
||||
parallelism: 10,
|
||||
ratePerSecond: 5,
|
||||
},
|
||||
},
|
||||
);
|
||||
```
|
||||
@@ -1,14 +1,10 @@
|
||||
---
|
||||
name: version-release
|
||||
description: "Version release workflow. Use when the user mentions 'release', 'hotfix', 'version upgrade', 'weekly release', or '发版'/'发布'/'小班车'. This skill is for release process and GitHub Release notes (not docs/changelog page writing)."
|
||||
disable-model-invocation: true
|
||||
argument-hint: '[minor|patch] [version?]'
|
||||
---
|
||||
|
||||
# Version Release Workflow
|
||||
|
||||
This skill is a router. The detailed steps live in `references/`.
|
||||
|
||||
## Scope Boundary (Important)
|
||||
|
||||
This skill is only for:
|
||||
@@ -32,12 +28,68 @@ 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 | Reference |
|
||||
| ----- | ---------------------------------------------- | --------------------- | -------------- | ------------------------------------ | ------------- | --------------------------------------- |
|
||||
| Minor | Feature iteration release | \~Every 4 weeks | canary | `🚀 release: v{x.y.0}` | Manually set | `references/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 | `references/patch-release-scenarios.md` |
|
||||
| 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 |
|
||||
|
||||
For writing the release-note body (any release type), see `references/release-notes-style.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
|
||||
```
|
||||
|
||||
## Auto-Release Trigger Rules (`auto-tag-release.yml`)
|
||||
|
||||
@@ -75,7 +127,7 @@ PRs that don't match any conditions above (e.g. `docs`, `chore`, `ci`, `test`) w
|
||||
|
||||
When the user requests a release:
|
||||
|
||||
### Precheck (applies to all release types)
|
||||
### Precheck
|
||||
|
||||
Before creating the release branch, verify the source branch:
|
||||
|
||||
@@ -83,18 +135,204 @@ 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
|
||||
|
||||
### Routing
|
||||
### Minor Release
|
||||
|
||||
Pick the right reference and follow it end-to-end:
|
||||
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
|
||||
|
||||
- **Minor release** → `references/minor-release.md`
|
||||
- **Patch release** (weekly / hotfix / model launch / DB migration) → `references/patch-release-scenarios.md`
|
||||
- **Writing the PR body / release notes** (any release type) → `references/release-notes-style.md`
|
||||
### Patch Release
|
||||
|
||||
### Hard Rules (apply to every release type)
|
||||
Choose workflow by scenario (see `reference/patch-release-scenarios.md`):
|
||||
|
||||
- **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 `references/release-notes-style.md` § Computing Inputs — never from memory or descriptions.
|
||||
- **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 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.
|
||||
|
||||
### 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 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
|
||||
|
||||
- [ ] 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
|
||||
|
||||
+3
-7
@@ -21,16 +21,12 @@ 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 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
|
||||
git log main..canary --oneline
|
||||
git diff main...canary --stat
|
||||
```
|
||||
|
||||
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.
|
||||
Write a user-facing changelog following the format in `patch-release-changelog-example.md`.
|
||||
|
||||
3. **Create PR to main** with the changelog as the PR body
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,330 +0,0 @@
|
||||
# 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).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Positioning](#positioning) — what this style optimizes for
|
||||
2. [Required Inputs Before Writing](#required-inputs-before-writing)
|
||||
3. [Computing Inputs (Hard Rules — Verify, Never Guess)](#computing-inputs-hard-rules--verify-never-guess) — base ref, PR refs, metrics, authors, pre-publish verification
|
||||
4. [Canonical Structure (Long-Form: Minor / Weekly)](#canonical-structure-long-form-minor--weekly)
|
||||
5. [Variants for Shorter Releases](#variants-for-shorter-releases) — hotfix, DB migration
|
||||
6. [Writing Rules (Hard)](#writing-rules-hard)
|
||||
7. [Style Rules (Long-Form)](#style-rules-long-form)
|
||||
8. [Release Size Heuristics](#release-size-heuristics) — when to use which variant
|
||||
9. [Contributor Ordering](#contributor-ordering)
|
||||
10. [Template](#template) — copy-paste skeleton
|
||||
11. [Quick Checklist](#quick-checklist) — long-form + hotfix
|
||||
|
||||
## 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
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
name: zustand
|
||||
description: "LobeHub Zustand store conventions: public/internal/dispatch action layers, optimistic update pattern, slice composition via `flattenActions`, and class-based action migration. Use whenever working under `src/store/**`, adding a `createXxxSlice`, writing `internal_*` or `internal_dispatch*` actions, designing `messagesMap`/`topicsMap` reducers, refactoring a `StateCreator` object slice into a `XxxActionImpl` class, or debugging stale store reads. Triggers on `useChatStore`/`useUserStore`/`useGlobalStore`, `createStore`, `flattenActions`, `StoreSetter`, `internal_dispatch`, 'add an action', 'zustand selector', 'store slice', 'class action', 'optimistic update'."
|
||||
user-invocable: false
|
||||
description: Zustand state management guide. Use when working with store code (src/store/**), implementing actions, managing state, or creating slices. Triggers on Zustand store development, state management questions, or action implementation.
|
||||
---
|
||||
|
||||
# LobeHub Zustand State Management
|
||||
|
||||
+11
-20
@@ -56,6 +56,7 @@ 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
|
||||
@@ -70,6 +71,7 @@ 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
|
||||
@@ -77,16 +79,19 @@ 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
|
||||
@@ -96,11 +101,13 @@ 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
|
||||
@@ -161,6 +168,7 @@ OPENAI_API_KEY=sk-xxxxxxxxx
|
||||
|
||||
# SILICONCLOUD_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
|
||||
# ## TencentCloud AI ####
|
||||
|
||||
# TENCENT_CLOUD_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
@@ -173,6 +181,7 @@ OPENAI_API_KEY=sk-xxxxxxxxx
|
||||
|
||||
# INFINIAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
|
||||
# ## 302.AI ###
|
||||
|
||||
# AI302_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
@@ -213,6 +222,7 @@ OPENAI_API_KEY=sk-xxxxxxxxx
|
||||
|
||||
# VERCELAIGATEWAY_API_KEY=your_vercel_ai_gateway_api_key
|
||||
|
||||
|
||||
# #######################################
|
||||
# ########### Market Service ############
|
||||
# #######################################
|
||||
@@ -273,6 +283,7 @@ OPENAI_API_KEY=sk-xxxxxxxxx
|
||||
# but some service providers may require configuration
|
||||
# S3_REGION=us-west-1
|
||||
|
||||
|
||||
# #######################################
|
||||
# ########### Auth Service ##############
|
||||
# #######################################
|
||||
@@ -413,23 +424,3 @@ 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
|
||||
|
||||
@@ -148,6 +148,3 @@ apps/desktop/resources/cli-package.json
|
||||
.superpowers/
|
||||
docs/superpowers/
|
||||
.heerogeneous-tracing
|
||||
|
||||
# Kagura agent runtime
|
||||
.kagura/
|
||||
|
||||
@@ -2,43 +2,6 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
## [Version 2.1.57](https://github.com/lobehub/lobe-chat/compare/v2.1.57-canary.33...v2.1.57)
|
||||
|
||||
<sup>Released on **2026-05-09**</sup>
|
||||
|
||||
#### 🐛 Bug Fixes
|
||||
|
||||
- **docker**: replace pnpm init with static package.json in /deps.
|
||||
- **onboarding**: guard skip/mode-switch footer with feature flag, desktop & init checks.
|
||||
- **misc**: hide runtime-only model aliases.
|
||||
|
||||
#### ✨ Features
|
||||
|
||||
- **misc**: set OSS default model to DeepSeek V4 Pro.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's fixed
|
||||
|
||||
- **docker**: replace pnpm init with static package.json in /deps, closes [#14576](https://github.com/lobehub/lobe-chat/issues/14576) ([8ed31df](https://github.com/lobehub/lobe-chat/commit/8ed31df))
|
||||
- **onboarding**: guard skip/mode-switch footer with feature flag, desktop & init checks, closes [#14560](https://github.com/lobehub/lobe-chat/issues/14560) ([9756dab](https://github.com/lobehub/lobe-chat/commit/9756dab))
|
||||
- **misc**: hide runtime-only model aliases, closes [#14552](https://github.com/lobehub/lobe-chat/issues/14552) ([2d33322](https://github.com/lobehub/lobe-chat/commit/2d33322))
|
||||
|
||||
#### What's improved
|
||||
|
||||
- **misc**: set OSS default model to DeepSeek V4 Pro, closes [#14555](https://github.com/lobehub/lobe-chat/issues/14555) ([8105fc0](https://github.com/lobehub/lobe-chat/commit/8105fc0))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 2.1.56](https://github.com/lobehub/lobe-chat/compare/v2.1.55...v2.1.56)
|
||||
|
||||
<sup>Released on **2026-05-01**</sup>
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ RUN set -e && \
|
||||
pnpm i && \
|
||||
mkdir -p /deps && \
|
||||
cd /deps && \
|
||||
echo '{"name":"deps","private":true}' > package.json && \
|
||||
pnpm init && \
|
||||
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.15" "User Commands"
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.11" "User Commands"
|
||||
.SH NAME
|
||||
lh \- LobeHub CLI \- manage and connect to LobeHub services
|
||||
.SH SYNOPSIS
|
||||
@@ -68,6 +68,9 @@ Manage agent groups
|
||||
.B bot
|
||||
Manage bot integrations
|
||||
.TP
|
||||
.B cron
|
||||
Manage agent cron jobs
|
||||
.TP
|
||||
.B generate
|
||||
Generate content (text, image, video, speech) Alias: gen.
|
||||
.TP
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lobehub/cli",
|
||||
"version": "0.0.15",
|
||||
"version": "0.0.11",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"lh": "./dist/index.js",
|
||||
|
||||
@@ -318,7 +318,7 @@ export function registerAgentCommand(program: Command) {
|
||||
}
|
||||
|
||||
// 1. Exec agent to get operationId
|
||||
const input: Record<string, any> = { prompt: options.prompt, trigger: 'cli' };
|
||||
const input: Record<string, any> = { prompt: options.prompt };
|
||||
if (options.agentId) input.agentId = options.agentId;
|
||||
if (deviceId) input.deviceId = deviceId;
|
||||
if (options.slug) input.slug = options.slug;
|
||||
|
||||
@@ -55,7 +55,7 @@ export function registerBriefCommand(program: Command) {
|
||||
typeBadge(b.type, b.priority),
|
||||
truncate(b.title, 40),
|
||||
truncate(b.summary, 50),
|
||||
b.taskId ? pc.dim(b.taskId) : '-',
|
||||
b.taskId ? pc.dim(b.taskId) : b.cronJobId ? pc.dim(b.cronJobId) : '-',
|
||||
b.resolvedAt ? pc.green('resolved') : b.readAt ? pc.dim('read') : 'new',
|
||||
timeAgo(b.createdAt),
|
||||
]);
|
||||
@@ -102,6 +102,7 @@ export function registerBriefCommand(program: Command) {
|
||||
console.log(`${pc.dim('Type:')} ${b.type} ${pc.dim('Created:')} ${timeAgo(b.createdAt)}`);
|
||||
if (b.agentId) console.log(`${pc.dim('Agent:')} ${b.agentId}`);
|
||||
if (b.taskId) console.log(`${pc.dim('Task:')} ${b.taskId}`);
|
||||
if (b.cronJobId) console.log(`${pc.dim('CronJob:')} ${b.cronJobId}`);
|
||||
if (b.topicId) console.log(`${pc.dim('Topic:')} ${b.topicId}`);
|
||||
console.log(`\n${b.summary}`);
|
||||
|
||||
@@ -120,14 +121,14 @@ export function registerBriefCommand(program: Command) {
|
||||
for (const a of actions) {
|
||||
const cmd =
|
||||
a.type === 'comment'
|
||||
? `lh brief resolve ${b.id} --action ${a.key} -m "message"`
|
||||
? `lh brief resolve ${b.id} --action ${a.key} -m "内容"`
|
||||
: `lh brief resolve ${b.id} --action ${a.key}`;
|
||||
console.log(` ${a.label} ${pc.dim(cmd)}`);
|
||||
}
|
||||
} else {
|
||||
console.log(pc.dim('Actions:'));
|
||||
console.log(pc.dim(` lh brief resolve ${b.id} # Approve`));
|
||||
console.log(pc.dim(` lh brief resolve ${b.id} --reply "revision notes" # Request revision`));
|
||||
console.log(pc.dim(` lh brief resolve ${b.id} # 确认通过`));
|
||||
console.log(pc.dim(` lh brief resolve ${b.id} --reply "修改意见" # 反馈修改`));
|
||||
}
|
||||
} else if ((b as any).resolvedComment) {
|
||||
console.log(`${pc.dim('Comment:')} ${(b as any).resolvedComment}`);
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { registerCronCommand } from './cron';
|
||||
|
||||
const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
mockTrpcClient: {
|
||||
agentCronJob: {
|
||||
batchUpdateStatus: { mutate: vi.fn() },
|
||||
create: { mutate: vi.fn() },
|
||||
delete: { mutate: vi.fn() },
|
||||
findById: { query: vi.fn() },
|
||||
getStats: { query: vi.fn() },
|
||||
list: { query: vi.fn() },
|
||||
resetExecutions: { mutate: vi.fn() },
|
||||
update: { mutate: vi.fn() },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({
|
||||
getTrpcClient: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient }));
|
||||
vi.mock('../utils/logger', () => ({
|
||||
log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() },
|
||||
setVerbose: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('cron command', () => {
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
|
||||
for (const method of Object.values(mockTrpcClient.agentCronJob)) {
|
||||
for (const fn of Object.values(method)) {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
function createProgram() {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerCronCommand(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
describe('list', () => {
|
||||
it('should list cron jobs', async () => {
|
||||
mockTrpcClient.agentCronJob.list.query.mockResolvedValue({
|
||||
data: [{ enabled: true, id: 'c1', name: 'Test Job', schedule: '* * * * *' }],
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'cron', 'list']);
|
||||
|
||||
expect(mockTrpcClient.agentCronJob.list.query).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should filter by agent-id', async () => {
|
||||
mockTrpcClient.agentCronJob.list.query.mockResolvedValue({ data: [] });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'cron', 'list', '--agent-id', 'a1']);
|
||||
|
||||
expect(mockTrpcClient.agentCronJob.list.query).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'a1' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('view', () => {
|
||||
it('should view cron job details', async () => {
|
||||
mockTrpcClient.agentCronJob.findById.query.mockResolvedValue({
|
||||
data: { enabled: true, id: 'c1', name: 'Test', schedule: '* * * * *' },
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'cron', 'view', 'c1']);
|
||||
|
||||
expect(mockTrpcClient.agentCronJob.findById.query).toHaveBeenCalledWith({ id: 'c1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a cron job', async () => {
|
||||
mockTrpcClient.agentCronJob.create.mutate.mockResolvedValue({ data: { id: 'c1' } });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'cron',
|
||||
'create',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'-s',
|
||||
'* * * * *',
|
||||
'-n',
|
||||
'My Job',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentCronJob.create.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'a1', cronPattern: '* * * * *', name: 'My Job' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete a cron job', async () => {
|
||||
mockTrpcClient.agentCronJob.delete.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'cron', 'delete', 'c1', '--yes']);
|
||||
|
||||
expect(mockTrpcClient.agentCronJob.delete.mutate).toHaveBeenCalledWith({ id: 'c1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggle', () => {
|
||||
it('should batch enable cron jobs', async () => {
|
||||
mockTrpcClient.agentCronJob.batchUpdateStatus.mutate.mockResolvedValue({
|
||||
data: { updatedCount: 2 },
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'cron', 'toggle', 'c1', 'c2', '--enable']);
|
||||
|
||||
expect(mockTrpcClient.agentCronJob.batchUpdateStatus.mutate).toHaveBeenCalledWith({
|
||||
enabled: true,
|
||||
ids: ['c1', 'c2'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should reset execution count', async () => {
|
||||
mockTrpcClient.agentCronJob.resetExecutions.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'cron', 'reset', 'c1', '--max', '100']);
|
||||
|
||||
expect(mockTrpcClient.agentCronJob.resetExecutions.mutate).toHaveBeenCalledWith({
|
||||
id: 'c1',
|
||||
newMaxExecutions: 100,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('stats', () => {
|
||||
it('should get stats', async () => {
|
||||
mockTrpcClient.agentCronJob.getStats.query.mockResolvedValue({
|
||||
data: { totalJobs: 5, totalExecutions: 100 },
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'cron', 'stats']);
|
||||
|
||||
expect(mockTrpcClient.agentCronJob.getStats.query).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { confirm, outputJson, printTable, timeAgo, truncate } from '../utils/format';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
export function registerCronCommand(program: Command) {
|
||||
const cron = program.command('cron').description('Manage agent cron jobs');
|
||||
|
||||
// ── list ──────────────────────────────────────────────
|
||||
|
||||
cron
|
||||
.command('list')
|
||||
.description('List cron jobs')
|
||||
.option('--agent-id <id>', 'Filter by agent ID')
|
||||
.option('--enabled', 'Only show enabled jobs')
|
||||
.option('--disabled', 'Only show disabled jobs')
|
||||
.option('-L, --limit <n>', 'Page size', '20')
|
||||
.option('--offset <n>', 'Offset', '0')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(
|
||||
async (options: {
|
||||
agentId?: string;
|
||||
disabled?: boolean;
|
||||
enabled?: boolean;
|
||||
json?: string | boolean;
|
||||
limit?: string;
|
||||
offset?: string;
|
||||
}) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const input: Record<string, any> = {};
|
||||
if (options.agentId) input.agentId = options.agentId;
|
||||
if (options.enabled) input.enabled = true;
|
||||
if (options.disabled) input.enabled = false;
|
||||
if (options.limit) input.limit = Number.parseInt(options.limit, 10);
|
||||
if (options.offset) input.offset = Number.parseInt(options.offset, 10);
|
||||
|
||||
const result = await client.agentCronJob.list.query(input as any);
|
||||
const items = (result as any).data ?? [];
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(items, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
console.log('No cron jobs found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = items.map((j: any) => [
|
||||
j.id || '',
|
||||
truncate(j.name || '', 30),
|
||||
j.schedule || '',
|
||||
j.enabled ? pc.green('enabled') : pc.dim('disabled'),
|
||||
`${j.executionCount ?? 0}/${j.maxExecutions ?? '∞'}`,
|
||||
j.updatedAt ? timeAgo(j.updatedAt) : '',
|
||||
]);
|
||||
|
||||
printTable(rows, ['ID', 'NAME', 'SCHEDULE', 'STATUS', 'EXECUTIONS', 'UPDATED']);
|
||||
},
|
||||
);
|
||||
|
||||
// ── view ──────────────────────────────────────────────
|
||||
|
||||
cron
|
||||
.command('view <id>')
|
||||
.description('View cron job details')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(async (id: string, options: { json?: string | boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.agentCronJob.findById.query({ id });
|
||||
const job = (result as any).data;
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(job, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!job) {
|
||||
log.error('Cron job not found.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`${pc.bold('ID:')} ${job.id}`);
|
||||
console.log(`${pc.bold('Name:')} ${job.name || ''}`);
|
||||
console.log(`${pc.bold('Agent ID:')} ${job.agentId || ''}`);
|
||||
console.log(`${pc.bold('Schedule:')} ${job.schedule || ''}`);
|
||||
console.log(
|
||||
`${pc.bold('Status:')} ${job.enabled ? pc.green('enabled') : pc.dim('disabled')}`,
|
||||
);
|
||||
console.log(
|
||||
`${pc.bold('Executions:')} ${job.executionCount ?? 0}/${job.maxExecutions ?? '∞'}`,
|
||||
);
|
||||
if (job.prompt) console.log(`${pc.bold('Prompt:')} ${truncate(job.prompt, 80)}`);
|
||||
if (job.createdAt) console.log(`${pc.bold('Created:')} ${timeAgo(job.createdAt)}`);
|
||||
if (job.updatedAt) console.log(`${pc.bold('Updated:')} ${timeAgo(job.updatedAt)}`);
|
||||
});
|
||||
|
||||
// ── create ────────────────────────────────────────────
|
||||
|
||||
cron
|
||||
.command('create')
|
||||
.description('Create a cron job')
|
||||
.requiredOption('--agent-id <id>', 'Agent ID')
|
||||
.requiredOption('-s, --schedule <cron>', 'Cron schedule expression')
|
||||
.option('-n, --name <name>', 'Job name')
|
||||
.option('-p, --prompt <prompt>', 'Prompt text')
|
||||
.option('--max-executions <n>', 'Maximum number of executions')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(
|
||||
async (options: {
|
||||
agentId: string;
|
||||
json?: boolean;
|
||||
maxExecutions?: string;
|
||||
name?: string;
|
||||
prompt?: string;
|
||||
schedule: string;
|
||||
}) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const input: Record<string, any> = {
|
||||
agentId: options.agentId,
|
||||
cronPattern: options.schedule,
|
||||
};
|
||||
if (options.name) input.name = options.name;
|
||||
if (options.prompt) input.content = options.prompt;
|
||||
if (options.maxExecutions) input.maxExecutions = Number.parseInt(options.maxExecutions, 10);
|
||||
|
||||
const result = await client.agentCronJob.create.mutate(input as any);
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (result as any).data;
|
||||
console.log(`${pc.green('✓')} Created cron job ${pc.bold(data?.id || '')}`);
|
||||
},
|
||||
);
|
||||
|
||||
// ── edit ───────────────────────────────────────────────
|
||||
|
||||
cron
|
||||
.command('edit <id>')
|
||||
.description('Update a cron job')
|
||||
.option('-n, --name <name>', 'Job name')
|
||||
.option('-s, --schedule <cron>', 'Cron schedule expression')
|
||||
.option('-p, --prompt <prompt>', 'Prompt text')
|
||||
.option('--max-executions <n>', 'Maximum number of executions')
|
||||
.option('--enable', 'Enable the job')
|
||||
.option('--disable', 'Disable the job')
|
||||
.action(
|
||||
async (
|
||||
id: string,
|
||||
options: {
|
||||
disable?: boolean;
|
||||
enable?: boolean;
|
||||
maxExecutions?: string;
|
||||
name?: string;
|
||||
prompt?: string;
|
||||
schedule?: string;
|
||||
},
|
||||
) => {
|
||||
const data: Record<string, any> = {};
|
||||
if (options.name) data.name = options.name;
|
||||
if (options.schedule) data.cronPattern = options.schedule;
|
||||
if (options.prompt) data.content = options.prompt;
|
||||
if (options.maxExecutions) data.maxExecutions = Number.parseInt(options.maxExecutions, 10);
|
||||
if (options.enable) data.enabled = true;
|
||||
if (options.disable) data.enabled = false;
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
log.error(
|
||||
'No changes specified. Use --name, --schedule, --prompt, --enable, or --disable.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
await client.agentCronJob.update.mutate({ data, id } as any);
|
||||
console.log(`${pc.green('✓')} Updated cron job ${pc.bold(id)}`);
|
||||
},
|
||||
);
|
||||
|
||||
// ── delete ────────────────────────────────────────────
|
||||
|
||||
cron
|
||||
.command('delete <id>')
|
||||
.description('Delete a cron job')
|
||||
.option('--yes', 'Skip confirmation prompt')
|
||||
.action(async (id: string, options: { yes?: boolean }) => {
|
||||
if (!options.yes) {
|
||||
const confirmed = await confirm('Are you sure you want to delete this cron job?');
|
||||
if (!confirmed) {
|
||||
console.log('Cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
await client.agentCronJob.delete.mutate({ id });
|
||||
console.log(`${pc.green('✓')} Deleted cron job ${pc.bold(id)}`);
|
||||
});
|
||||
|
||||
// ── toggle ────────────────────────────────────────────
|
||||
|
||||
cron
|
||||
.command('toggle <ids...>')
|
||||
.description('Batch enable or disable cron jobs')
|
||||
.option('--enable', 'Enable the jobs')
|
||||
.option('--disable', 'Disable the jobs')
|
||||
.action(async (ids: string[], options: { disable?: boolean; enable?: boolean }) => {
|
||||
if (!options.enable && !options.disable) {
|
||||
log.error('Specify --enable or --disable.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const enabled = !!options.enable;
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.agentCronJob.batchUpdateStatus.mutate({ enabled, ids });
|
||||
const count = (result as any).data?.updatedCount ?? ids.length;
|
||||
console.log(`${pc.green('✓')} ${enabled ? 'Enabled' : 'Disabled'} ${count} cron job(s)`);
|
||||
});
|
||||
|
||||
// ── reset ─────────────────────────────────────────────
|
||||
|
||||
cron
|
||||
.command('reset <id>')
|
||||
.description('Reset execution count for a cron job')
|
||||
.option('--max <n>', 'Set new max executions')
|
||||
.action(async (id: string, options: { max?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const input: Record<string, any> = { id };
|
||||
if (options.max) input.newMaxExecutions = Number.parseInt(options.max, 10);
|
||||
|
||||
await client.agentCronJob.resetExecutions.mutate(input as any);
|
||||
console.log(`${pc.green('✓')} Reset execution count for ${pc.bold(id)}`);
|
||||
});
|
||||
|
||||
// ── stats ─────────────────────────────────────────────
|
||||
|
||||
cron
|
||||
.command('stats')
|
||||
.description('Get cron job execution statistics')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(async (options: { json?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.agentCronJob.getStats.query();
|
||||
const stats = (result as any).data;
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(stats, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
console.log('No statistics available.');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(stats as Record<string, any>)) {
|
||||
console.log(`${pc.bold(key + ':')} ${value}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
+14
-120
@@ -10,10 +10,7 @@ import type {
|
||||
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']);
|
||||
|
||||
@@ -24,22 +21,7 @@ interface ExecOptions {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -189,35 +171,12 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
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);
|
||||
}
|
||||
|
||||
// Standalone (phase 1a): no server ingest, so the operationId is just an
|
||||
// identity stamp on the JSONL stream. Generate a fresh one if the caller
|
||||
// didn't provide --operation-id; phase 1b will require it as a real
|
||||
// server-allocated id.
|
||||
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
|
||||
@@ -244,93 +203,36 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
// 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 () => {
|
||||
const onSigint = () => {
|
||||
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
|
||||
}
|
||||
}
|
||||
});
|
||||
process.on('SIGTERM', () => handle.kill('SIGTERM'));
|
||||
|
||||
// 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;
|
||||
// Stream events out as JSONL on stdout. Each line is one `AgentStreamEvent`.
|
||||
// Use raw write (not console.log) so we don't pull in console formatting
|
||||
// and JSONL stays parseable downstream.
|
||||
try {
|
||||
for await (const event of handle.events) {
|
||||
if (emitJsonl) {
|
||||
process.stdout.write(`${JSON.stringify(event)}\n`);
|
||||
}
|
||||
ingester.push(event);
|
||||
process.stdout.write(`${JSON.stringify(event)}\n`);
|
||||
}
|
||||
} 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.
|
||||
// Pass the child's exit code through. Signal-induced exits (SIGINT etc.)
|
||||
// surface as `code === null` — map to 130 (POSIX convention for SIGINT).
|
||||
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 (code !== null) process.exit(code);
|
||||
if (signal === 'SIGINT') process.exit(130);
|
||||
if (signal === 'SIGTERM') process.exit(143);
|
||||
if (signal === 'SIGKILL') process.exit(137);
|
||||
@@ -366,15 +268,7 @@ export function registerHeteroCommand(program: Command) {
|
||||
)
|
||||
.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.',
|
||||
'Operation id stamped onto every emitted event. Generated as a uuid if omitted (phase 1a).',
|
||||
)
|
||||
.action(exec);
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@ function readAgentProfile(workspacePath: string): AgentProfile {
|
||||
// Try to extract **Emoji:** value (single emoji)
|
||||
const emojiMatch = content.match(/\*{0,2}Emoji:?\*{0,2}\s*(.+)/i);
|
||||
const rawAvatar = emojiMatch ? emojiMatch[1].trim() : undefined;
|
||||
// Filter out placeholder text like (TBD), _(TBD)_, N/A, and Chinese-language equivalents.
|
||||
// Filter out placeholder text like (待定)(Chinese TBD), _(待定)_, (TBD), N/A, etc.
|
||||
const isPlaceholder =
|
||||
rawAvatar && /^[_*((].*[))_*]$|^(?:tbd|todo|n\/?a|none|待定|未定)$/i.test(rawAvatar);
|
||||
const avatar = rawAvatar && !isPlaceholder ? rawAvatar : undefined;
|
||||
|
||||
@@ -83,23 +83,6 @@ 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,8 +5,6 @@ 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');
|
||||
|
||||
@@ -35,9 +33,7 @@ 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 ?? [])).filter(
|
||||
isVisibleModel,
|
||||
);
|
||||
let items = Array.isArray(result) ? result : ((result as any).items ?? []);
|
||||
|
||||
if (options.type) {
|
||||
items = items.filter((m: any) => m.type === options.type);
|
||||
|
||||
@@ -145,7 +145,7 @@ export function registerReviewCommands(task: Command) {
|
||||
|
||||
rc.command('add <id>')
|
||||
.description('Add a review rubric')
|
||||
.requiredOption('-n, --name <name>', 'Rubric name (e.g. "Content Accuracy")')
|
||||
.requiredOption('-n, --name <name>', 'Rubric name (e.g. "内容准确性")')
|
||||
.option('--type <type>', 'Rubric type (default: llm-rubric)', 'llm-rubric')
|
||||
.option('-t, --threshold <n>', 'Pass threshold 0-100 (converted to 0-1)')
|
||||
.option('-d, --description <text>', 'Criteria description (for llm-rubric type)')
|
||||
|
||||
@@ -8,6 +8,7 @@ import { registerBotCommand } from './commands/bot';
|
||||
import { registerCompletionCommand } from './commands/completion';
|
||||
import { registerConfigCommand } from './commands/config';
|
||||
import { registerConnectCommand } from './commands/connect';
|
||||
import { registerCronCommand } from './commands/cron';
|
||||
import { registerDeviceCommand } from './commands/device';
|
||||
import { registerDocCommand } from './commands/doc';
|
||||
import { registerEvalCommand } from './commands/eval';
|
||||
@@ -59,6 +60,7 @@ export function createProgram() {
|
||||
registerAgentCommand(program);
|
||||
registerAgentGroupCommand(program);
|
||||
registerBotCommand(program);
|
||||
registerCronCommand(program);
|
||||
registerGenerateCommand(program);
|
||||
registerFileCommand(program);
|
||||
registerHeteroCommand(program);
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,18 +7,18 @@ import { entryLocaleJsonFilepath, i18nConfig, localeDir, srcDefaultLocales } fro
|
||||
import { tagWhite, writeJSON } from './utils';
|
||||
|
||||
export const genDefaultLocale = () => {
|
||||
consola.info(`Default locale: ${i18nConfig.entryLocale}...`);
|
||||
consola.info(`默认语言为 ${i18nConfig.entryLocale}...`);
|
||||
|
||||
// Ensure entry locale directory exists
|
||||
const entryLocaleDir = localeDir(i18nConfig.entryLocale);
|
||||
if (!existsSync(entryLocaleDir)) {
|
||||
mkdirSync(entryLocaleDir, { recursive: true });
|
||||
consola.info(`Creating directory: ${entryLocaleDir}`);
|
||||
consola.info(`创建目录:${entryLocaleDir}`);
|
||||
}
|
||||
|
||||
const resources = require(srcDefaultLocales);
|
||||
const data = Object.entries(resources.default);
|
||||
consola.start(`Generating default locale JSON files, found ${data.length} namespaces...`);
|
||||
consola.start(`生成默认语言 JSON 文件,发现 ${data.length} 个命名空间...`);
|
||||
|
||||
for (const [ns, value] of data) {
|
||||
const filepath = entryLocaleJsonFilepath(`${ns}.json`);
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { readJSON, tagWhite, writeJSON } from './utils';
|
||||
|
||||
export const genDiff = () => {
|
||||
consola.start(`Comparing localization files between dev and prod environments...`);
|
||||
consola.start(`对比开发与生产环境中的本地化文件...`);
|
||||
|
||||
const resources = require(srcDefaultLocales);
|
||||
const data = Object.entries(resources.default);
|
||||
@@ -21,7 +21,7 @@ export const genDiff = () => {
|
||||
for (const [ns, devJSON] of data) {
|
||||
const filepath = entryLocaleJsonFilepath(`${ns}.json`);
|
||||
if (!existsSync(filepath)) {
|
||||
consola.info(`File does not exist, skipping: ${filepath}`);
|
||||
consola.info(`文件不存在,跳过:${filepath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export const genDiff = () => {
|
||||
}
|
||||
|
||||
if (clearLocals.length > 0) {
|
||||
consola.info('Cleaned up stale entries for the following locales:', clearLocals.join(', '));
|
||||
consola.info('清理了以下语言的过期项目:', clearLocals.join(', '));
|
||||
}
|
||||
consola.success(tagWhite(ns), colors.gray(filepath));
|
||||
}
|
||||
|
||||
@@ -21,15 +21,15 @@ const run = async () => {
|
||||
ensureLocalesDirs();
|
||||
|
||||
// Diff analysis
|
||||
split('Diff Analysis');
|
||||
split('差异分析');
|
||||
genDiff();
|
||||
|
||||
// Generate default locale files
|
||||
split('Generate Default Locale Files');
|
||||
split('生成默认语言文件');
|
||||
genDefaultLocale();
|
||||
|
||||
// Generate i18n files
|
||||
split('Generate i18n Files');
|
||||
split('生成国际化文件');
|
||||
};
|
||||
|
||||
run();
|
||||
|
||||
@@ -1,4 +1 @@
|
||||
export const ELECTRON_BE_PROTOCOL_SCHEME = 'lobe-backend';
|
||||
|
||||
export const LOCAL_FILE_PROTOCOL_SCHEME = 'localfile';
|
||||
export const LOCAL_FILE_PROTOCOL_HOST = 'file';
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
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';
|
||||
@@ -35,10 +33,6 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
return this.app.getController(ShellCommandCtr);
|
||||
}
|
||||
|
||||
private get heterogeneousAgentCtr() {
|
||||
return this.app.getController(HeterogeneousAgentCtr);
|
||||
}
|
||||
|
||||
// ─── Lifecycle ───
|
||||
|
||||
afterAppReady() {
|
||||
@@ -53,9 +47,6 @@ 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();
|
||||
}
|
||||
@@ -117,45 +108,6 @@ 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> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFile, spawn } from 'node:child_process';
|
||||
import { readFile, rm, stat } from 'node:fs/promises';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
GitBranchListItem,
|
||||
GitCheckoutResult,
|
||||
GitFileDiffStatus,
|
||||
GitFileRevertResult,
|
||||
GitLinkedPullRequestResult,
|
||||
GitPullResult,
|
||||
GitPushResult,
|
||||
@@ -1107,70 +1106,4 @@ export default class GitController extends ControllerModule {
|
||||
return { error: stderr || 'git push failed', success: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revert a single working-tree change. Mirrors what "Discard changes" does
|
||||
* in GitHub Desktop / VSCode SCM: restore the file to its HEAD state,
|
||||
* dropping any unstaged / staged edits — and physically delete the file
|
||||
* when it doesn't exist at HEAD (untracked or staged-add).
|
||||
*
|
||||
* Branch logic by HEAD presence:
|
||||
* - present at HEAD → `git checkout HEAD -- <file>` (covers modified,
|
||||
* deleted, staged-D — restores both index + worktree from HEAD)
|
||||
* - absent at HEAD → `git rm --cached` (unstage if staged-A; silent
|
||||
* no-op for untracked) + `fs.rm` to delete the file from disk
|
||||
*
|
||||
* filePath is the repo-relative path from `git status` output, the same
|
||||
* shape we hand to the renderer in `GitWorkingTreePatch.filePath`. We
|
||||
* reject absolute paths and `..` traversal so the renderer can't poke
|
||||
* outside the repo even if its payload were tampered with.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async revertGitFile(payload: { filePath: string; path: string }): Promise<GitFileRevertResult> {
|
||||
const { path: dirPath, filePath } = payload;
|
||||
if (!filePath?.trim()) return { error: 'File path is required', success: false };
|
||||
if (path.isAbsolute(filePath) || filePath.split(/[/\\]/).includes('..')) {
|
||||
return { error: `Invalid file path: ${filePath}`, success: false };
|
||||
}
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// Probe HEAD via cat-file -e — exit 0 means the blob exists at HEAD.
|
||||
let existsAtHead: boolean;
|
||||
try {
|
||||
await execFileAsync('git', ['cat-file', '-e', `HEAD:${filePath}`], {
|
||||
cwd: dirPath,
|
||||
timeout: 5000,
|
||||
});
|
||||
existsAtHead = true;
|
||||
} catch {
|
||||
existsAtHead = false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (existsAtHead) {
|
||||
await execFileAsync('git', ['checkout', 'HEAD', '--', filePath], {
|
||||
cwd: dirPath,
|
||||
timeout: 15_000,
|
||||
});
|
||||
} else {
|
||||
// Unstage if the file is in the index (staged-add). `git rm --cached`
|
||||
// exits non-zero on untracked paths, which is fine — swallow it.
|
||||
try {
|
||||
await execFileAsync('git', ['rm', '--cached', '--quiet', '--', filePath], {
|
||||
cwd: dirPath,
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch {
|
||||
// not staged — fall through to the disk-delete
|
||||
}
|
||||
await rm(path.resolve(dirPath, filePath), { force: true, recursive: false });
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
const stderr: string = (error?.stderr ?? error?.message ?? '').toString().trim();
|
||||
logger.debug('[revertGitFile] failed', { filePath, stderr });
|
||||
return { error: stderr || 'git revert failed', success: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { unlinkSync } from 'node:fs';
|
||||
import { access, appendFile, mkdir, unlink, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
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';
|
||||
@@ -16,8 +14,6 @@ import {
|
||||
CODEX_CLI_INSTALL_DOCS_URL,
|
||||
HeterogeneousAgentSessionErrorCode,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import type { AskUserBridge } from '@lobechat/heterogeneous-agents/askUser';
|
||||
import { AskUserMcpServer } from '@lobechat/heterogeneous-agents/askUser';
|
||||
import type { AgentContentBlock } from '@lobechat/heterogeneous-agents/spawn';
|
||||
import {
|
||||
AgentStreamPipeline,
|
||||
@@ -103,18 +99,6 @@ interface CancelSessionParams {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
interface SubmitInterventionParams {
|
||||
cancelled?: boolean;
|
||||
/** When set, signals user-cancelled or timeout — the bridge resolves with isError. */
|
||||
cancelReason?: 'timeout' | 'user_cancelled';
|
||||
/** Operation id stamped on the request the renderer is responding to. */
|
||||
operationId: string;
|
||||
/** Structured user answer; ignored when `cancelled` is true. */
|
||||
result?: unknown;
|
||||
/** Correlation key carried on the original `agent_intervention_request`. */
|
||||
toolCallId: string;
|
||||
}
|
||||
|
||||
interface StopSessionParams {
|
||||
sessionId: string;
|
||||
}
|
||||
@@ -166,28 +150,10 @@ interface CliTraceSession {
|
||||
*
|
||||
* Lifecycle: startSession → sendPrompt → (heteroAgentEvent broadcasts) → stopSession
|
||||
*/
|
||||
interface InterventionSlot {
|
||||
bridge: AskUserBridge;
|
||||
/** Resolves once bridge.events() iterator ends (after `cancelAll`). */
|
||||
pumpDone: Promise<void>;
|
||||
/** Path to the per-op temp `mcp.json` we wrote for `--mcp-config`. */
|
||||
tmpConfigPath: string;
|
||||
}
|
||||
|
||||
export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
static override readonly groupName = 'heterogeneousAgent';
|
||||
|
||||
private sessions = new Map<string, AgentSession>();
|
||||
/**
|
||||
* Per-operation AskUserQuestion bridge state. Keyed by `operationId` so the
|
||||
* `submitIntervention` IPC can route an answer to the right pending MCP
|
||||
* handler regardless of which `sessionId` it belongs to (one session can
|
||||
* fire many ops over its lifetime).
|
||||
*/
|
||||
private opIdToIntervention = new Map<string, InterventionSlot>();
|
||||
/** Lazy single MCP server, started on first claude-code prompt. */
|
||||
private askUserMcpServer?: AskUserMcpServer;
|
||||
private askUserMcpStartPromise?: Promise<AskUserMcpServer>;
|
||||
|
||||
private resolveSessionCommand(session: AgentSession): string {
|
||||
const resolvedCommand = session.command.trim();
|
||||
@@ -601,92 +567,6 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── AskUserQuestion MCP server (LOBE-8725) ───
|
||||
|
||||
/**
|
||||
* Lazy single-instance MCP server for CC's AskUserQuestion replacement.
|
||||
* First claude-code prompt triggers `start()`; subsequent prompts reuse
|
||||
* the same listener. Concurrent first-callers de-dupe via the in-flight
|
||||
* promise so we don't bind two ports.
|
||||
*/
|
||||
private async ensureAskUserMcpServerStarted(): Promise<AskUserMcpServer> {
|
||||
if (this.askUserMcpServer) return this.askUserMcpServer;
|
||||
if (!this.askUserMcpStartPromise) {
|
||||
this.askUserMcpStartPromise = (async () => {
|
||||
const server = new AskUserMcpServer();
|
||||
await server.start();
|
||||
this.askUserMcpServer = server;
|
||||
logger.info('AskUserQuestion MCP server started:', server.url);
|
||||
return server;
|
||||
})().catch((err) => {
|
||||
// Reset so a later sendPrompt can retry; surface the error.
|
||||
this.askUserMcpStartPromise = undefined;
|
||||
logger.error('Failed to start AskUserQuestion MCP server:', err);
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return this.askUserMcpStartPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a per-op AskUserQuestion bridge, write its temp `mcp.json`,
|
||||
* and start pumping the bridge's outbound events into the regular
|
||||
* `heteroAgentEvent` broadcast. Caller must invoke the returned cleanup
|
||||
* after the spawn finishes (success, error, or cancel) to remove the
|
||||
* temp file and tear down the bridge.
|
||||
*
|
||||
* Pump errors are logged but never thrown — they don't fail the spawn.
|
||||
*/
|
||||
private async setupInterventionForOp(
|
||||
operationId: string,
|
||||
sessionId: string,
|
||||
): Promise<{ cleanup: () => Promise<void>; tmpConfigPath: string }> {
|
||||
const server = await this.ensureAskUserMcpServerStarted();
|
||||
const bridge = server.registerOperation(operationId);
|
||||
const tmpConfigPath = path.join(os.tmpdir(), `lobe-cc-mcp-${operationId}.json`);
|
||||
|
||||
// `alwaysLoad: true` is the undocumented CC flag that promotes our
|
||||
// server's tool out of the deferred set so the model calls it directly
|
||||
// (no ToolSearch hop). See LOBE-8725 spike notes — falls back to the
|
||||
// 2-hop ToolSearch path if a future CC drops the flag, no breakage.
|
||||
const config = {
|
||||
mcpServers: {
|
||||
lobe_cc: {
|
||||
alwaysLoad: true,
|
||||
type: 'http' as const,
|
||||
url: server.urlForOperation(operationId),
|
||||
},
|
||||
},
|
||||
};
|
||||
await writeFile(tmpConfigPath, JSON.stringify(config), 'utf8');
|
||||
|
||||
// Pump bridge.events() into the `heteroAgentEvent` broadcast. The
|
||||
// iterator only ends after `cancelAll()`, so `pumpDone` resolves at
|
||||
// cleanup time and gates teardown.
|
||||
const pumpDone = (async () => {
|
||||
for await (const event of bridge.events()) {
|
||||
this.broadcast('heteroAgentEvent', { event, sessionId });
|
||||
}
|
||||
})().catch((err) => {
|
||||
logger.warn('AskUserQuestion bridge pump error:', err);
|
||||
});
|
||||
|
||||
this.opIdToIntervention.set(operationId, { bridge, pumpDone, tmpConfigPath });
|
||||
|
||||
const cleanup = async () => {
|
||||
// Unregistering on the server cancels all bridge pendings AND closes
|
||||
// the events iterator (cancelAll fires from within unregisterOperation).
|
||||
this.askUserMcpServer?.unregisterOperation(operationId);
|
||||
await pumpDone;
|
||||
this.opIdToIntervention.delete(operationId);
|
||||
await unlink(tmpConfigPath).catch(() => {
|
||||
/* file may already be gone if app crashed mid-prompt */
|
||||
});
|
||||
};
|
||||
|
||||
return { cleanup, tmpConfigPath };
|
||||
}
|
||||
|
||||
// ─── File cache ───
|
||||
|
||||
private get fileCacheDir(): string {
|
||||
@@ -817,58 +697,32 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
throw new Error(preflightError.message);
|
||||
}
|
||||
|
||||
// Stand up the AskUserQuestion MCP bridge for claude-code prompts BEFORE
|
||||
// building the spawn plan so the driver can wire the temp config path
|
||||
// into `--mcp-config`. Codex / future agents skip this entirely.
|
||||
const intervention =
|
||||
session.agentType === 'claude-code'
|
||||
? await this.setupInterventionForOp(params.operationId, session.sessionId).catch((err) => {
|
||||
logger.warn('Failed to set up AskUserQuestion bridge — proceeding without it:', err);
|
||||
return undefined;
|
||||
})
|
||||
: undefined;
|
||||
|
||||
let spawnPlan;
|
||||
let traceSession;
|
||||
let cwd: string;
|
||||
try {
|
||||
const driver = getHeterogeneousAgentDriver(session.agentType);
|
||||
spawnPlan = await driver.buildSpawnPlan({
|
||||
args: session.args,
|
||||
helpers: {
|
||||
buildClaudeStreamJsonInput: (prompt, imageList) =>
|
||||
this.buildStreamJsonInput(prompt, imageList),
|
||||
resolveCliImagePaths: (imageList) => this.resolveCliImagePaths(imageList),
|
||||
},
|
||||
imageList: params.imageList ?? [],
|
||||
mcpConfigPath: intervention?.tmpConfigPath,
|
||||
prompt: params.prompt,
|
||||
resumeSessionId: session.agentSessionId,
|
||||
});
|
||||
|
||||
// Fall back to the user's Desktop so the process never inherits
|
||||
// the Electron parent's cwd (which is `/` when launched from Finder).
|
||||
cwd = session.cwd || electronApp.getPath('desktop');
|
||||
traceSession = await this.createCliTraceSession({
|
||||
cliArgs: spawnPlan.args,
|
||||
cwd,
|
||||
imageList: params.imageList ?? [],
|
||||
session,
|
||||
stdinPayload: spawnPlan.stdinPayload,
|
||||
});
|
||||
} catch (err) {
|
||||
// We never made it to spawn — the `proc.on('exit')` cleanup path
|
||||
// won't run, so tear the intervention bridge down right here.
|
||||
if (intervention) {
|
||||
await intervention.cleanup().catch((cleanupErr) => {
|
||||
logger.warn('AskUserQuestion cleanup error during pre-spawn failure:', cleanupErr);
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const driver = getHeterogeneousAgentDriver(session.agentType);
|
||||
const spawnPlan = await driver.buildSpawnPlan({
|
||||
args: session.args,
|
||||
helpers: {
|
||||
buildClaudeStreamJsonInput: (prompt, imageList) =>
|
||||
this.buildStreamJsonInput(prompt, imageList),
|
||||
resolveCliImagePaths: (imageList) => this.resolveCliImagePaths(imageList),
|
||||
},
|
||||
imageList: params.imageList ?? [],
|
||||
prompt: params.prompt,
|
||||
resumeSessionId: session.agentSessionId,
|
||||
});
|
||||
const useStdin = spawnPlan.stdinPayload !== undefined;
|
||||
const cliArgs = spawnPlan.args;
|
||||
|
||||
// Fall back to the user's Desktop so the process never inherits
|
||||
// the Electron parent's cwd (which is `/` when launched from Finder).
|
||||
const cwd = session.cwd || electronApp.getPath('desktop');
|
||||
const traceSession = await this.createCliTraceSession({
|
||||
cliArgs,
|
||||
cwd,
|
||||
imageList: params.imageList ?? [],
|
||||
session,
|
||||
stdinPayload: spawnPlan.stdinPayload,
|
||||
});
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
logger.info('Spawning agent:', session.command, cliArgs.join(' '), `(cwd: ${cwd})`);
|
||||
|
||||
@@ -984,15 +838,6 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
void stdoutDrained
|
||||
.then(() => stdoutBroadcastQueue)
|
||||
.finally(async () => {
|
||||
// Tear down the AskUserQuestion bridge / temp `mcp.json` for this
|
||||
// op. Pending MCP handlers get a `session_ended` cancellation so
|
||||
// they return cleanly even if CC was killed mid-tool-call.
|
||||
if (intervention) {
|
||||
await intervention.cleanup().catch((err) => {
|
||||
logger.warn('AskUserQuestion cleanup error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
void this.writeCliTraceJson(traceSession, 'exit.json', {
|
||||
code,
|
||||
finishedAt: new Date().toISOString(),
|
||||
@@ -1127,54 +972,10 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderer → main: deliver the user's answer to a pending CC AskUserQuestion
|
||||
* (or signal cancellation). The matching bridge resolves its blocked
|
||||
* `pending()` Promise, the local MCP handler returns to CC, and CC's
|
||||
* `tool_result` flows back through the normal stream pipeline.
|
||||
*
|
||||
* Idempotent — late submissions for already-resolved tool calls are no-ops.
|
||||
* No-op when called for an unknown opId; the bridge may have been cleaned
|
||||
* up already (op finished / cancelled).
|
||||
*/
|
||||
@IpcMethod()
|
||||
async submitIntervention(params: SubmitInterventionParams): Promise<void> {
|
||||
const slot = this.opIdToIntervention.get(params.operationId);
|
||||
if (!slot) {
|
||||
logger.warn('submitIntervention: no active intervention for operationId', params.operationId);
|
||||
return;
|
||||
}
|
||||
slot.bridge.resolve(params.toolCallId, {
|
||||
cancelReason: params.cancelled ? (params.cancelReason ?? 'user_cancelled') : undefined,
|
||||
cancelled: params.cancelled,
|
||||
result: params.result,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously unlink every pending intervention's temp `mcp.json`. The
|
||||
* async exit-handler cleanup loses to Electron's main-process teardown
|
||||
* often enough that we'd leak `lobe-cc-mcp-<opId>.json` files into
|
||||
* `os.tmpdir()` on real shutdowns; sync unlink here is the only reliable
|
||||
* guarantee. Safe to call multiple times.
|
||||
*/
|
||||
private unlinkPendingInterventionConfigsSync = (): void => {
|
||||
for (const [, intervention] of this.opIdToIntervention) {
|
||||
try {
|
||||
unlinkSync(intervention.tmpConfigPath);
|
||||
} catch {
|
||||
/* file may already be gone — fine */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Cleanup on app quit. `before-quit` covers the user-driven Cmd+Q /
|
||||
* `app.quit()` path; SIGTERM / SIGINT cover external kills (test
|
||||
* harnesses, OS shutdown) where Electron's lifecycle events never fire.
|
||||
* Cleanup on app quit.
|
||||
*/
|
||||
afterAppReady() {
|
||||
electronApp.on('before-quit', () => {
|
||||
this.unlinkPendingInterventionConfigsSync();
|
||||
for (const [, session] of this.sessions) {
|
||||
if (session.process && !session.process.killed) {
|
||||
session.cancelledByUs = true;
|
||||
@@ -1182,28 +983,6 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
}
|
||||
}
|
||||
this.sessions.clear();
|
||||
// The exit handlers will tear each per-op intervention down, but if
|
||||
// CC's stdio close races shutdown we'd leave the MCP server bound to
|
||||
// a port. Stopping it here cancels every still-pending bridge with
|
||||
// `session_ended` and closes the listener.
|
||||
void this.askUserMcpServer?.stop().catch((err) => {
|
||||
logger.warn('AskUserQuestion MCP server stop error:', err);
|
||||
});
|
||||
});
|
||||
|
||||
const onSignal = (signal: NodeJS.Signals) => {
|
||||
this.unlinkPendingInterventionConfigsSync();
|
||||
// Defer to Electron's normal quit flow so the rest of the app gets a
|
||||
// chance to tear down. The `before-quit` handler above is idempotent.
|
||||
try {
|
||||
electronApp.quit();
|
||||
} catch {
|
||||
/* during late shutdown app.quit may throw — fine */
|
||||
}
|
||||
// Last-resort exit if Electron is wedged and won't quit on its own.
|
||||
setTimeout(() => process.exit(signal === 'SIGINT' ? 130 : 143), 1000).unref();
|
||||
};
|
||||
process.on('SIGTERM', onSignal);
|
||||
process.on('SIGINT', onSignal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import type {
|
||||
DetectAppsResult,
|
||||
OpenInAppParams,
|
||||
OpenInAppResult,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
|
||||
import { getCachedDetection } from '@/modules/openInApp/cache';
|
||||
import { detectApp } from '@/modules/openInApp/detectors';
|
||||
import { launchApp } from '@/modules/openInApp/launchers';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
const logger = createLogger('controllers:OpenInAppCtr');
|
||||
|
||||
export default class OpenInAppCtr extends ControllerModule {
|
||||
static override readonly groupName = 'openInApp';
|
||||
|
||||
@IpcMethod()
|
||||
async detectApps(): Promise<DetectAppsResult> {
|
||||
const apps = await getCachedDetection();
|
||||
return { apps };
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async openInApp({ appId, path }: OpenInAppParams): Promise<OpenInAppResult> {
|
||||
// Re-validate installation status before launching: per spec, the main
|
||||
// process must reject if the app disappeared between probe and launch.
|
||||
const installed = await detectApp(appId, process.platform);
|
||||
if (!installed) {
|
||||
logger.warn(`openInApp: ${appId} reported not installed`);
|
||||
return { error: `${appId} is not installed`, success: false };
|
||||
}
|
||||
|
||||
const result = await launchApp(appId, path, process.platform);
|
||||
if (result.success) {
|
||||
logger.info(`openInApp: launched ${appId} with path ${path}`);
|
||||
} else {
|
||||
logger.error(`openInApp: launch failed for ${appId}: ${result.error}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -802,131 +802,4 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
expect(toolEnds.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('app-quit cleanup of AskUserQuestion temp configs (LOBE-8725)', () => {
|
||||
// The async exit-handler cleanup races Electron's main-process teardown
|
||||
// and used to leak `lobe-cc-mcp-<opId>.json` files in `os.tmpdir()` on
|
||||
// every quit. The controller now unlinks pending intervention temp
|
||||
// configs *synchronously* from `before-quit` AND from process signal
|
||||
// handlers (SIGTERM / SIGINT — `before-quit` doesn't fire on external
|
||||
// kills). These tests exercise both paths against real files.
|
||||
|
||||
/**
|
||||
* Drop a temp `lobe-cc-mcp-<id>.json` and stash it on the controller's
|
||||
* `opIdToIntervention` map under the same key, so the quit hook treats
|
||||
* it like a real pending intervention and tries to unlink it.
|
||||
*/
|
||||
const seedPendingIntervention = async (ctr: HeterogeneousAgentCtr, opId: string) => {
|
||||
const tmpConfigPath = path.join(tmpdir(), `lobe-cc-mcp-test-${opId}.json`);
|
||||
await writeFile(tmpConfigPath, '{"mcpServers":{}}');
|
||||
const slot = {
|
||||
bridge: {} as any,
|
||||
pumpDone: Promise.resolve(),
|
||||
tmpConfigPath,
|
||||
};
|
||||
(ctr as any).opIdToIntervention.set(opId, slot);
|
||||
return tmpConfigPath;
|
||||
};
|
||||
|
||||
const captureRegisteredHandler = (
|
||||
registerSpy: ReturnType<typeof vi.fn> | ReturnType<typeof vi.spyOn>,
|
||||
eventName: string,
|
||||
): (() => void) => {
|
||||
const calls = (registerSpy as any).mock.calls as Array<[string, () => void]>;
|
||||
const match = calls.findLast(([evt]) => evt === eventName);
|
||||
if (!match) throw new Error(`no handler registered for "${eventName}"`);
|
||||
return match[1];
|
||||
};
|
||||
|
||||
it('before-quit synchronously unlinks every pending intervention temp config', async () => {
|
||||
const electron = (await import('electron')) as any;
|
||||
electron.app.on.mockClear();
|
||||
|
||||
const ctr = new HeterogeneousAgentCtr({
|
||||
appStoragePath,
|
||||
storeManager: { get: vi.fn() },
|
||||
} as any);
|
||||
|
||||
const fileA = await seedPendingIntervention(ctr, 'opA');
|
||||
const fileB = await seedPendingIntervention(ctr, 'opB');
|
||||
|
||||
ctr.afterAppReady();
|
||||
const beforeQuit = captureRegisteredHandler(electron.app.on, 'before-quit');
|
||||
beforeQuit();
|
||||
|
||||
await expect(access(fileA)).rejects.toThrow();
|
||||
await expect(access(fileB)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('SIGTERM handler unlinks pending intervention temp configs (external-kill path)', async () => {
|
||||
// External kills (test harness, OS shutdown) skip Electron's lifecycle
|
||||
// events entirely — `before-quit` never fires, so the controller has to
|
||||
// hook the raw process signal too. Stub `process.on` so the handler is
|
||||
// *recorded* but never actually attached to the test runner's process
|
||||
// (otherwise the test leaks a SIGTERM listener that survives the test).
|
||||
// Same for `process.exit` — the controller's fail-safe shouldn't get a
|
||||
// chance to actually exit the worker if its `setTimeout(...).unref()`
|
||||
// ever fires before mockRestore.
|
||||
const electron = (await import('electron')) as any;
|
||||
electron.app.on.mockClear();
|
||||
const processOnSpy = vi.spyOn(process, 'on').mockImplementation(() => process);
|
||||
const processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
|
||||
|
||||
const ctr = new HeterogeneousAgentCtr({
|
||||
appStoragePath,
|
||||
storeManager: { get: vi.fn() },
|
||||
} as any);
|
||||
const file = await seedPendingIntervention(ctr, 'opSigterm');
|
||||
|
||||
ctr.afterAppReady();
|
||||
const sigterm = captureRegisteredHandler(processOnSpy, 'SIGTERM');
|
||||
sigterm();
|
||||
|
||||
await expect(access(file)).rejects.toThrow();
|
||||
|
||||
processOnSpy.mockRestore();
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('SIGINT handler unlinks pending intervention temp configs (Ctrl-C path)', async () => {
|
||||
const electron = (await import('electron')) as any;
|
||||
electron.app.on.mockClear();
|
||||
const processOnSpy = vi.spyOn(process, 'on').mockImplementation(() => process);
|
||||
const processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
|
||||
|
||||
const ctr = new HeterogeneousAgentCtr({
|
||||
appStoragePath,
|
||||
storeManager: { get: vi.fn() },
|
||||
} as any);
|
||||
const file = await seedPendingIntervention(ctr, 'opSigint');
|
||||
|
||||
ctr.afterAppReady();
|
||||
const sigint = captureRegisteredHandler(processOnSpy, 'SIGINT');
|
||||
sigint();
|
||||
|
||||
await expect(access(file)).rejects.toThrow();
|
||||
|
||||
processOnSpy.mockRestore();
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('cleanup is idempotent — already-deleted files do not throw', async () => {
|
||||
const electron = (await import('electron')) as any;
|
||||
electron.app.on.mockClear();
|
||||
|
||||
const ctr = new HeterogeneousAgentCtr({
|
||||
appStoragePath,
|
||||
storeManager: { get: vi.fn() },
|
||||
} as any);
|
||||
const file = await seedPendingIntervention(ctr, 'opIdempotent');
|
||||
|
||||
// Pre-delete the file out from under the controller — simulates a
|
||||
// partial cleanup race where the async exit handler beat us to it.
|
||||
await unlink(file);
|
||||
|
||||
ctr.afterAppReady();
|
||||
const beforeQuit = captureRegisteredHandler(electron.app.on, 'before-quit');
|
||||
expect(() => beforeQuit()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { type App } from '@/core/App';
|
||||
|
||||
import LocalFileCtr from '../LocalFileCtr';
|
||||
|
||||
// Real fs + real @lobechat/file-loaders end-to-end. We only mock the
|
||||
// boundaries we genuinely cannot run in a test process: electron IPC,
|
||||
// execa shell-outs, logger, net fetch.
|
||||
vi.mock('electron', () => ({
|
||||
dialog: { showOpenDialog: vi.fn(), showSaveDialog: vi.fn() },
|
||||
ipcMain: { handle: vi.fn() },
|
||||
shell: { openPath: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('execa', () => ({ execa: vi.fn() }));
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/net-fetch', () => ({ netFetch: vi.fn() }));
|
||||
|
||||
vi.mock('@/utils/file-system', () => ({ makeSureDirExist: vi.fn() }));
|
||||
|
||||
const mockApp = {
|
||||
appStoragePath: '/mock/app/storage',
|
||||
getService: vi.fn(),
|
||||
toolDetectorManager: { getBestTool: vi.fn(() => null) },
|
||||
} as unknown as App;
|
||||
|
||||
describe('LocalFileCtr — readFile / readFiles (real fs)', () => {
|
||||
const tmpDir = path.join(os.tmpdir(), 'localfilectr-readfile-test-' + process.pid);
|
||||
let localFileCtr: LocalFileCtr;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
await mkdir(tmpDir, { recursive: true });
|
||||
localFileCtr = new LocalFileCtr(mockApp);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
describe('readFile', () => {
|
||||
it('should read file successfully with default location', async () => {
|
||||
const filePath = path.join(tmpDir, 'test.txt');
|
||||
const content = 'line1\nline2\nline3\nline4\nline5';
|
||||
await writeFile(filePath, content);
|
||||
|
||||
const result = await localFileCtr.readFile({ path: filePath });
|
||||
|
||||
expect(result).toEqual({
|
||||
charCount: 29,
|
||||
content,
|
||||
createdTime: expect.any(Date),
|
||||
fileType: 'txt',
|
||||
filename: 'test.txt',
|
||||
lineCount: 5,
|
||||
loc: [0, 200],
|
||||
modifiedTime: expect.any(Date),
|
||||
totalCharCount: 29,
|
||||
totalLineCount: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('should read file with custom location range', async () => {
|
||||
const filePath = path.join(tmpDir, 'range.txt');
|
||||
await writeFile(filePath, 'line1\nline2\nline3\nline4\nline5');
|
||||
|
||||
const result = await localFileCtr.readFile({ loc: [1, 3], path: filePath });
|
||||
|
||||
expect(result).toEqual({
|
||||
charCount: 11,
|
||||
content: 'line2\nline3',
|
||||
createdTime: expect.any(Date),
|
||||
fileType: 'txt',
|
||||
filename: 'range.txt',
|
||||
lineCount: 2,
|
||||
loc: [1, 3],
|
||||
modifiedTime: expect.any(Date),
|
||||
totalCharCount: 29,
|
||||
totalLineCount: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('should read full file content when fullContent is true', async () => {
|
||||
const filePath = path.join(tmpDir, 'full.txt');
|
||||
const content = 'line1\nline2\nline3\nline4\nline5';
|
||||
await writeFile(filePath, content);
|
||||
|
||||
const result = await localFileCtr.readFile({ fullContent: true, path: filePath });
|
||||
|
||||
expect(result).toEqual({
|
||||
charCount: 29,
|
||||
content,
|
||||
createdTime: expect.any(Date),
|
||||
fileType: 'txt',
|
||||
filename: 'full.txt',
|
||||
lineCount: 5,
|
||||
loc: [0, 5],
|
||||
modifiedTime: expect.any(Date),
|
||||
totalCharCount: 29,
|
||||
totalLineCount: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle file read error', async () => {
|
||||
const result = await localFileCtr.readFile({
|
||||
path: path.join(tmpDir, 'does-not-exist.txt'),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
charCount: 0,
|
||||
content: expect.stringContaining('Error accessing or processing file'),
|
||||
createdTime: expect.any(Date),
|
||||
fileType: 'txt',
|
||||
filename: 'does-not-exist.txt',
|
||||
lineCount: 0,
|
||||
loc: [0, 0],
|
||||
modifiedTime: expect.any(Date),
|
||||
totalCharCount: 0,
|
||||
totalLineCount: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('readFiles', () => {
|
||||
it('should read multiple files successfully', async () => {
|
||||
const file1 = path.join(tmpDir, 'a.txt');
|
||||
const file2 = path.join(tmpDir, 'b.txt');
|
||||
await writeFile(file1, 'content a');
|
||||
await writeFile(file2, 'content b');
|
||||
|
||||
const result = await localFileCtr.readFiles({ paths: [file1, file2] });
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
charCount: 9,
|
||||
content: 'content a',
|
||||
createdTime: expect.any(Date),
|
||||
fileType: 'txt',
|
||||
filename: 'a.txt',
|
||||
lineCount: 1,
|
||||
loc: [0, 200],
|
||||
modifiedTime: expect.any(Date),
|
||||
totalCharCount: 9,
|
||||
totalLineCount: 1,
|
||||
},
|
||||
{
|
||||
charCount: 9,
|
||||
content: 'content b',
|
||||
createdTime: expect.any(Date),
|
||||
fileType: 'txt',
|
||||
filename: 'b.txt',
|
||||
lineCount: 1,
|
||||
loc: [0, 200],
|
||||
modifiedTime: expect.any(Date),
|
||||
totalCharCount: 9,
|
||||
totalLineCount: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -106,6 +106,7 @@ const mockApp = {
|
||||
describe('LocalFileCtr', () => {
|
||||
let localFileCtr: LocalFileCtr;
|
||||
let mockShell: any;
|
||||
let mockLoadFile: any;
|
||||
let mockFsPromises: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -113,6 +114,7 @@ describe('LocalFileCtr', () => {
|
||||
|
||||
// Import mocks
|
||||
mockShell = (await import('electron')).shell;
|
||||
mockLoadFile = (await import('@lobechat/file-loaders')).loadFile;
|
||||
mockFsPromises = await import('node:fs/promises');
|
||||
|
||||
localFileCtr = new LocalFileCtr(mockApp);
|
||||
@@ -176,9 +178,91 @@ describe('LocalFileCtr', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// readFile / readFiles e2e tests live in LocalFileCtr.readFile.test.ts so
|
||||
// they exercise real fs + file-loaders without fighting the heavy mocks
|
||||
// this suite needs for execa-driven tools, electron, and the like.
|
||||
describe('readFile', () => {
|
||||
it('should read file successfully with default location', async () => {
|
||||
const mockFileContent = 'line1\nline2\nline3\nline4\nline5';
|
||||
vi.mocked(mockLoadFile).mockResolvedValue({
|
||||
content: mockFileContent,
|
||||
filename: 'test.txt',
|
||||
fileType: 'txt',
|
||||
createdTime: new Date('2024-01-01'),
|
||||
modifiedTime: new Date('2024-01-02'),
|
||||
});
|
||||
|
||||
const result = await localFileCtr.readFile({ path: '/test/file.txt' });
|
||||
|
||||
expect(result.filename).toBe('test.txt');
|
||||
expect(result.fileType).toBe('txt');
|
||||
expect(result.totalLineCount).toBe(5);
|
||||
expect(result.content).toBe(mockFileContent);
|
||||
});
|
||||
|
||||
it('should read file with custom location range', async () => {
|
||||
const mockFileContent = 'line1\nline2\nline3\nline4\nline5';
|
||||
vi.mocked(mockLoadFile).mockResolvedValue({
|
||||
content: mockFileContent,
|
||||
filename: 'test.txt',
|
||||
fileType: 'txt',
|
||||
createdTime: new Date('2024-01-01'),
|
||||
modifiedTime: new Date('2024-01-02'),
|
||||
});
|
||||
|
||||
const result = await localFileCtr.readFile({ path: '/test/file.txt', loc: [1, 3] });
|
||||
|
||||
expect(result.content).toBe('line2\nline3');
|
||||
expect(result.lineCount).toBe(2);
|
||||
expect(result.totalLineCount).toBe(5);
|
||||
});
|
||||
|
||||
it('should read full file content when fullContent is true', async () => {
|
||||
const mockFileContent = 'line1\nline2\nline3\nline4\nline5';
|
||||
vi.mocked(mockLoadFile).mockResolvedValue({
|
||||
content: mockFileContent,
|
||||
filename: 'test.txt',
|
||||
fileType: 'txt',
|
||||
createdTime: new Date('2024-01-01'),
|
||||
modifiedTime: new Date('2024-01-02'),
|
||||
});
|
||||
|
||||
const result = await localFileCtr.readFile({ path: '/test/file.txt', fullContent: true });
|
||||
|
||||
expect(result.content).toBe(mockFileContent);
|
||||
expect(result.lineCount).toBe(5);
|
||||
expect(result.charCount).toBe(mockFileContent.length);
|
||||
expect(result.totalLineCount).toBe(5);
|
||||
expect(result.totalCharCount).toBe(mockFileContent.length);
|
||||
expect(result.loc).toEqual([0, 5]);
|
||||
});
|
||||
|
||||
it('should handle file read error', async () => {
|
||||
vi.mocked(mockLoadFile).mockRejectedValue(new Error('File not found'));
|
||||
|
||||
const result = await localFileCtr.readFile({ path: '/test/missing.txt' });
|
||||
|
||||
expect(result.content).toContain('Error accessing or processing file');
|
||||
expect(result.lineCount).toBe(0);
|
||||
expect(result.charCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readFiles', () => {
|
||||
it('should read multiple files successfully', async () => {
|
||||
vi.mocked(mockLoadFile).mockResolvedValue({
|
||||
content: 'file content',
|
||||
filename: 'test.txt',
|
||||
fileType: 'txt',
|
||||
createdTime: new Date('2024-01-01'),
|
||||
modifiedTime: new Date('2024-01-02'),
|
||||
});
|
||||
|
||||
const result = await localFileCtr.readFiles({
|
||||
paths: ['/test/file1.txt', '/test/file2.txt'],
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(mockLoadFile).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleWriteFile', () => {
|
||||
it('should write file successfully', async () => {
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import type { DetectedApp, OpenInAppResult } from '@lobechat/electron-client-ipc';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
import type { IpcContext } from '@/utils/ipc';
|
||||
import { IpcHandler } from '@/utils/ipc/base';
|
||||
|
||||
import OpenInAppCtr from '../OpenInAppCtr';
|
||||
|
||||
const { getCachedDetectionMock, detectAppMock, launchAppMock, ipcHandlers, ipcMainHandleMock } =
|
||||
vi.hoisted(() => {
|
||||
const handlers = new Map<string, (event: any, ...args: any[]) => any>();
|
||||
const handle = vi.fn((channel: string, handler: any) => {
|
||||
handlers.set(channel, handler);
|
||||
});
|
||||
return {
|
||||
detectAppMock: vi.fn(),
|
||||
getCachedDetectionMock: vi.fn(),
|
||||
ipcHandlers: handlers,
|
||||
ipcMainHandleMock: handle,
|
||||
launchAppMock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const invokeIpc = async <T = any>(
|
||||
channel: string,
|
||||
payload?: any,
|
||||
context?: Partial<IpcContext>,
|
||||
): Promise<T> => {
|
||||
const handler = ipcHandlers.get(channel);
|
||||
if (!handler) throw new Error(`IPC handler for ${channel} not found`);
|
||||
|
||||
const fakeEvent = {
|
||||
sender: context?.sender ?? ({ id: 'test' } as any),
|
||||
};
|
||||
|
||||
if (payload === undefined) {
|
||||
return handler(fakeEvent);
|
||||
}
|
||||
|
||||
return handler(fakeEvent, payload);
|
||||
};
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/modules/openInApp/cache', () => ({
|
||||
getCachedDetection: getCachedDetectionMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/modules/openInApp/detectors', () => ({
|
||||
detectApp: detectAppMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/modules/openInApp/launchers', () => ({
|
||||
launchApp: launchAppMock,
|
||||
}));
|
||||
|
||||
const mockApp = {} as unknown as App;
|
||||
|
||||
describe('OpenInAppCtr', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ipcHandlers.clear();
|
||||
ipcMainHandleMock.mockClear();
|
||||
(IpcHandler.getInstance() as any).registeredChannels?.clear();
|
||||
new OpenInAppCtr(mockApp);
|
||||
});
|
||||
|
||||
describe('detectApps', () => {
|
||||
it('should call getCachedDetection and return the apps list', async () => {
|
||||
const apps: DetectedApp[] = [
|
||||
{ displayName: 'Visual Studio Code', id: 'vscode', installed: true },
|
||||
{ displayName: 'Cursor', id: 'cursor', installed: false },
|
||||
];
|
||||
getCachedDetectionMock.mockResolvedValue(apps);
|
||||
|
||||
const result = await invokeIpc('openInApp.detectApps');
|
||||
|
||||
expect(getCachedDetectionMock).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({ apps });
|
||||
});
|
||||
});
|
||||
|
||||
describe('openInApp', () => {
|
||||
it('should launch the app when installed', async () => {
|
||||
detectAppMock.mockResolvedValue(true);
|
||||
const launchResult: OpenInAppResult = { success: true };
|
||||
launchAppMock.mockResolvedValue(launchResult);
|
||||
|
||||
const result = await invokeIpc('openInApp.openInApp', {
|
||||
appId: 'vscode',
|
||||
path: '/tmp/project',
|
||||
});
|
||||
|
||||
expect(detectAppMock).toHaveBeenCalledWith('vscode', process.platform);
|
||||
expect(launchAppMock).toHaveBeenCalledWith('vscode', '/tmp/project', process.platform);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should not launch and return error when app is not installed', async () => {
|
||||
detectAppMock.mockResolvedValue(false);
|
||||
|
||||
const result = await invokeIpc('openInApp.openInApp', {
|
||||
appId: 'cursor',
|
||||
path: '/tmp/project',
|
||||
});
|
||||
|
||||
expect(detectAppMock).toHaveBeenCalledWith('cursor', process.platform);
|
||||
expect(launchAppMock).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
error: 'cursor is not installed',
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass through launch errors when launchApp fails', async () => {
|
||||
detectAppMock.mockResolvedValue(true);
|
||||
const launchResult: OpenInAppResult = {
|
||||
error: 'Path not found: /tmp/missing',
|
||||
success: false,
|
||||
};
|
||||
launchAppMock.mockResolvedValue(launchResult);
|
||||
|
||||
const result = await invokeIpc('openInApp.openInApp', {
|
||||
appId: 'vscode',
|
||||
path: '/tmp/missing',
|
||||
});
|
||||
|
||||
expect(detectAppMock).toHaveBeenCalledWith('vscode', process.platform);
|
||||
expect(launchAppMock).toHaveBeenCalledWith('vscode', '/tmp/missing', process.platform);
|
||||
expect(result).toEqual(launchResult);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,6 @@ import McpInstallCtr from './McpInstallCtr';
|
||||
import MenuController from './MenuCtr';
|
||||
import NetworkProxyCtr from './NetworkProxyCtr';
|
||||
import NotificationCtr from './NotificationCtr';
|
||||
import OpenInAppCtr from './OpenInAppCtr';
|
||||
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
|
||||
import RemoteServerSyncCtr from './RemoteServerSyncCtr';
|
||||
import ScreenCaptureCtr from './ScreenCaptureCtr';
|
||||
@@ -38,7 +37,6 @@ export const controllerIpcConstructors = [
|
||||
MenuController,
|
||||
NetworkProxyCtr,
|
||||
NotificationCtr,
|
||||
OpenInAppCtr,
|
||||
RemoteServerConfigCtr,
|
||||
RemoteServerSyncCtr,
|
||||
ScreenCaptureCtr,
|
||||
|
||||
@@ -31,7 +31,6 @@ import { createLogger } from '@/utils/logger';
|
||||
import { BrowserManager } from './browser/BrowserManager';
|
||||
import { I18nManager } from './infrastructure/I18nManager';
|
||||
import { IoCContainer } from './infrastructure/IoCContainer';
|
||||
import { LocalFileProtocolManager } from './infrastructure/LocalFileProtocolManager';
|
||||
import { ProtocolManager } from './infrastructure/ProtocolManager';
|
||||
import { RendererUrlManager } from './infrastructure/RendererUrlManager';
|
||||
import { StaticFileServerManager } from './infrastructure/StaticFileServerManager';
|
||||
@@ -63,7 +62,6 @@ export class App {
|
||||
staticFileServerManager: StaticFileServerManager;
|
||||
protocolManager: ProtocolManager;
|
||||
rendererUrlManager: RendererUrlManager;
|
||||
localFileProtocolManager: LocalFileProtocolManager;
|
||||
toolDetectorManager: ToolDetectorManager;
|
||||
screenCaptureManager: ScreenCaptureManager;
|
||||
chromeFlags: string[] = ['OverlayScrollbar', 'FluentOverlayScrollbar', 'FluentScrollbar'];
|
||||
@@ -104,7 +102,6 @@ export class App {
|
||||
this.storeManager = new StoreManager(this);
|
||||
|
||||
this.rendererUrlManager = new RendererUrlManager();
|
||||
this.localFileProtocolManager = new LocalFileProtocolManager();
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
privileges: {
|
||||
@@ -117,7 +114,6 @@ export class App {
|
||||
scheme: ELECTRON_BE_PROTOCOL_SCHEME,
|
||||
},
|
||||
this.rendererUrlManager.protocolScheme,
|
||||
this.localFileProtocolManager.protocolScheme,
|
||||
]);
|
||||
|
||||
// load controllers
|
||||
@@ -156,10 +152,6 @@ export class App {
|
||||
// should register before app ready
|
||||
this.rendererUrlManager.configureRendererLoader();
|
||||
|
||||
// Serves arbitrary local files (e.g. project file previews) via
|
||||
// `localfile://` to the renderer. Active in both dev and prod.
|
||||
this.localFileProtocolManager.registerHandler();
|
||||
|
||||
// initialize protocol handlers
|
||||
this.protocolManager.initialize();
|
||||
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { app, protocol } from 'electron';
|
||||
|
||||
import { LOCAL_FILE_PROTOCOL_HOST, LOCAL_FILE_PROTOCOL_SCHEME } from '@/const/protocol';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { getExportMimeType } from '../../utils/mime';
|
||||
|
||||
const LOCAL_FILE_PROTOCOL_PRIVILEGES = {
|
||||
allowServiceWorkers: false,
|
||||
bypassCSP: false,
|
||||
corsEnabled: true,
|
||||
secure: true,
|
||||
standard: true,
|
||||
stream: true,
|
||||
supportFetchAPI: true,
|
||||
} as const;
|
||||
|
||||
const logger = createLogger('core:LocalFileProtocolManager');
|
||||
|
||||
const EXTRA_MIME_TYPES: Record<string, string> = {
|
||||
'.avif': 'image/avif',
|
||||
'.bmp': 'image/bmp',
|
||||
'.heic': 'image/heic',
|
||||
'.heif': 'image/heif',
|
||||
'.tif': 'image/tiff',
|
||||
'.tiff': 'image/tiff',
|
||||
};
|
||||
|
||||
const getMimeType = (filePath: string): string => {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
return getExportMimeType(filePath) ?? EXTRA_MIME_TYPES[ext] ?? 'application/octet-stream';
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom `localfile://` protocol that serves arbitrary local files to the
|
||||
* Electron renderer (e.g. previews for the project Files tree).
|
||||
*
|
||||
* URL shape: `localfile://file/<percent-encoded-absolute-path>`
|
||||
* - host is fixed to `file` so the scheme behaves as `standard`
|
||||
* - the absolute path is encoded in the URL pathname
|
||||
*
|
||||
* Examples:
|
||||
* localfile://file//Users/alice/Pictures/cat.png
|
||||
* localfile://file/C:/Users/alice/Pictures/cat.png
|
||||
*/
|
||||
export class LocalFileProtocolManager {
|
||||
private handlerRegistered = false;
|
||||
|
||||
get protocolScheme() {
|
||||
return {
|
||||
privileges: LOCAL_FILE_PROTOCOL_PRIVILEGES,
|
||||
scheme: LOCAL_FILE_PROTOCOL_SCHEME,
|
||||
};
|
||||
}
|
||||
|
||||
registerHandler() {
|
||||
if (this.handlerRegistered) return;
|
||||
|
||||
const register = () => {
|
||||
if (this.handlerRegistered) return;
|
||||
|
||||
protocol.handle(LOCAL_FILE_PROTOCOL_SCHEME, async (request) => {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.hostname !== LOCAL_FILE_PROTOCOL_HOST) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const resolvedPath = this.resolveFilePath(url.pathname);
|
||||
if (!resolvedPath) {
|
||||
return new Response('Invalid path', { status: 400 });
|
||||
}
|
||||
|
||||
const fileStat = await stat(resolvedPath);
|
||||
if (!fileStat.isFile()) {
|
||||
return new Response('Not a file', { status: 404 });
|
||||
}
|
||||
|
||||
const buffer = await readFile(resolvedPath);
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', getMimeType(resolvedPath));
|
||||
headers.set('Content-Length', String(buffer.byteLength));
|
||||
// Local files are immutable from the renderer's perspective for a
|
||||
// single preview session; allow short-lived caching to avoid
|
||||
// re-reading large images during scrolling/refresh.
|
||||
headers.set('Cache-Control', 'private, max-age=60');
|
||||
|
||||
return new Response(buffer, { headers, status: 200 });
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
if (code === 'EACCES' || code === 'EPERM') {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
logger.error(`Failed to serve localfile request ${request.url}:`, error);
|
||||
return new Response('Internal Server Error', { status: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
this.handlerRegistered = true;
|
||||
logger.debug(`Registered ${LOCAL_FILE_PROTOCOL_SCHEME}:// handler`);
|
||||
};
|
||||
|
||||
if (app.isReady()) {
|
||||
register();
|
||||
} else {
|
||||
app.whenReady().then(register);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the URL pathname back into an absolute filesystem path.
|
||||
*
|
||||
* Pathname examples produced by `new URL('localfile://file//abs/path')`:
|
||||
* posix: `//abs/path` -> `/abs/path`
|
||||
* windows: `/C:/abs/path` -> `C:/abs/path`
|
||||
*
|
||||
* Returns null when the path is non-absolute or escapes via segments we
|
||||
* cannot safely normalize (defense-in-depth, not a sandbox).
|
||||
*/
|
||||
private resolveFilePath(pathname: string): string | null {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(pathname);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Strip the single leading slash inserted by URL parsing on standard
|
||||
// schemes; what remains should already be an absolute filesystem path.
|
||||
let candidate = decoded.startsWith('/') ? decoded.slice(1) : decoded;
|
||||
if (!candidate) return null;
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// posix-style absolute path won't have a drive letter; treat as invalid
|
||||
// on Windows.
|
||||
candidate = candidate.replaceAll('/', '\\');
|
||||
} else if (!candidate.startsWith('/')) {
|
||||
// We expect an absolute POSIX path: `localfile://file//abs/path` yields
|
||||
// pathname `//abs/path` -> after stripping one slash -> `/abs/path`.
|
||||
candidate = `/${candidate}`;
|
||||
}
|
||||
|
||||
const normalized = path.normalize(candidate);
|
||||
if (!path.isAbsolute(normalized)) return null;
|
||||
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { LocalFileProtocolManager } from '../LocalFileProtocolManager';
|
||||
|
||||
const { mockApp, mockProtocol, mockReadFile, mockStat, protocolHandlerRef } = vi.hoisted(() => {
|
||||
const protocolHandlerRef = { current: null as any };
|
||||
|
||||
return {
|
||||
mockApp: {
|
||||
isReady: vi.fn().mockReturnValue(true),
|
||||
whenReady: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
mockProtocol: {
|
||||
handle: vi.fn((_scheme: string, handler: any) => {
|
||||
protocolHandlerRef.current = handler;
|
||||
}),
|
||||
},
|
||||
mockReadFile: vi.fn(),
|
||||
mockStat: vi.fn(),
|
||||
protocolHandlerRef,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: mockApp,
|
||||
protocol: mockProtocol,
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
readFile: mockReadFile,
|
||||
stat: mockStat,
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('LocalFileProtocolManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
protocolHandlerRef.current = null;
|
||||
mockApp.isReady.mockReturnValue(true);
|
||||
mockStat.mockImplementation(async () => ({ isFile: () => true, size: 1024 }));
|
||||
mockReadFile.mockImplementation(async () => Buffer.from('image-bytes'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
protocolHandlerRef.current = null;
|
||||
});
|
||||
|
||||
it('exposes scheme metadata for registerSchemesAsPrivileged', () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
expect(manager.protocolScheme).toEqual({
|
||||
privileges: expect.objectContaining({
|
||||
bypassCSP: false,
|
||||
secure: true,
|
||||
standard: true,
|
||||
supportFetchAPI: true,
|
||||
}),
|
||||
scheme: 'localfile',
|
||||
});
|
||||
});
|
||||
|
||||
it('serves a POSIX absolute path with the correct mime type', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
manager.registerHandler();
|
||||
|
||||
expect(mockProtocol.handle).toHaveBeenCalledWith('localfile', expect.any(Function));
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
const response = await handler({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url: 'localfile://file/Users/alice/Pictures/cat.png',
|
||||
});
|
||||
|
||||
expect(mockStat).toHaveBeenCalledWith('/Users/alice/Pictures/cat.png');
|
||||
expect(mockReadFile).toHaveBeenCalledWith('/Users/alice/Pictures/cat.png');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('Content-Type')).toBe('image/png');
|
||||
expect(response.headers.get('Content-Length')).toBe('11'); // 'image-bytes'.length
|
||||
});
|
||||
|
||||
it('serves source files as text through the localfile protocol', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
manager.registerHandler();
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
const response = await handler({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url: 'localfile://file/Users/alice/project/App.tsx',
|
||||
});
|
||||
|
||||
expect(mockStat).toHaveBeenCalledWith('/Users/alice/project/App.tsx');
|
||||
expect(mockReadFile).toHaveBeenCalledWith('/Users/alice/project/App.tsx');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('Content-Type')).toBe('text/plain; charset=utf-8');
|
||||
});
|
||||
|
||||
it('decodes percent-encoded characters in the path', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
manager.registerHandler();
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
await handler({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url: 'localfile://file/Users/alice/My%20Pictures/%E5%9B%BE%20%23.png',
|
||||
});
|
||||
|
||||
expect(mockStat).toHaveBeenCalledWith('/Users/alice/My Pictures/图 #.png');
|
||||
});
|
||||
|
||||
it('rejects requests to a different host', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
manager.registerHandler();
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
const response = await handler({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url: 'localfile://other/Users/alice/cat.png',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(mockStat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 404 when the path is a directory', async () => {
|
||||
mockStat.mockImplementation(async () => ({ isFile: () => false, size: 0 }));
|
||||
|
||||
const manager = new LocalFileProtocolManager();
|
||||
manager.registerHandler();
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
const response = await handler({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url: 'localfile://file/Users/alice/folder',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(mockReadFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps ENOENT errors to a 404 response', async () => {
|
||||
mockStat.mockImplementation(async () => {
|
||||
const err: NodeJS.ErrnoException = new Error('no such file');
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
});
|
||||
|
||||
const manager = new LocalFileProtocolManager();
|
||||
manager.registerHandler();
|
||||
const handler = protocolHandlerRef.current;
|
||||
|
||||
const response = await handler({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url: 'localfile://file/nonexistent.png',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('defers registration until app ready when not yet ready', async () => {
|
||||
mockApp.isReady.mockReturnValue(false);
|
||||
let resolveReady: () => void = () => undefined;
|
||||
mockApp.whenReady.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
resolveReady = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const manager = new LocalFileProtocolManager();
|
||||
manager.registerHandler();
|
||||
|
||||
expect(mockProtocol.handle).not.toHaveBeenCalled();
|
||||
resolveReady();
|
||||
await new Promise((r) => setImmediate(r));
|
||||
expect(mockProtocol.handle).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type {
|
||||
HeterogeneousAgentBuildPlanHelpers,
|
||||
HeterogeneousAgentBuildPlanParams,
|
||||
} from '../types';
|
||||
import { claudeCodeDriver } from './claudeCode';
|
||||
|
||||
const stubHelpers: HeterogeneousAgentBuildPlanHelpers = {
|
||||
buildClaudeStreamJsonInput: async () => '{"type":"user","message":{}}\n',
|
||||
resolveCliImagePaths: async () => [],
|
||||
};
|
||||
|
||||
const buildParams = (
|
||||
overrides: Partial<HeterogeneousAgentBuildPlanParams> = {},
|
||||
): HeterogeneousAgentBuildPlanParams => ({
|
||||
args: [],
|
||||
helpers: stubHelpers,
|
||||
imageList: [],
|
||||
prompt: 'hi',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('claudeCodeDriver', () => {
|
||||
it('omits --mcp-config when mcpConfigPath is undefined', async () => {
|
||||
const { args } = await claudeCodeDriver.buildSpawnPlan(buildParams());
|
||||
expect(args).not.toContain('--mcp-config');
|
||||
});
|
||||
|
||||
it('appends --mcp-config <path> when mcpConfigPath is provided', async () => {
|
||||
const { args } = await claudeCodeDriver.buildSpawnPlan(
|
||||
buildParams({ mcpConfigPath: '/tmp/lobe-cc-mcp-op-1.json' }),
|
||||
);
|
||||
const idx = args.indexOf('--mcp-config');
|
||||
expect(idx).toBeGreaterThan(-1);
|
||||
expect(args[idx + 1]).toBe('/tmp/lobe-cc-mcp-op-1.json');
|
||||
});
|
||||
|
||||
it('still pins --disallowedTools AskUserQuestion alongside --mcp-config', async () => {
|
||||
// Even with our local MCP replacement available, CC's built-in stays
|
||||
// disabled — leaving both visible would let the model double-register
|
||||
// the same name and pick the broken one.
|
||||
const { args } = await claudeCodeDriver.buildSpawnPlan(
|
||||
buildParams({ mcpConfigPath: '/tmp/x.json' }),
|
||||
);
|
||||
const disallowedIdx = args.indexOf('--disallowedTools');
|
||||
expect(disallowedIdx).toBeGreaterThan(-1);
|
||||
expect(args[disallowedIdx + 1]).toBe('AskUserQuestion');
|
||||
});
|
||||
|
||||
it('--mcp-config goes before --resume so user --args can still override the resume id', async () => {
|
||||
const { args } = await claudeCodeDriver.buildSpawnPlan(
|
||||
buildParams({ mcpConfigPath: '/tmp/x.json', resumeSessionId: 'cc-prev-1' }),
|
||||
);
|
||||
const mcpIdx = args.indexOf('--mcp-config');
|
||||
const resumeIdx = args.indexOf('--resume');
|
||||
expect(mcpIdx).toBeGreaterThan(-1);
|
||||
expect(resumeIdx).toBeGreaterThan(-1);
|
||||
expect(mcpIdx).toBeLessThan(resumeIdx);
|
||||
expect(args[resumeIdx + 1]).toBe('cc-prev-1');
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,12 @@
|
||||
import { CLAUDE_CODE_BASE_ARGS } from '@lobechat/heterogeneous-agents/spawn';
|
||||
|
||||
import type { HeterogeneousAgentBuildPlanParams, HeterogeneousAgentDriver } from '../types';
|
||||
|
||||
// Desktop runs CC as the user (never root, so bypassPermissions is fine) and
|
||||
// renders the chat bubble live, so it always wants partial deltas. Compose
|
||||
// the shared invariant base args (`@lobechat/heterogeneous-agents/spawn`)
|
||||
// with those caller-specific flags.
|
||||
const DESKTOP_CLAUDE_CODE_ARGS = [
|
||||
...CLAUDE_CODE_BASE_ARGS,
|
||||
const CLAUDE_CODE_BASE_ARGS = [
|
||||
'-p',
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
'--include-partial-messages',
|
||||
'--permission-mode',
|
||||
'bypassPermissions',
|
||||
@@ -18,7 +17,6 @@ export const claudeCodeDriver: HeterogeneousAgentDriver = {
|
||||
args,
|
||||
helpers,
|
||||
imageList,
|
||||
mcpConfigPath,
|
||||
prompt,
|
||||
resumeSessionId,
|
||||
}: HeterogeneousAgentBuildPlanParams) {
|
||||
@@ -26,11 +24,7 @@ export const claudeCodeDriver: HeterogeneousAgentDriver = {
|
||||
|
||||
return {
|
||||
args: [
|
||||
...DESKTOP_CLAUDE_CODE_ARGS,
|
||||
// Wire the controller-managed temp mcp.json (AskUserQuestion server,
|
||||
// see LOBE-8725) when present. Path-based config is required — CC
|
||||
// does not accept inline JSON for `--mcp-config`.
|
||||
...(mcpConfigPath ? ['--mcp-config', mcpConfigPath] : []),
|
||||
...CLAUDE_CODE_BASE_ARGS,
|
||||
...(resumeSessionId ? ['--resume', resumeSessionId] : []),
|
||||
...args,
|
||||
],
|
||||
|
||||
@@ -20,12 +20,6 @@ export interface HeterogeneousAgentBuildPlanParams {
|
||||
args: string[];
|
||||
helpers: HeterogeneousAgentBuildPlanHelpers;
|
||||
imageList: HeterogeneousAgentImageAttachment[];
|
||||
/**
|
||||
* Optional path to an MCP config JSON written by the controller (e.g. for
|
||||
* the local `lobe_cc` AskUserQuestion server). Drivers that recognize the
|
||||
* field append `--mcp-config <path>`; others ignore it.
|
||||
*/
|
||||
mcpConfigPath?: string;
|
||||
prompt: string;
|
||||
resumeSessionId?: string;
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { clearDetectionCache, getCachedDetection } from '../cache';
|
||||
import { detectAllApps } from '../detectors';
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../detectors', () => ({
|
||||
detectAllApps: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedDetectAll = vi.mocked(detectAllApps);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearDetectionCache();
|
||||
});
|
||||
|
||||
describe('getCachedDetection', () => {
|
||||
it('invokes detection on first call', async () => {
|
||||
mockedDetectAll.mockResolvedValueOnce([
|
||||
{ displayName: 'VS Code', id: 'vscode', installed: true },
|
||||
]);
|
||||
|
||||
const result = await getCachedDetection('darwin');
|
||||
|
||||
expect(result).toEqual([{ displayName: 'VS Code', id: 'vscode', installed: true }]);
|
||||
expect(mockedDetectAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('concurrent callers share a single inflight promise', async () => {
|
||||
let resolveFn: (value: any) => void = () => {};
|
||||
const inflight = new Promise<any>((resolve) => {
|
||||
resolveFn = resolve;
|
||||
});
|
||||
mockedDetectAll.mockReturnValueOnce(inflight);
|
||||
|
||||
const p1 = getCachedDetection('darwin');
|
||||
const p2 = getCachedDetection('darwin');
|
||||
const p3 = getCachedDetection('darwin');
|
||||
|
||||
expect(mockedDetectAll).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveFn([{ displayName: 'VS Code', id: 'vscode', installed: true }]);
|
||||
const results = await Promise.all([p1, p2, p3]);
|
||||
|
||||
// all three share the same resolved value
|
||||
expect(results[0]).toBe(results[1]);
|
||||
expect(results[1]).toBe(results[2]);
|
||||
expect(mockedDetectAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('subsequent serial calls reuse the cached promise', async () => {
|
||||
mockedDetectAll.mockResolvedValueOnce([
|
||||
{ displayName: 'VS Code', id: 'vscode', installed: true },
|
||||
]);
|
||||
|
||||
await getCachedDetection('darwin');
|
||||
await getCachedDetection('darwin');
|
||||
await getCachedDetection('darwin');
|
||||
|
||||
expect(mockedDetectAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('re-invokes detection after clearDetectionCache', async () => {
|
||||
mockedDetectAll.mockResolvedValueOnce([
|
||||
{ displayName: 'VS Code', id: 'vscode', installed: true },
|
||||
]);
|
||||
await getCachedDetection('darwin');
|
||||
expect(mockedDetectAll).toHaveBeenCalledTimes(1);
|
||||
|
||||
clearDetectionCache();
|
||||
mockedDetectAll.mockResolvedValueOnce([
|
||||
{ displayName: 'VS Code', id: 'vscode', installed: false },
|
||||
]);
|
||||
await getCachedDetection('darwin');
|
||||
|
||||
expect(mockedDetectAll).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,274 +0,0 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { access } from 'node:fs/promises';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { detectAllApps, detectApp } from '../detectors';
|
||||
import { extractAllIcons } from '../iconExtractor';
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock node:fs/promises
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
access: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock node:child_process - execFile is wrapped via promisify, so the mock must
|
||||
// expose execFile as the underlying callback-style function we can drive.
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the icon extractor — detection tests should not depend on real icon
|
||||
// extraction. The default returns an empty Map (no icons) which leaves the
|
||||
// `icon` field absent from all detection results.
|
||||
vi.mock('../iconExtractor', () => ({
|
||||
extractAllIcons: vi.fn(async () => new Map<string, string>()),
|
||||
}));
|
||||
|
||||
const mockedAccess = vi.mocked(access);
|
||||
const mockedExecFile = vi.mocked(execFile) as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
interface ExecOutcome {
|
||||
code: number;
|
||||
error?: NodeJS.ErrnoException;
|
||||
stderr?: string;
|
||||
stdout?: string;
|
||||
}
|
||||
|
||||
const respondExec = (outcome: ExecOutcome) => {
|
||||
mockedExecFile.mockImplementationOnce(
|
||||
(_file: string, _args: string[], _opts: unknown, cb: any) => {
|
||||
const callback = typeof _opts === 'function' ? _opts : cb;
|
||||
if (outcome.code === 0) {
|
||||
callback(null, outcome.stdout ?? '', outcome.stderr ?? '');
|
||||
} else {
|
||||
const err: NodeJS.ErrnoException & { stderr?: string } =
|
||||
outcome.error ?? new Error('exec failed');
|
||||
err.stderr = outcome.stderr ?? '';
|
||||
(err as any).code = outcome.code;
|
||||
callback(err, '', outcome.stderr ?? '');
|
||||
}
|
||||
return undefined as any;
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('detectApp', () => {
|
||||
describe('appBundle strategy', () => {
|
||||
it('returns true when fs.access resolves for any path', async () => {
|
||||
mockedAccess.mockRejectedValueOnce(new Error('missing'));
|
||||
mockedAccess.mockResolvedValueOnce(undefined);
|
||||
|
||||
const result = await detectApp('terminal', 'darwin');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockedAccess).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('returns false when all paths reject', async () => {
|
||||
mockedAccess.mockRejectedValue(new Error('missing'));
|
||||
|
||||
const result = await detectApp('vscode', 'darwin');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('commandV strategy', () => {
|
||||
it('returns true on exit 0', async () => {
|
||||
respondExec({ code: 0, stdout: '/usr/bin/zed' });
|
||||
|
||||
const result = await detectApp('zed', 'linux');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockedExecFile).toHaveBeenCalledWith(
|
||||
'/bin/sh',
|
||||
['-c', 'command -v "zed"'],
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false on non-zero exit', async () => {
|
||||
respondExec({ code: 1, stderr: 'not found' });
|
||||
|
||||
const result = await detectApp('zed', 'linux');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects unsafe binary names without spawning a shell', async () => {
|
||||
// We monkey-patch a registry entry transiently to inject a malicious binary.
|
||||
const registry = await import('../registry');
|
||||
const originalGhostty = registry.APP_REGISTRY.ghostty.detect.linux;
|
||||
registry.APP_REGISTRY.ghostty.detect.linux = {
|
||||
binary: 'foo; rm -rf /',
|
||||
type: 'commandV',
|
||||
};
|
||||
|
||||
const result = await detectApp('ghostty', 'linux');
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockedExecFile).not.toHaveBeenCalled();
|
||||
|
||||
registry.APP_REGISTRY.ghostty.detect.linux = originalGhostty;
|
||||
});
|
||||
});
|
||||
|
||||
describe('registryAppPaths strategy', () => {
|
||||
it('returns true on exit 0', async () => {
|
||||
respondExec({ code: 0, stdout: 'C:\\Program Files\\code.exe' });
|
||||
|
||||
const result = await detectApp('vscode', 'win32');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockedExecFile).toHaveBeenCalledWith(
|
||||
'where',
|
||||
['Code.exe'],
|
||||
{ windowsHide: true },
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false on non-zero exit', async () => {
|
||||
respondExec({ code: 1, stderr: 'not found' });
|
||||
|
||||
const result = await detectApp('vscode', 'win32');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns false when platform has no detect entry for the app', async () => {
|
||||
const result = await detectApp('xcode', 'linux');
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockedAccess).not.toHaveBeenCalled();
|
||||
expect(mockedExecFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns true for ALWAYS_INSTALLED entries without probing', async () => {
|
||||
const darwinFinder = await detectApp('finder', 'darwin');
|
||||
const win32Explorer = await detectApp('explorer', 'win32');
|
||||
const linuxFiles = await detectApp('files', 'linux');
|
||||
|
||||
expect(darwinFinder).toBe(true);
|
||||
expect(win32Explorer).toBe(true);
|
||||
expect(linuxFiles).toBe(true);
|
||||
expect(mockedAccess).not.toHaveBeenCalled();
|
||||
expect(mockedExecFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectAllApps', () => {
|
||||
it('returns one entry per AppId regardless of platform', async () => {
|
||||
mockedAccess.mockRejectedValue(new Error('missing'));
|
||||
mockedExecFile.mockImplementation((_file: string, _args: string[], _opts: unknown, cb: any) => {
|
||||
const callback = typeof _opts === 'function' ? _opts : cb;
|
||||
const err: NodeJS.ErrnoException = new Error('fail');
|
||||
callback(err, '', '');
|
||||
return undefined as any;
|
||||
});
|
||||
|
||||
const apps = await detectAllApps('linux');
|
||||
|
||||
const registry = await import('../registry');
|
||||
expect(apps.length).toBe(Object.keys(registry.APP_REGISTRY).length);
|
||||
// every entry has the three required fields
|
||||
for (const app of apps) {
|
||||
expect(app).toEqual(
|
||||
expect.objectContaining({
|
||||
displayName: expect.any(String),
|
||||
id: expect.any(String),
|
||||
installed: expect.any(Boolean),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('marks unsupported-on-platform apps as not installed', async () => {
|
||||
mockedAccess.mockRejectedValue(new Error('missing'));
|
||||
mockedExecFile.mockImplementation((_file: string, _args: string[], _opts: unknown, cb: any) => {
|
||||
const callback = typeof _opts === 'function' ? _opts : cb;
|
||||
const err: NodeJS.ErrnoException = new Error('fail');
|
||||
callback(err, '', '');
|
||||
return undefined as any;
|
||||
});
|
||||
|
||||
const apps = await detectAllApps('linux');
|
||||
|
||||
const xcode = apps.find((a) => a.id === 'xcode');
|
||||
expect(xcode?.installed).toBe(false);
|
||||
});
|
||||
|
||||
it('marks ALWAYS_INSTALLED platform file manager as installed without probes', async () => {
|
||||
mockedAccess.mockRejectedValue(new Error('missing'));
|
||||
mockedExecFile.mockImplementation((_file: string, _args: string[], _opts: unknown, cb: any) => {
|
||||
const callback = typeof _opts === 'function' ? _opts : cb;
|
||||
const err: NodeJS.ErrnoException = new Error('fail');
|
||||
callback(err, '', '');
|
||||
return undefined as any;
|
||||
});
|
||||
|
||||
const apps = await detectAllApps('darwin');
|
||||
|
||||
const finder = apps.find((a) => a.id === 'finder');
|
||||
expect(finder?.installed).toBe(true);
|
||||
});
|
||||
|
||||
it('merges extracted icons onto installed apps only', async () => {
|
||||
mockedAccess.mockRejectedValue(new Error('missing'));
|
||||
mockedExecFile.mockImplementation((_file: string, _args: string[], _opts: unknown, cb: any) => {
|
||||
const callback = typeof _opts === 'function' ? _opts : cb;
|
||||
const err: NodeJS.ErrnoException = new Error('fail');
|
||||
callback(err, '', '');
|
||||
return undefined as any;
|
||||
});
|
||||
|
||||
vi.mocked(extractAllIcons).mockResolvedValueOnce(
|
||||
new Map([['finder', 'data:image/png;base64,FAKE']]),
|
||||
);
|
||||
|
||||
const apps = await detectAllApps('darwin');
|
||||
|
||||
const finder = apps.find((a) => a.id === 'finder');
|
||||
expect(finder?.icon).toBe('data:image/png;base64,FAKE');
|
||||
|
||||
// not-installed apps must not have an icon field
|
||||
const xcode = apps.find((a) => a.id === 'xcode');
|
||||
expect(xcode?.installed).toBe(false);
|
||||
expect(xcode?.icon).toBeUndefined();
|
||||
});
|
||||
|
||||
it('passes only installed AppIds to extractAllIcons', async () => {
|
||||
mockedAccess.mockRejectedValue(new Error('missing'));
|
||||
mockedExecFile.mockImplementation((_file: string, _args: string[], _opts: unknown, cb: any) => {
|
||||
const callback = typeof _opts === 'function' ? _opts : cb;
|
||||
const err: NodeJS.ErrnoException = new Error('fail');
|
||||
callback(err, '', '');
|
||||
return undefined as any;
|
||||
});
|
||||
|
||||
vi.mocked(extractAllIcons).mockResolvedValueOnce(new Map());
|
||||
|
||||
await detectAllApps('darwin');
|
||||
|
||||
expect(extractAllIcons).toHaveBeenCalledTimes(1);
|
||||
const [ids, platform] = vi.mocked(extractAllIcons).mock.calls[0];
|
||||
expect(platform).toBe('darwin');
|
||||
// only finder is ALWAYS_INSTALLED on darwin; all others fail probes
|
||||
expect(ids).toEqual(['finder']);
|
||||
});
|
||||
});
|
||||
@@ -1,261 +0,0 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { access, mkdtemp, readFile, unlink } from 'node:fs/promises';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { __resetForTest, extractAllIcons, extractAppIcon } from '../iconExtractor';
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
access: vi.fn(),
|
||||
mkdtemp: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedAccess = vi.mocked(access);
|
||||
const mockedMkdtemp = vi.mocked(mkdtemp);
|
||||
const mockedReadFile = vi.mocked(readFile);
|
||||
const mockedUnlink = vi.mocked(unlink);
|
||||
const mockedExecFile = vi.mocked(execFile) as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
/**
|
||||
* Drives the next execFile call. The promisified callback signature is
|
||||
* `(error, stdout, stderr)`; non-error responses resolve with stdout.
|
||||
*/
|
||||
const respondExec = (
|
||||
match: { args?: string[]; binary: string },
|
||||
outcome: { error?: Error; stderr?: string; stdout?: string },
|
||||
) => {
|
||||
mockedExecFile.mockImplementationOnce(
|
||||
(_file: string, _args: string[], _opts: unknown, cb: any) => {
|
||||
const callback = typeof _opts === 'function' ? _opts : cb;
|
||||
if (_file !== match.binary) {
|
||||
callback(new Error(`unexpected binary: ${_file}`), '', '');
|
||||
return undefined as any;
|
||||
}
|
||||
if (match.args && JSON.stringify(_args) !== JSON.stringify(match.args)) {
|
||||
callback(new Error(`unexpected args: ${JSON.stringify(_args)}`), '', '');
|
||||
return undefined as any;
|
||||
}
|
||||
if (outcome.error) {
|
||||
callback(outcome.error, '', outcome.stderr ?? '');
|
||||
} else {
|
||||
callback(null, outcome.stdout ?? '', outcome.stderr ?? '');
|
||||
}
|
||||
return undefined as any;
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// Shorthand: tools-available probe passes (which plutil + which sips both 0).
|
||||
const respondToolsAvailable = () => {
|
||||
// /usr/bin/which plutil
|
||||
respondExec({ binary: '/usr/bin/which' }, { stdout: '/usr/bin/plutil\n' });
|
||||
// /usr/bin/which sips
|
||||
respondExec({ binary: '/usr/bin/which' }, { stdout: '/usr/bin/sips\n' });
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedAccess.mockReset();
|
||||
mockedMkdtemp.mockReset();
|
||||
mockedReadFile.mockReset();
|
||||
mockedUnlink.mockReset();
|
||||
mockedExecFile.mockReset();
|
||||
mockedUnlink.mockResolvedValue(undefined);
|
||||
__resetForTest();
|
||||
});
|
||||
|
||||
describe('extractAppIcon', () => {
|
||||
it('returns a data URL when plutil + sips succeed on darwin', async () => {
|
||||
respondToolsAvailable();
|
||||
mockedAccess.mockResolvedValueOnce(undefined); // bundle exists
|
||||
// plutil CFBundleIconFile lookup
|
||||
respondExec({ binary: 'plutil' }, { stdout: 'Code.icns\n' });
|
||||
mockedAccess.mockResolvedValueOnce(undefined); // .icns exists
|
||||
mockedMkdtemp.mockResolvedValueOnce('/tmp/lobehub-openinapp-test');
|
||||
// sips conversion
|
||||
respondExec({ binary: 'sips' }, { stdout: '' });
|
||||
mockedReadFile.mockResolvedValueOnce(Buffer.from([0x89, 0x50, 0x4e, 0x47])); // PNG header
|
||||
|
||||
const result = await extractAppIcon('vscode', 'darwin');
|
||||
|
||||
expect(result).toBe(
|
||||
`data:image/png;base64,${Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString('base64')}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('appends .icns suffix when CFBundleIconFile has no extension', async () => {
|
||||
respondToolsAvailable();
|
||||
mockedAccess.mockResolvedValueOnce(undefined); // bundle exists
|
||||
respondExec({ binary: 'plutil' }, { stdout: 'Terminal\n' });
|
||||
mockedAccess.mockImplementationOnce(async (p: any) => {
|
||||
// .icns existence check — verify suffix appended
|
||||
if (typeof p === 'string' && p.endsWith('Terminal.icns')) return undefined;
|
||||
throw new Error('wrong path: ' + String(p));
|
||||
});
|
||||
mockedMkdtemp.mockResolvedValueOnce('/tmp/lobehub-openinapp-test');
|
||||
respondExec({ binary: 'sips' }, { stdout: '' });
|
||||
mockedReadFile.mockResolvedValueOnce(Buffer.from([0x89, 0x50]));
|
||||
|
||||
const result = await extractAppIcon('terminal', 'darwin');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.startsWith('data:image/png;base64,')).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to the next path when the first bundle does not exist', async () => {
|
||||
respondToolsAvailable();
|
||||
// terminal has two candidate paths; first fails, second succeeds.
|
||||
mockedAccess.mockRejectedValueOnce(new Error('missing'));
|
||||
mockedAccess.mockResolvedValueOnce(undefined);
|
||||
respondExec({ binary: 'plutil' }, { stdout: 'Terminal\n' });
|
||||
mockedAccess.mockResolvedValueOnce(undefined);
|
||||
mockedMkdtemp.mockResolvedValueOnce('/tmp/lobehub-openinapp-test');
|
||||
respondExec({ binary: 'sips' }, { stdout: '' });
|
||||
mockedReadFile.mockResolvedValueOnce(Buffer.from([0xff]));
|
||||
|
||||
const result = await extractAppIcon('terminal', 'darwin');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns undefined when no bundle path exists', async () => {
|
||||
respondToolsAvailable();
|
||||
mockedAccess.mockRejectedValue(new Error('missing'));
|
||||
|
||||
const result = await extractAppIcon('vscode', 'darwin');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when plutil cannot read CFBundleIconFile', async () => {
|
||||
respondToolsAvailable();
|
||||
mockedAccess.mockResolvedValueOnce(undefined);
|
||||
respondExec({ binary: 'plutil' }, { error: new Error('plutil: not found') });
|
||||
|
||||
const result = await extractAppIcon('vscode', 'darwin');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when the resolved .icns is missing', async () => {
|
||||
respondToolsAvailable();
|
||||
mockedAccess.mockResolvedValueOnce(undefined); // bundle exists
|
||||
respondExec({ binary: 'plutil' }, { stdout: 'Code.icns\n' });
|
||||
mockedAccess.mockRejectedValueOnce(new Error('missing icns')); // .icns missing
|
||||
|
||||
const result = await extractAppIcon('vscode', 'darwin');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when sips fails', async () => {
|
||||
respondToolsAvailable();
|
||||
mockedAccess.mockResolvedValueOnce(undefined);
|
||||
respondExec({ binary: 'plutil' }, { stdout: 'Code.icns\n' });
|
||||
mockedAccess.mockResolvedValueOnce(undefined);
|
||||
mockedMkdtemp.mockResolvedValueOnce('/tmp/lobehub-openinapp-test');
|
||||
respondExec({ binary: 'sips' }, { error: new Error('sips error') });
|
||||
|
||||
const result = await extractAppIcon('vscode', 'darwin');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when the produced PNG is empty', async () => {
|
||||
respondToolsAvailable();
|
||||
mockedAccess.mockResolvedValueOnce(undefined);
|
||||
respondExec({ binary: 'plutil' }, { stdout: 'Code.icns\n' });
|
||||
mockedAccess.mockResolvedValueOnce(undefined);
|
||||
mockedMkdtemp.mockResolvedValueOnce('/tmp/lobehub-openinapp-test');
|
||||
respondExec({ binary: 'sips' }, { stdout: '' });
|
||||
mockedReadFile.mockResolvedValueOnce(Buffer.alloc(0));
|
||||
|
||||
const result = await extractAppIcon('vscode', 'darwin');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when registry has no darwin entry for the app', async () => {
|
||||
respondToolsAvailable();
|
||||
const result = await extractAppIcon('explorer', 'darwin');
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockedAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns undefined on win32 (extractor is macOS-only)', async () => {
|
||||
const result = await extractAppIcon('vscode', 'win32');
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockedExecFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns undefined on linux (extractor is macOS-only)', async () => {
|
||||
const result = await extractAppIcon('vscode', 'linux');
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockedExecFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractAllIcons', () => {
|
||||
it('returns a map of only AppIds with successfully extracted icons', async () => {
|
||||
respondToolsAvailable();
|
||||
|
||||
// vscode succeeds
|
||||
mockedAccess.mockResolvedValueOnce(undefined); // bundle
|
||||
respondExec({ binary: 'plutil' }, { stdout: 'Code.icns\n' });
|
||||
mockedAccess.mockResolvedValueOnce(undefined); // .icns
|
||||
mockedMkdtemp.mockResolvedValueOnce('/tmp/lobehub-openinapp-test');
|
||||
respondExec({ binary: 'sips' }, { stdout: '' });
|
||||
mockedReadFile.mockResolvedValueOnce(Buffer.from('vscode'));
|
||||
|
||||
// cursor fails at bundle access (try all paths fail)
|
||||
mockedAccess.mockRejectedValue(new Error('missing'));
|
||||
|
||||
// xcode succeeds — reset access for it
|
||||
// (subsequent calls to mockedAccess will keep returning rejection)
|
||||
// So this test exercises: success, fail-no-bundle.
|
||||
|
||||
const map = await extractAllIcons(['vscode', 'cursor'], 'darwin');
|
||||
|
||||
expect(map.has('vscode')).toBe(true);
|
||||
expect(map.has('cursor')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns empty map when input list is empty', async () => {
|
||||
const map = await extractAllIcons([], 'darwin');
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
|
||||
it('does not throw when extraction errors', async () => {
|
||||
respondToolsAvailable();
|
||||
mockedAccess.mockResolvedValueOnce(undefined);
|
||||
respondExec({ binary: 'plutil' }, { error: new Error('boom') });
|
||||
|
||||
const map = await extractAllIcons(['vscode'], 'darwin');
|
||||
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
|
||||
it('skips all when tools are unavailable', async () => {
|
||||
// /usr/bin/which plutil fails
|
||||
respondExec({ binary: '/usr/bin/which' }, { error: new Error('not found') });
|
||||
|
||||
const map = await extractAllIcons(['vscode', 'terminal'], 'darwin');
|
||||
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,247 +0,0 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { access } from 'node:fs/promises';
|
||||
|
||||
import { shell } from 'electron';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { launchApp } from '../launchers';
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
access: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
shell: {
|
||||
openPath: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedAccess = vi.mocked(access);
|
||||
const mockedExecFile = vi.mocked(execFile) as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockedShell = vi.mocked(shell);
|
||||
|
||||
type LastCall = { file: string; args: string[] };
|
||||
|
||||
const captureExec = (): LastCall => {
|
||||
expect(mockedExecFile).toHaveBeenCalled();
|
||||
const [file, args] = mockedExecFile.mock.calls[0];
|
||||
return { args: args as string[], file: file as string };
|
||||
};
|
||||
|
||||
interface ExecOutcome {
|
||||
code: number;
|
||||
stderr?: string;
|
||||
stdout?: string;
|
||||
}
|
||||
|
||||
const respondExec = (outcome: ExecOutcome) => {
|
||||
mockedExecFile.mockImplementationOnce(
|
||||
(_file: string, _args: string[], _opts: unknown, cb: any) => {
|
||||
const callback = typeof _opts === 'function' ? _opts : cb;
|
||||
if (outcome.code === 0) {
|
||||
callback(null, outcome.stdout ?? '', outcome.stderr ?? '');
|
||||
} else {
|
||||
const err: NodeJS.ErrnoException & { stderr?: string } = new Error('exec failed');
|
||||
err.stderr = outcome.stderr ?? '';
|
||||
(err as any).code = outcome.code;
|
||||
callback(err, '', outcome.stderr ?? '');
|
||||
}
|
||||
return undefined as any;
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedAccess.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe('launchApp - path validation', () => {
|
||||
it('rejects relative paths', async () => {
|
||||
const result = await launchApp('vscode', 'relative/path', 'darwin');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Path must be absolute');
|
||||
expect(mockedExecFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects paths that do not exist', async () => {
|
||||
mockedAccess.mockRejectedValueOnce(new Error('ENOENT'));
|
||||
|
||||
const result = await launchApp('vscode', '/missing', 'darwin');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Path not found: /missing');
|
||||
expect(mockedExecFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns error when app is not available on platform', async () => {
|
||||
const result = await launchApp('xcode', '/some/path', 'linux');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Xcode');
|
||||
expect(result.error).toContain('not available on this platform');
|
||||
});
|
||||
});
|
||||
|
||||
describe('launchApp - macOpenA strategy', () => {
|
||||
it('spawns open -a <appName> <path>', async () => {
|
||||
respondExec({ code: 0 });
|
||||
|
||||
const result = await launchApp('vscode', '/work/dir', 'darwin');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const call = captureExec();
|
||||
expect(call.file).toBe('open');
|
||||
expect(call.args).toEqual(['-a', 'Visual Studio Code', '/work/dir']);
|
||||
});
|
||||
|
||||
it('returns stderr substring on failure', async () => {
|
||||
respondExec({ code: 1, stderr: ' cannot open Cursor.app ' });
|
||||
|
||||
const result = await launchApp('cursor', '/work/dir', 'darwin');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('cannot open Cursor.app');
|
||||
});
|
||||
});
|
||||
|
||||
describe('launchApp - macOpen strategy', () => {
|
||||
it('spawns open <path>', async () => {
|
||||
respondExec({ code: 0 });
|
||||
|
||||
const result = await launchApp('finder', '/work/dir', 'darwin');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const call = captureExec();
|
||||
expect(call.file).toBe('open');
|
||||
expect(call.args).toEqual(['/work/dir']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('launchApp - exec strategy', () => {
|
||||
it('spawns <binary> <path>', async () => {
|
||||
respondExec({ code: 0 });
|
||||
|
||||
const result = await launchApp('vscode', '/work/dir', 'linux');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const call = captureExec();
|
||||
expect(call.file).toBe('code');
|
||||
expect(call.args).toEqual(['/work/dir']);
|
||||
});
|
||||
|
||||
it('appends registry-provided args before path', async () => {
|
||||
const registry = await import('../registry');
|
||||
const original = registry.APP_REGISTRY.vscode.launch.linux;
|
||||
registry.APP_REGISTRY.vscode.launch.linux = {
|
||||
args: ['--new-window'],
|
||||
binary: 'code',
|
||||
type: 'exec',
|
||||
};
|
||||
|
||||
respondExec({ code: 0 });
|
||||
|
||||
const result = await launchApp('vscode', '/work/dir', 'linux');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const call = captureExec();
|
||||
expect(call.args).toEqual(['--new-window', '/work/dir']);
|
||||
|
||||
registry.APP_REGISTRY.vscode.launch.linux = original;
|
||||
});
|
||||
|
||||
it('rejects suspicious binary names', async () => {
|
||||
const registry = await import('../registry');
|
||||
const original = registry.APP_REGISTRY.vscode.launch.linux;
|
||||
registry.APP_REGISTRY.vscode.launch.linux = {
|
||||
binary: 'rm; ls',
|
||||
type: 'exec',
|
||||
};
|
||||
|
||||
const result = await launchApp('vscode', '/work/dir', 'linux');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Invalid binary name');
|
||||
expect(mockedExecFile).not.toHaveBeenCalled();
|
||||
|
||||
registry.APP_REGISTRY.vscode.launch.linux = original;
|
||||
});
|
||||
|
||||
it('rejects binary names with spaces', async () => {
|
||||
const registry = await import('../registry');
|
||||
const original = registry.APP_REGISTRY.vscode.launch.linux;
|
||||
registry.APP_REGISTRY.vscode.launch.linux = {
|
||||
binary: 'foo bar',
|
||||
type: 'exec',
|
||||
};
|
||||
|
||||
const result = await launchApp('vscode', '/work/dir', 'linux');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Invalid binary name');
|
||||
|
||||
registry.APP_REGISTRY.vscode.launch.linux = original;
|
||||
});
|
||||
|
||||
it('accepts absolute-path binary names', async () => {
|
||||
const registry = await import('../registry');
|
||||
const original = registry.APP_REGISTRY.vscode.launch.linux;
|
||||
registry.APP_REGISTRY.vscode.launch.linux = {
|
||||
binary: '/usr/local/bin/code',
|
||||
type: 'exec',
|
||||
};
|
||||
|
||||
respondExec({ code: 0 });
|
||||
|
||||
const result = await launchApp('vscode', '/work/dir', 'linux');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const call = captureExec();
|
||||
expect(call.file).toBe('/usr/local/bin/code');
|
||||
|
||||
registry.APP_REGISTRY.vscode.launch.linux = original;
|
||||
});
|
||||
|
||||
it('returns stderr substring on non-zero exit', async () => {
|
||||
respondExec({ code: 1, stderr: 'command not found' });
|
||||
|
||||
const result = await launchApp('vscode', '/work/dir', 'linux');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('command not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('launchApp - shellOpenPath strategy', () => {
|
||||
it('delegates to shell.openPath', async () => {
|
||||
mockedShell.openPath.mockResolvedValueOnce('');
|
||||
|
||||
const result = await launchApp('explorer', '/abs/work-dir', 'win32');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockedShell.openPath).toHaveBeenCalledWith('/abs/work-dir');
|
||||
});
|
||||
|
||||
it('returns error string from shell.openPath as error', async () => {
|
||||
mockedShell.openPath.mockResolvedValueOnce('cannot open');
|
||||
|
||||
const result = await launchApp('files', '/some/dir', 'linux');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('cannot open');
|
||||
});
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { DetectedApp } from '@lobechat/electron-client-ipc';
|
||||
|
||||
import { detectAllApps } from './detectors';
|
||||
|
||||
let cachedPromise: Promise<DetectedApp[]> | null = null;
|
||||
|
||||
export const getCachedDetection = (
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<DetectedApp[]> => {
|
||||
if (!cachedPromise) {
|
||||
cachedPromise = detectAllApps(platform);
|
||||
}
|
||||
return cachedPromise;
|
||||
};
|
||||
|
||||
export const clearDetectionCache = (): void => {
|
||||
cachedPromise = null;
|
||||
};
|
||||
@@ -1,109 +0,0 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { access } from 'node:fs/promises';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import type { DetectedApp, OpenInAppId } from '@lobechat/electron-client-ipc';
|
||||
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { extractAllIcons } from './iconExtractor';
|
||||
import type { DetectStrategy } from './registry';
|
||||
import { ALWAYS_INSTALLED, APP_REGISTRY } from './registry';
|
||||
|
||||
// Icon extraction shells out to plutil + sips on macOS (see iconExtractor.ts)
|
||||
// so Electron itself cannot crash on `app.getFileIcon` regressions. Renderer
|
||||
// falls back to lucide if extraction returns undefined.
|
||||
|
||||
const logger = createLogger('modules:openInApp:detectors');
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const SAFE_BINARY_REGEX = /^[\w.-]+$/;
|
||||
|
||||
const probeAppBundle = async (paths: string[]): Promise<boolean> => {
|
||||
for (const path of paths) {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const probeCommandV = async (binary: string): Promise<boolean> => {
|
||||
if (!SAFE_BINARY_REGEX.test(binary)) {
|
||||
logger.debug(`rejecting unsafe binary name for commandV: ${binary}`);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await execFileAsync('/bin/sh', ['-c', `command -v "${binary}"`]);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.debug(`commandV probe failed for ${binary}: ${(error as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const probeRegistryAppPaths = async (exeName: string): Promise<boolean> => {
|
||||
try {
|
||||
await execFileAsync('where', [exeName], { windowsHide: true });
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.debug(`where probe failed for ${exeName}: ${(error as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const runDetectStrategy = (strategy: DetectStrategy): Promise<boolean> => {
|
||||
switch (strategy.type) {
|
||||
case 'appBundle': {
|
||||
return probeAppBundle(strategy.paths);
|
||||
}
|
||||
case 'commandV': {
|
||||
return probeCommandV(strategy.binary);
|
||||
}
|
||||
case 'registryAppPaths': {
|
||||
return probeRegistryAppPaths(strategy.exeName);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const detectApp = async (id: OpenInAppId, platform: NodeJS.Platform): Promise<boolean> => {
|
||||
if (ALWAYS_INSTALLED[platform] === id) {
|
||||
return true;
|
||||
}
|
||||
const descriptor = APP_REGISTRY[id];
|
||||
const strategy = descriptor?.detect[platform];
|
||||
if (!strategy) {
|
||||
return false;
|
||||
}
|
||||
return runDetectStrategy(strategy);
|
||||
};
|
||||
|
||||
export const detectAllApps = async (
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<DetectedApp[]> => {
|
||||
const entries = Object.entries(APP_REGISTRY) as Array<
|
||||
[OpenInAppId, (typeof APP_REGISTRY)[OpenInAppId]]
|
||||
>;
|
||||
const installedFlags = await Promise.all(entries.map(([id]) => detectApp(id, platform)));
|
||||
|
||||
// Extract icons for installed apps only. Extraction shells out to plutil +
|
||||
// sips (see iconExtractor.ts) so it cannot crash the renderer; failures
|
||||
// resolve to undefined and the renderer falls back to lucide icons.
|
||||
const installedIds = entries.filter((_entry, i) => installedFlags[i]).map(([id]) => id);
|
||||
const icons = await extractAllIcons(installedIds, platform);
|
||||
|
||||
return entries.map(([id, descriptor], i) => {
|
||||
const installed = installedFlags[i];
|
||||
const icon = installed ? icons.get(id) : undefined;
|
||||
return {
|
||||
displayName: descriptor.displayName,
|
||||
id,
|
||||
installed,
|
||||
...(icon ? { icon } : {}),
|
||||
} satisfies DetectedApp;
|
||||
});
|
||||
};
|
||||
@@ -1,210 +0,0 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { access, mkdtemp, readFile, unlink } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { OpenInAppId } from '@lobechat/electron-client-ipc';
|
||||
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { APP_REGISTRY } from './registry';
|
||||
|
||||
const logger = createLogger('modules:openInApp:iconExtractor');
|
||||
|
||||
// Manual promise wrapper rather than util.promisify(execFile): the latter
|
||||
// relies on execFile's custom `util.promisify.custom` symbol to return
|
||||
// `{ stdout, stderr }`, which vi.fn() mocks don't carry — so destructuring
|
||||
// silently yields `undefined` under test. This wrapper resolves directly to
|
||||
// the stdout string and is mock-friendly.
|
||||
const execFileToString = (
|
||||
file: string,
|
||||
args: string[],
|
||||
opts?: { timeout?: number },
|
||||
): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const cb = (err: Error | null, stdout: string, stderr: string) => {
|
||||
if (err) {
|
||||
(err as Error & { stderr?: string }).stderr = stderr;
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(stdout);
|
||||
}
|
||||
};
|
||||
if (opts) execFile(file, args, opts, cb);
|
||||
else execFile(file, args, cb);
|
||||
});
|
||||
|
||||
/** Render dimensions for the extracted PNG. 64 keeps the payload tiny while
|
||||
* staying crisp at the renderer's 16-20 px display size on retina. */
|
||||
const ICON_SIZE = 64;
|
||||
|
||||
/** Per-extraction bound. plutil and sips are local file ops; tens of ms is
|
||||
* typical, so a generous timeout still catches real hangs. */
|
||||
const EXEC_TIMEOUT_MS = 5000;
|
||||
|
||||
let tmpDirPromise: Promise<string | undefined> | undefined;
|
||||
|
||||
const ensureTmpDir = async (): Promise<string | undefined> => {
|
||||
if (tmpDirPromise) return tmpDirPromise;
|
||||
tmpDirPromise = (async () => {
|
||||
try {
|
||||
return await mkdtemp(path.join(tmpdir(), 'lobehub-openinapp-'));
|
||||
} catch (error) {
|
||||
logger.debug(`failed to create tmp dir: ${(error as Error).message}`);
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
return tmpDirPromise;
|
||||
};
|
||||
|
||||
let toolsAvailablePromise: Promise<boolean> | undefined;
|
||||
|
||||
/**
|
||||
* Confirm `plutil` and `sips` are both on PATH. Both ship with every macOS
|
||||
* install so this is effectively a sanity check; cached for the process lifetime.
|
||||
*/
|
||||
const areToolsAvailable = (): Promise<boolean> => {
|
||||
if (toolsAvailablePromise) return toolsAvailablePromise;
|
||||
toolsAvailablePromise = (async () => {
|
||||
try {
|
||||
await execFileToString('/usr/bin/which', ['plutil']);
|
||||
await execFileToString('/usr/bin/which', ['sips']);
|
||||
return true;
|
||||
} catch {
|
||||
logger.debug('plutil or sips missing from PATH; falling back to renderer icons');
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
return toolsAvailablePromise;
|
||||
};
|
||||
|
||||
const resolveDarwinBundlePath = async (id: OpenInAppId): Promise<string | undefined> => {
|
||||
const strategy = APP_REGISTRY[id]?.detect.darwin;
|
||||
if (!strategy || strategy.type !== 'appBundle') return undefined;
|
||||
for (const candidate of strategy.paths) {
|
||||
try {
|
||||
await access(candidate);
|
||||
return candidate;
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Look up the bundle's icon file name via Info.plist (`CFBundleIconFile`).
|
||||
* Returns the resolved absolute .icns path, or undefined if not derivable.
|
||||
*/
|
||||
const resolveIcnsPath = async (bundlePath: string): Promise<string | undefined> => {
|
||||
const plistPath = path.join(bundlePath, 'Contents', 'Info.plist');
|
||||
try {
|
||||
const stdout = await execFileToString(
|
||||
'plutil',
|
||||
['-extract', 'CFBundleIconFile', 'raw', plistPath],
|
||||
{ timeout: EXEC_TIMEOUT_MS },
|
||||
);
|
||||
const iconName = stdout.trim();
|
||||
if (!iconName) return undefined;
|
||||
const fileName = iconName.endsWith('.icns') ? iconName : `${iconName}.icns`;
|
||||
const icnsPath = path.join(bundlePath, 'Contents', 'Resources', fileName);
|
||||
await access(icnsPath);
|
||||
return icnsPath;
|
||||
} catch (error) {
|
||||
logger.debug(`resolveIcnsPath failed for ${bundlePath}: ${(error as Error).message}`);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Resize/convert the given .icns to a 64×64 PNG using sips, then return the
|
||||
* base64 data URL. The PNG file is unlinked after read.
|
||||
*/
|
||||
const renderIcnsToDataUrl = async (
|
||||
icnsPath: string,
|
||||
tmpDir: string,
|
||||
filename: string,
|
||||
): Promise<string | undefined> => {
|
||||
const outPath = path.join(tmpDir, filename);
|
||||
try {
|
||||
await execFileToString(
|
||||
'sips',
|
||||
[
|
||||
'-z',
|
||||
String(ICON_SIZE),
|
||||
String(ICON_SIZE),
|
||||
'-s',
|
||||
'format',
|
||||
'png',
|
||||
icnsPath,
|
||||
'--out',
|
||||
outPath,
|
||||
],
|
||||
{ timeout: EXEC_TIMEOUT_MS },
|
||||
);
|
||||
const buf = await readFile(outPath);
|
||||
if (buf.length === 0) return undefined;
|
||||
return `data:image/png;base64,${buf.toString('base64')}`;
|
||||
} catch (error) {
|
||||
logger.debug(`sips failed for ${icnsPath}: ${(error as Error).message}`);
|
||||
return undefined;
|
||||
} finally {
|
||||
unlink(outPath).catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract the real macOS app icon for the given AppId by reading the bundle's
|
||||
* Info.plist (`CFBundleIconFile`) and rendering the resolved .icns via `sips`.
|
||||
* Both `plutil` and `sips` ship with every macOS install — no Xcode, swift, or
|
||||
* electron-builder bundling required, and no JXA / NSImage drawing path
|
||||
* (which is broken in JXA: lockFocus and NSGraphicsContext class methods are
|
||||
* not exposed). macOS only; other platforms return undefined.
|
||||
*/
|
||||
export const extractAppIcon = async (
|
||||
id: OpenInAppId,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<string | undefined> => {
|
||||
if (platform !== 'darwin') return undefined;
|
||||
try {
|
||||
if (!(await areToolsAvailable())) return undefined;
|
||||
const bundlePath = await resolveDarwinBundlePath(id);
|
||||
if (!bundlePath) return undefined;
|
||||
const icnsPath = await resolveIcnsPath(bundlePath);
|
||||
if (!icnsPath) return undefined;
|
||||
const tmpDir = await ensureTmpDir();
|
||||
if (!tmpDir) return undefined;
|
||||
return await renderIcnsToDataUrl(icnsPath, tmpDir, `${id}.png`);
|
||||
} catch (error) {
|
||||
logger.debug(`extractAppIcon error for ${id}: ${(error as Error).message}`);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve icons for a list of installed AppIds. Sequential — keeps spawn
|
||||
* pressure low and matches the underlying single-thread tools.
|
||||
*/
|
||||
export const extractAllIcons = async (
|
||||
installedIds: OpenInAppId[],
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<Map<OpenInAppId, string>> => {
|
||||
const map = new Map<OpenInAppId, string>();
|
||||
for (const id of installedIds) {
|
||||
try {
|
||||
const icon = await extractAppIcon(id, platform);
|
||||
if (icon) map.set(id, icon);
|
||||
} catch (error) {
|
||||
logger.debug(`extractAllIcons: skipping ${id} after error: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
/**
|
||||
* Test-only: reset the module-level caches so each test starts fresh.
|
||||
*/
|
||||
export const __resetForTest = () => {
|
||||
tmpDirPromise = undefined;
|
||||
toolsAvailablePromise = undefined;
|
||||
};
|
||||
@@ -1,106 +0,0 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { access } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import type { OpenInAppId, OpenInAppResult } from '@lobechat/electron-client-ipc';
|
||||
import { shell } from 'electron';
|
||||
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import type { LaunchStrategy } from './registry';
|
||||
import { APP_REGISTRY } from './registry';
|
||||
|
||||
const logger = createLogger('modules:openInApp:launchers');
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const SAFE_BINARY_REGEX = /^[\w.-]+$/;
|
||||
|
||||
const isAllowedBinary = (binary: string): boolean =>
|
||||
SAFE_BINARY_REGEX.test(binary) || path.isAbsolute(binary);
|
||||
|
||||
interface ExecError extends Error {
|
||||
stderr?: string;
|
||||
}
|
||||
|
||||
const formatExecError = (error: unknown): string => {
|
||||
const err = error as ExecError;
|
||||
const stderr = typeof err?.stderr === 'string' ? err.stderr.trim() : '';
|
||||
const fallback = err?.message ?? 'Launch failed';
|
||||
return (stderr || fallback).slice(0, 200);
|
||||
};
|
||||
|
||||
const runLaunchStrategy = async (
|
||||
strategy: LaunchStrategy,
|
||||
absolutePath: string,
|
||||
): Promise<OpenInAppResult> => {
|
||||
switch (strategy.type) {
|
||||
case 'macOpenA': {
|
||||
try {
|
||||
await execFileAsync('open', ['-a', strategy.appName, absolutePath]);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { error: formatExecError(error), success: false };
|
||||
}
|
||||
}
|
||||
case 'macOpen': {
|
||||
try {
|
||||
await execFileAsync('open', [absolutePath]);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { error: formatExecError(error), success: false };
|
||||
}
|
||||
}
|
||||
case 'exec': {
|
||||
if (!isAllowedBinary(strategy.binary)) {
|
||||
return { error: 'Invalid binary name', success: false };
|
||||
}
|
||||
const extraArgs = strategy.args ?? [];
|
||||
try {
|
||||
await execFileAsync(strategy.binary, [...extraArgs, absolutePath]);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { error: formatExecError(error), success: false };
|
||||
}
|
||||
}
|
||||
case 'shellOpenPath': {
|
||||
const result = await shell.openPath(absolutePath);
|
||||
return result ? { error: result, success: false } : { success: true };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const launchApp = async (
|
||||
id: OpenInAppId,
|
||||
absolutePath: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<OpenInAppResult> => {
|
||||
const descriptor = APP_REGISTRY[id];
|
||||
const strategy = descriptor?.launch[platform];
|
||||
if (!descriptor || !strategy) {
|
||||
const displayName = descriptor?.displayName ?? id;
|
||||
return {
|
||||
error: `${displayName} is not available on this platform`,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!path.isAbsolute(absolutePath)) {
|
||||
return { error: 'Path must be absolute', success: false };
|
||||
}
|
||||
|
||||
try {
|
||||
await access(absolutePath);
|
||||
} catch {
|
||||
return { error: `Path not found: ${absolutePath}`, success: false };
|
||||
}
|
||||
|
||||
const result = await runLaunchStrategy(strategy, absolutePath);
|
||||
if (result.success) {
|
||||
logger.info(`launched ${id} at ${absolutePath}`);
|
||||
} else {
|
||||
logger.error(`failed to launch ${id} at ${absolutePath}: ${result.error}`);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -1,129 +0,0 @@
|
||||
import type { OpenInAppId } from '@lobechat/electron-client-ipc';
|
||||
|
||||
export type DetectStrategy =
|
||||
| { paths: string[]; type: 'appBundle' }
|
||||
| { exeName: string; type: 'registryAppPaths' }
|
||||
| { binary: string; type: 'commandV' };
|
||||
|
||||
export type LaunchStrategy =
|
||||
| { appName: string; type: 'macOpenA' }
|
||||
| { type: 'macOpen' }
|
||||
| { args?: string[]; binary: string; type: 'exec' }
|
||||
| { type: 'shellOpenPath' };
|
||||
|
||||
export interface AppDescriptor {
|
||||
detect: Partial<Record<NodeJS.Platform, DetectStrategy>>;
|
||||
displayName: string;
|
||||
launch: Partial<Record<NodeJS.Platform, LaunchStrategy>>;
|
||||
}
|
||||
|
||||
export const APP_REGISTRY: Record<OpenInAppId, AppDescriptor> = {
|
||||
vscode: {
|
||||
detect: {
|
||||
darwin: { paths: ['/Applications/Visual Studio Code.app'], type: 'appBundle' },
|
||||
linux: { binary: 'code', type: 'commandV' },
|
||||
win32: { exeName: 'Code.exe', type: 'registryAppPaths' },
|
||||
},
|
||||
displayName: 'VS Code',
|
||||
launch: {
|
||||
darwin: { appName: 'Visual Studio Code', type: 'macOpenA' },
|
||||
linux: { binary: 'code', type: 'exec' },
|
||||
win32: { binary: 'code', type: 'exec' },
|
||||
},
|
||||
},
|
||||
cursor: {
|
||||
detect: {
|
||||
darwin: { paths: ['/Applications/Cursor.app'], type: 'appBundle' },
|
||||
linux: { binary: 'cursor', type: 'commandV' },
|
||||
win32: { exeName: 'Cursor.exe', type: 'registryAppPaths' },
|
||||
},
|
||||
displayName: 'Cursor',
|
||||
launch: {
|
||||
darwin: { appName: 'Cursor', type: 'macOpenA' },
|
||||
linux: { binary: 'cursor', type: 'exec' },
|
||||
win32: { binary: 'cursor', type: 'exec' },
|
||||
},
|
||||
},
|
||||
zed: {
|
||||
detect: {
|
||||
darwin: { paths: ['/Applications/Zed.app'], type: 'appBundle' },
|
||||
linux: { binary: 'zed', type: 'commandV' },
|
||||
},
|
||||
displayName: 'Zed',
|
||||
launch: {
|
||||
darwin: { appName: 'Zed', type: 'macOpenA' },
|
||||
linux: { binary: 'zed', type: 'exec' },
|
||||
},
|
||||
},
|
||||
webstorm: {
|
||||
detect: {
|
||||
darwin: { paths: ['/Applications/WebStorm.app'], type: 'appBundle' },
|
||||
linux: { binary: 'webstorm', type: 'commandV' },
|
||||
win32: { exeName: 'webstorm64.exe', type: 'registryAppPaths' },
|
||||
},
|
||||
displayName: 'WebStorm',
|
||||
launch: {
|
||||
darwin: { appName: 'WebStorm', type: 'macOpenA' },
|
||||
linux: { binary: 'webstorm', type: 'exec' },
|
||||
win32: { binary: 'webstorm', type: 'exec' },
|
||||
},
|
||||
},
|
||||
xcode: {
|
||||
detect: { darwin: { paths: ['/Applications/Xcode.app'], type: 'appBundle' } },
|
||||
displayName: 'Xcode',
|
||||
launch: { darwin: { appName: 'Xcode', type: 'macOpenA' } },
|
||||
},
|
||||
finder: {
|
||||
detect: {
|
||||
darwin: { paths: ['/System/Library/CoreServices/Finder.app'], type: 'appBundle' },
|
||||
},
|
||||
displayName: 'Finder',
|
||||
launch: { darwin: { type: 'macOpen' } },
|
||||
},
|
||||
explorer: {
|
||||
detect: { win32: { exeName: 'explorer.exe', type: 'registryAppPaths' } },
|
||||
displayName: 'Explorer',
|
||||
launch: { win32: { type: 'shellOpenPath' } },
|
||||
},
|
||||
files: {
|
||||
detect: { linux: { binary: 'xdg-open', type: 'commandV' } },
|
||||
displayName: 'Files',
|
||||
launch: { linux: { type: 'shellOpenPath' } },
|
||||
},
|
||||
terminal: {
|
||||
detect: {
|
||||
darwin: {
|
||||
paths: [
|
||||
'/System/Applications/Utilities/Terminal.app',
|
||||
'/Applications/Utilities/Terminal.app',
|
||||
],
|
||||
type: 'appBundle',
|
||||
},
|
||||
},
|
||||
displayName: 'Terminal',
|
||||
launch: { darwin: { appName: 'Terminal', type: 'macOpenA' } },
|
||||
},
|
||||
iterm2: {
|
||||
detect: { darwin: { paths: ['/Applications/iTerm.app'], type: 'appBundle' } },
|
||||
displayName: 'iTerm2',
|
||||
launch: { darwin: { appName: 'iTerm', type: 'macOpenA' } },
|
||||
},
|
||||
ghostty: {
|
||||
detect: {
|
||||
darwin: { paths: ['/Applications/Ghostty.app'], type: 'appBundle' },
|
||||
linux: { binary: 'ghostty', type: 'commandV' },
|
||||
},
|
||||
displayName: 'Ghostty',
|
||||
launch: {
|
||||
darwin: { appName: 'Ghostty', type: 'macOpenA' },
|
||||
linux: { binary: 'ghostty', type: 'exec' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** AppIds that are always considered "installed" — file managers, which we treat as platform-provided. */
|
||||
export const ALWAYS_INSTALLED: Partial<Record<NodeJS.Platform, OpenInAppId>> = {
|
||||
darwin: 'finder',
|
||||
linux: 'files',
|
||||
win32: 'explorer',
|
||||
};
|
||||
@@ -1,185 +0,0 @@
|
||||
import * as childProcess from 'node:child_process';
|
||||
import * as os from 'node:os';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mocks must be set up before importing the module under test, because the
|
||||
// module captures `promisify(execFile)` / `promisify(exec)` at import time.
|
||||
vi.mock('node:os', async () => {
|
||||
const actual = await vi.importActual<typeof os>('node:os');
|
||||
return { ...actual, platform: vi.fn(() => actual.platform()) };
|
||||
});
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
exec: vi.fn(),
|
||||
execFile: vi.fn(),
|
||||
}));
|
||||
|
||||
const platformMock = vi.mocked(os.platform);
|
||||
const execFileMock = vi.mocked(childProcess.execFile);
|
||||
const execMock = vi.mocked(childProcess.exec);
|
||||
|
||||
const noErr = null;
|
||||
const callExecFile = (stdout: string, stderr = '') => {
|
||||
execFileMock.mockImplementationOnce(((file: string, args: any, opts: any, cb: any) => {
|
||||
// promisify-wrapped: the callback is always the last positional arg.
|
||||
const callback = typeof opts === 'function' ? opts : cb;
|
||||
callback(noErr, { stdout, stderr });
|
||||
return {} as any;
|
||||
}) as any);
|
||||
};
|
||||
const callExecFileError = (err: Error) => {
|
||||
execFileMock.mockImplementationOnce(((file: string, args: any, opts: any, cb: any) => {
|
||||
const callback = typeof opts === 'function' ? opts : cb;
|
||||
callback(err, { stdout: '', stderr: '' });
|
||||
return {} as any;
|
||||
}) as any);
|
||||
};
|
||||
const callExec = (stdout: string, stderr = '') => {
|
||||
execMock.mockImplementationOnce(((cmd: string, opts: any, cb: any) => {
|
||||
const callback = typeof opts === 'function' ? opts : cb;
|
||||
callback(noErr, { stdout, stderr });
|
||||
return {} as any;
|
||||
}) as any);
|
||||
};
|
||||
|
||||
describe('cliAgentDetectors', () => {
|
||||
beforeEach(() => {
|
||||
execFileMock.mockReset();
|
||||
execMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe('on Windows with an npm-installed `claude.cmd` shim', () => {
|
||||
beforeEach(() => {
|
||||
platformMock.mockReturnValue('win32');
|
||||
});
|
||||
|
||||
it('resolves `claude` to the .cmd path via `where`, then runs it through the shell', async () => {
|
||||
// 1) `where claude` → resolves to the .cmd shim under %APPDATA%\npm
|
||||
callExecFile('C:\\Users\\Hanam\\AppData\\Roaming\\npm\\claude.cmd\r\n');
|
||||
// 2) `cmd /c "...\\claude.cmd" --version` → keyword match
|
||||
callExec('1.2.3 (Claude Code)');
|
||||
|
||||
const { claudeCodeDetector } = await import('../cliAgentDetectors');
|
||||
const status = await claudeCodeDetector.detect();
|
||||
|
||||
expect(status.available).toBe(true);
|
||||
expect(status.path).toBe('C:\\Users\\Hanam\\AppData\\Roaming\\npm\\claude.cmd');
|
||||
expect(status.version).toBe('1.2.3 (Claude Code)');
|
||||
|
||||
// The validation call must go via `exec` (shell), NOT `execFile`, so
|
||||
// cmd.exe can actually interpret the .cmd shim.
|
||||
expect(execMock).toHaveBeenCalledTimes(1);
|
||||
const execCall = execMock.mock.calls[0]!;
|
||||
expect(execCall[0]).toBe('"C:\\Users\\Hanam\\AppData\\Roaming\\npm\\claude.cmd" --version');
|
||||
});
|
||||
|
||||
it('returns unavailable when `where` finds nothing', async () => {
|
||||
callExecFileError(new Error('not found'));
|
||||
|
||||
const { claudeCodeDetector } = await import('../cliAgentDetectors');
|
||||
const status = await claudeCodeDetector.detect();
|
||||
|
||||
expect(status.available).toBe(false);
|
||||
// We should NOT proceed to invoke anything after a failed resolve.
|
||||
expect(execMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects custom commands containing shell metacharacters', async () => {
|
||||
const { detectHeterogeneousCliCommand } = await import('../cliAgentDetectors');
|
||||
const status = await detectHeterogeneousCliCommand('claude-code', 'claude & calc.exe');
|
||||
|
||||
expect(status.available).toBe(false);
|
||||
expect(execFileMock).not.toHaveBeenCalled();
|
||||
expect(execMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails detection when version output does not match the expected keyword', async () => {
|
||||
callExecFile('C:\\some\\other\\claude.cmd\r\n');
|
||||
callExec('this is some other binary v1.0');
|
||||
|
||||
const { claudeCodeDetector } = await import('../cliAgentDetectors');
|
||||
const status = await claudeCodeDetector.detect();
|
||||
|
||||
expect(status.available).toBe(false);
|
||||
});
|
||||
|
||||
it('prefers a .cmd shim when `where` returns multiple PATHEXT matches (codex case)', async () => {
|
||||
// npm drops a Unix shell-script wrapper (extensionless) alongside the
|
||||
// Windows `.cmd` / `.ps1` shims. `where` lists every PATHEXT match;
|
||||
// taking the first line would land us on the unrunnable wrapper.
|
||||
callExecFile(
|
||||
[
|
||||
'C:\\Users\\Hanam\\AppData\\Roaming\\npm\\codex',
|
||||
'C:\\Users\\Hanam\\AppData\\Roaming\\npm\\codex.cmd',
|
||||
'C:\\Users\\Hanam\\AppData\\Roaming\\npm\\codex.ps1',
|
||||
].join('\r\n'),
|
||||
);
|
||||
callExec('codex 0.130.0');
|
||||
|
||||
const { codexDetector } = await import('../cliAgentDetectors');
|
||||
const status = await codexDetector.detect();
|
||||
|
||||
expect(status.available).toBe(true);
|
||||
expect(status.path).toBe('C:\\Users\\Hanam\\AppData\\Roaming\\npm\\codex.cmd');
|
||||
expect(execMock.mock.calls[0]![0]).toBe(
|
||||
'"C:\\Users\\Hanam\\AppData\\Roaming\\npm\\codex.cmd" --version',
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers .exe over .cmd when both are present', async () => {
|
||||
callExecFile(['C:\\tools\\foo.exe', 'C:\\tools\\foo.cmd'].join('\r\n'));
|
||||
callExecFile('claude code 1.0.0');
|
||||
|
||||
const { claudeCodeDetector } = await import('../cliAgentDetectors');
|
||||
const status = await claudeCodeDetector.detect();
|
||||
|
||||
expect(status.available).toBe(true);
|
||||
expect(status.path).toBe('C:\\tools\\foo.exe');
|
||||
// .exe runs directly via execFile — no shell.
|
||||
expect(execMock).not.toHaveBeenCalled();
|
||||
expect(execFileMock).toHaveBeenCalledTimes(2);
|
||||
expect(execFileMock.mock.calls[1]![0]).toBe('C:\\tools\\foo.exe');
|
||||
});
|
||||
|
||||
it('reports unavailable when `where` only returns unrunnable matches (.ps1 / extensionless)', async () => {
|
||||
callExecFile(
|
||||
[
|
||||
'C:\\Users\\Hanam\\AppData\\Roaming\\npm\\claude',
|
||||
'C:\\Users\\Hanam\\AppData\\Roaming\\npm\\claude.ps1',
|
||||
].join('\r\n'),
|
||||
);
|
||||
|
||||
const { claudeCodeDetector } = await import('../cliAgentDetectors');
|
||||
const status = await claudeCodeDetector.detect();
|
||||
|
||||
expect(status.available).toBe(false);
|
||||
// Must not attempt to invoke the unrunnable matches.
|
||||
expect(execMock).not.toHaveBeenCalled();
|
||||
expect(execFileMock).toHaveBeenCalledTimes(1); // just `where`
|
||||
});
|
||||
});
|
||||
|
||||
describe('on macOS / Linux with a Unix-style claude binary', () => {
|
||||
beforeEach(() => {
|
||||
platformMock.mockReturnValue('darwin');
|
||||
});
|
||||
|
||||
it('runs the binary directly via execFile (no shell)', async () => {
|
||||
callExecFile('/usr/local/bin/claude\n');
|
||||
callExecFile('1.2.3 (Claude Code)');
|
||||
|
||||
const { claudeCodeDetector } = await import('../cliAgentDetectors');
|
||||
const status = await claudeCodeDetector.detect();
|
||||
|
||||
expect(status.available).toBe(true);
|
||||
expect(status.path).toBe('/usr/local/bin/claude');
|
||||
expect(execMock).not.toHaveBeenCalled();
|
||||
expect(execFileMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,11 @@
|
||||
import { exec, execFile } from 'node:child_process';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { platform } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import type { IToolDetector, ToolStatus } from '@/core/infrastructure/ToolDetectorManager';
|
||||
import { createCommandDetector } from '@/core/infrastructure/ToolDetectorManager';
|
||||
|
||||
const execFilePromise = promisify(execFile);
|
||||
const execPromise = promisify(exec);
|
||||
|
||||
type HeterogeneousCliAgentType = 'claude-code' | 'codex';
|
||||
|
||||
@@ -19,54 +17,17 @@ interface ValidatedDetectorOptions {
|
||||
validateKeywords: string[];
|
||||
}
|
||||
|
||||
const isWindows = () => platform() === 'win32';
|
||||
|
||||
// Reject anything that could break out of the `cmd /c "<path>" --version`
|
||||
// shell line we build for Windows .cmd shims (see `detectValidatedCommand`).
|
||||
// User-supplied custom commands flow through here via `detectHeterogeneousCliCommand`.
|
||||
const WINDOWS_SHELL_METAS = /[&|;<>^`!"]/;
|
||||
|
||||
// Extensions we can actually execute on Windows, in preference order:
|
||||
// `.exe` runs directly via `execFile`, `.cmd` / `.bat` runs via `cmd.exe`.
|
||||
// `.ps1` and extensionless wrappers (npm sometimes drops a Unix shell script
|
||||
// next to the `.cmd` shim) are deliberately excluded — we can't run them.
|
||||
const WINDOWS_RUNNABLE_EXTS = ['.exe', '.cmd', '.bat'] as const;
|
||||
|
||||
const pickWindowsRunnable = (lines: string[]): string | undefined => {
|
||||
for (const ext of WINDOWS_RUNNABLE_EXTS) {
|
||||
const match = lines.find((line) => line.toLowerCase().endsWith(ext));
|
||||
if (match) return match;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const resolveCommandPath = async (command: string): Promise<string | undefined> => {
|
||||
const trimmedCommand = command.trim();
|
||||
if (!trimmedCommand) return;
|
||||
|
||||
if (path.isAbsolute(trimmedCommand) || trimmedCommand.includes(path.sep)) {
|
||||
return trimmedCommand;
|
||||
}
|
||||
|
||||
const whichCommand = isWindows() ? 'where' : 'which';
|
||||
const whichCommand = platform() === 'win32' ? 'where' : 'which';
|
||||
|
||||
try {
|
||||
const { stdout } = await execFilePromise(whichCommand, [trimmedCommand], { timeout: 3000 });
|
||||
const lines = stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
if (lines.length === 0) return undefined;
|
||||
|
||||
// Windows `where` lists every PATHEXT match (e.g. for `codex` npm ships
|
||||
// a Unix shell wrapper alongside `codex.cmd` and `codex.ps1`). Picking
|
||||
// the first line can land us on something we can't execute, so prefer a
|
||||
// runnable extension and bail otherwise.
|
||||
if (isWindows()) return pickWindowsRunnable(lines);
|
||||
|
||||
return lines[0];
|
||||
return stdout.trim().split(/\r?\n/)[0] || trimmedCommand;
|
||||
} catch {
|
||||
return undefined;
|
||||
return trimmedCommand;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -76,27 +37,14 @@ const detectValidatedCommand = async (
|
||||
): Promise<ToolStatus> => {
|
||||
const trimmedCommand = command.trim();
|
||||
if (!trimmedCommand) return { available: false };
|
||||
if (isWindows() && WINDOWS_SHELL_METAS.test(trimmedCommand)) return { available: false };
|
||||
|
||||
const { validateFlag = '--version', validateKeywords } = options;
|
||||
|
||||
// Resolve via where/which BEFORE invoking. On Windows this is what discovers
|
||||
// npm-installed shims like `claude.cmd` under %APPDATA%\npm — `execFile`
|
||||
// alone won't apply PATHEXT and can't run .cmd files directly.
|
||||
const resolvedPath = await resolveCommandPath(trimmedCommand);
|
||||
if (!resolvedPath) return { available: false };
|
||||
|
||||
try {
|
||||
const needsShell = isWindows() && /\.(?:cmd|bat)$/i.test(resolvedPath);
|
||||
const { stderr, stdout } = needsShell
|
||||
? await execPromise(`"${resolvedPath}" ${validateFlag}`, {
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
})
|
||||
: await execFilePromise(resolvedPath, [validateFlag], {
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
});
|
||||
const { stderr, stdout } = await execFilePromise(trimmedCommand, [validateFlag], {
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
});
|
||||
const output = `${stdout}\n${stderr}`.trim();
|
||||
const loweredOutput = output.toLowerCase();
|
||||
|
||||
@@ -106,7 +54,7 @@ const detectValidatedCommand = async (
|
||||
|
||||
return {
|
||||
available: true,
|
||||
path: resolvedPath,
|
||||
path: await resolveCommandPath(trimmedCommand),
|
||||
version: output.split(/\r?\n/)[0],
|
||||
};
|
||||
} catch {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { randomUUID } from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
|
||||
import type {
|
||||
AgentRunRequestMessage,
|
||||
SystemInfoRequestMessage,
|
||||
ToolCallRequestMessage,
|
||||
} from '@lobechat/device-gateway-client';
|
||||
@@ -22,10 +21,6 @@ interface ToolCallHandler {
|
||||
(apiName: string, args: any): Promise<unknown>;
|
||||
}
|
||||
|
||||
interface AgentRunHandler {
|
||||
(request: AgentRunRequestMessage): Promise<{ reason?: string; status: 'accepted' | 'rejected' }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* GatewayConnectionService
|
||||
*
|
||||
@@ -40,7 +35,6 @@ 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 ───
|
||||
|
||||
@@ -65,10 +59,6 @@ export default class GatewayConnectionService extends ServiceModule {
|
||||
this.toolCallHandler = handler;
|
||||
}
|
||||
|
||||
setAgentRunHandler(handler: AgentRunHandler) {
|
||||
this.agentRunHandler = handler;
|
||||
}
|
||||
|
||||
// ─── Device ID ───
|
||||
|
||||
loadOrCreateDeviceId() {
|
||||
@@ -188,10 +178,6 @@ 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();
|
||||
@@ -253,30 +239,6 @@ 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 (
|
||||
|
||||
@@ -4,46 +4,22 @@ export const getExportMimeType = (filePath: string) => {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
|
||||
const map: Record<string, string> = {
|
||||
'.bash': 'text/plain; charset=utf-8',
|
||||
'.c': 'text/plain; charset=utf-8',
|
||||
'.cpp': 'text/plain; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.csv': 'text/csv; charset=utf-8',
|
||||
'.dockerfile': 'text/plain; charset=utf-8',
|
||||
'.fish': 'text/plain; charset=utf-8',
|
||||
'.gif': 'image/gif',
|
||||
'.go': 'text/plain; charset=utf-8',
|
||||
'.graphql': 'application/graphql; charset=utf-8',
|
||||
'.h': 'text/plain; charset=utf-8',
|
||||
'.hpp': 'text/plain; charset=utf-8',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.ico': 'image/x-icon',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.js': 'application/javascript; charset=utf-8',
|
||||
'.jsx': 'application/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.log': 'text/plain; charset=utf-8',
|
||||
'.map': 'application/json; charset=utf-8',
|
||||
'.md': 'text/markdown; charset=utf-8',
|
||||
'.mdx': 'text/markdown; charset=utf-8',
|
||||
'.mp4': 'video/mp4',
|
||||
'.png': 'image/png',
|
||||
'.py': 'text/plain; charset=utf-8',
|
||||
'.rs': 'text/plain; charset=utf-8',
|
||||
'.sh': 'text/plain; charset=utf-8',
|
||||
'.svg': 'image/svg+xml; charset=utf-8',
|
||||
'.toml': 'application/toml; charset=utf-8',
|
||||
'.ts': 'text/plain; charset=utf-8',
|
||||
'.tsx': 'text/plain; charset=utf-8',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.xml': 'application/xml; charset=utf-8',
|
||||
'.yaml': 'application/yaml; charset=utf-8',
|
||||
'.yml': 'application/yaml; charset=utf-8',
|
||||
'.webp': 'image/webp',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.zsh': 'text/plain; charset=utf-8',
|
||||
};
|
||||
|
||||
return map[ext];
|
||||
|
||||
@@ -3,8 +3,7 @@ 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_MODEL = 'deepseek-v4-pro';
|
||||
export const DEFAULT_MODEL = 'claude-sonnet-4-6';
|
||||
export const DEFAULT_ONBOARDING_MODEL = 'gemini-3-flash-preview';
|
||||
export const DEFAULT_ONBOARDING_PROVIDER = 'google';
|
||||
export const DEFAULT_PROVIDER = 'deepseek';
|
||||
export const DEFAULT_PROVIDER = 'openai';
|
||||
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 { AgentRunRequestMessage, DeviceAttachment, Env } from './types';
|
||||
import type { DeviceAttachment, Env } from './types';
|
||||
|
||||
const AUTH_TIMEOUT = 10_000; // 10s to authenticate after connect
|
||||
const HEARTBEAT_TIMEOUT = 90_000; // 90s without heartbeat → close
|
||||
@@ -31,9 +31,6 @@ 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);
|
||||
@@ -105,16 +102,12 @@ 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' ||
|
||||
data.type === 'agent_run_ack'
|
||||
) {
|
||||
const pending = this.pendingRequests.get(data.requestId ?? data.operationId);
|
||||
if (data.type === 'tool_call_response' || data.type === 'system_info_response') {
|
||||
const pending = this.pendingRequests.get(data.requestId);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve(data.type === 'agent_run_ack' ? data : data.result);
|
||||
this.pendingRequests.delete(data.requestId ?? data.operationId);
|
||||
pending.resolve(data.result);
|
||||
this.pendingRequests.delete(data.requestId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,67 +278,13 @@ 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> {
|
||||
const sockets = this.getAuthenticatedSockets();
|
||||
if (sockets.length === 0) {
|
||||
return Response.json(
|
||||
{ content: 'Desktop device offline', error: 'DEVICE_OFFLINE', success: false },
|
||||
{ content: '桌面设备不在线', error: 'DEVICE_OFFLINE', success: false },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
@@ -395,7 +334,7 @@ export class DeviceGatewayDO extends DurableObject<Env> {
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{
|
||||
content: `Tool call timed out (${timeout / 1000}s)`,
|
||||
content: `工具调用超时(${timeout / 1000}s)`,
|
||||
error: (err as Error).message,
|
||||
success: false,
|
||||
},
|
||||
|
||||
@@ -92,42 +92,12 @@ 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,12 +1,4 @@
|
||||
[
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["hide runtime-only model aliases."],
|
||||
"features": ["set OSS default model to DeepSeek V4 Pro."]
|
||||
},
|
||||
"date": "2026-05-09",
|
||||
"version": "2.1.57"
|
||||
},
|
||||
{
|
||||
"children": {},
|
||||
"date": "2026-05-01",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"https://file.rene.wang/540830955-0fe626a3-0ddc-4f67-b595-3c5b3f1701e0.png": "/blog/assetsa8e504275f2cd891fabecca985998de0.webp",
|
||||
"https://file.rene.wang/Changelog-Seedance.png": "/blog/assetsb2bf4ddf0a45ff887a993c18cb7ab983.webp",
|
||||
"https://file.rene.wang/changlog-04-14.png": "/blog/assets300abe7e259d293da6c5ed4f642a1be6.webp",
|
||||
"https://file.rene.wang/clipboard-1768907980491-9cc0669fc3a38.png": "/blog/assets8be3a46c8f9c5d3b61bc541f44b7f245.webp",
|
||||
"https://file.rene.wang/clipboard-1768908081787-ed9eb1cb78bdb.png": "/blog/assetsab009b79dd794f02aec24b7607f342e8.webp",
|
||||
@@ -54,8 +53,6 @@
|
||||
"https://file.rene.wang/clipboard-1774923001079-89ce6aa271a62.png": "/blog/assets53e6ec9cf72554dbc1f8224fc0550a03.webp",
|
||||
"https://file.rene.wang/clipboard-1775701725582-123f8f8cf73f8.png": "/blog/assets7ea204859aeb5aa9be5810a20ba1669a.webp",
|
||||
"https://file.rene.wang/clipboard-1776909505252-94b051f3ea0a7.png": "/blog/assetsdfda32866c4bc59af0526e52f31d1da2.webp",
|
||||
"https://file.rene.wang/clipboard-1777343750668-9b3dcb0dfff86.png": "/blog/assetsfa267a02f20bc5ba6f1273bcf27b7c9f.webp",
|
||||
"https://file.rene.wang/clipboard-1778331942656-f33b41b2dc439.png": "/blog/assets71fe5959cbc6f0a89243d7262f48fafc.webp",
|
||||
"https://file.rene.wang/lobehub/467951f5-ad65-498d-aea9-fca8f35a4314.png": "/blog/assets907ea775d228958baca38e2dbb65939a.webp",
|
||||
"https://file.rene.wang/lobehub/58d91528-373a-4a42-b520-cf6cb1f8ce1e.png": "/blog/assets7dccdd4df55aede71001da649639437f.webp",
|
||||
"https://file.rene.wang/lobehub/ee700103-3c08-41dc-9ddf-c7705bb7bc6a.png": "/blog/assets196d679bc7071abbf71f2a8566f05aa3.webp",
|
||||
@@ -472,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/task.png": "/blog/assets4aa1732a45832afc780600e6e329860c.webp"
|
||||
"https://file.rene.wang/clipboard-1777343750668-9b3dcb0dfff86.png": "/blog/assetsfa267a02f20bc5ba6f1273bcf27b7c9f.webp",
|
||||
"https://file.rene.wang/Changelog-Seedance.png": "/blog/assetsb2bf4ddf0a45ff887a993c18cb7ab983.webp"
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
---
|
||||
title: Delegate Claude Code and Codex
|
||||
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.
|
||||
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
|
||||
@@ -13,12 +14,9 @@ tags:
|
||||
|
||||
# Delegate Claude Code and Codex
|
||||
|
||||
Now you can control coding agents in LobeHub. Simply click `Create Agent` and choose your coding agent. This feature is only available on desktop app.
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user