Compare commits

..

2 Commits

Author SHA1 Message Date
rdmclin2 8d38d59e8e fix: add telegram timeout error 2026-05-08 16:36:21 +07:00
rdmclin2 41c71655b6 fix: bot message callback 2026-05-08 16:31:31 +07:00
1569 changed files with 30727 additions and 103148 deletions
-2
View File
@@ -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
-2
View File
@@ -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
+298
View File
@@ -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.
+6 -6
View File
@@ -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.
@@ -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 |
-1
View File
@@ -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
View File
@@ -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
---
+6 -3
View File
@@ -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 -2
View File
@@ -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 -2
View File
@@ -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
+39 -33
View File
@@ -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"
-6
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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 -2
View File
@@ -1,7 +1,6 @@
---
name: react
description: "LobeHub React/SPA component conventions: antd-style with `createStaticStyles` + `cssVar.*` (prefer zero-runtime over `createStyles` + `token`), `@lobehub/ui/base-ui` primitives before `@lobehub/ui` before antd, `Flexbox`/`Center` for layouts, react-router-dom navigation, and the `.desktop.tsx` sync rule. Use when writing or editing any `.tsx` under `src/**`, picking a styling helper, choosing a component (Select/Modal/Drawer/Button/Tooltip), wiring routes in `desktopRouter.config.tsx`/`.desktop.tsx`, or adding a `Link`/`useNavigate` call in the SPA. Triggers on `createStyles`/`createStaticStyles`, `cssVar`, `@lobehub/ui`, `antd-style`, `Flexbox`, `useNavigate`, `react-router-dom`, `Link`, 'new component', 'add a page', 'edit a layout', 'desktopRouter', 'componentMap.desktop'."
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
-1
View File
@@ -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
+4 -5
View File
@@ -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.
+380 -70
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2 -7
View File
@@ -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
@@ -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,
},
},
);
```
+259 -21
View File
@@ -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
@@ -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 812 (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 -2
View File
@@ -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
View File
@@ -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
-3
View File
@@ -148,6 +148,3 @@ apps/desktop/resources/cli-package.json
.superpowers/
docs/superpowers/
.heerogeneous-tracing
# Kagura agent runtime
.kagura/
-37
View File
@@ -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">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#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
View File
@@ -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 . .
+4 -1
View File
@@ -1,6 +1,6 @@
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
.\" Manual command details come from the Commander command tree.
.TH LH 1 "" "@lobehub/cli 0.0.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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lobehub/cli",
"version": "0.0.15",
"version": "0.0.11",
"type": "module",
"bin": {
"lh": "./dist/index.js",
+1 -1
View File
@@ -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;
+5 -4
View File
@@ -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}`);
+172
View File
@@ -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();
});
});
});
+271
View File
@@ -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
View File
@@ -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);
}
+1 -1
View File
@@ -208,7 +208,7 @@ function readAgentProfile(workspacePath: string): AgentProfile {
// Try to extract **Emoji:** value (single emoji)
const emojiMatch = content.match(/\*{0,2}Emoji:?\*{0,2}\s*(.+)/i);
const rawAvatar = emojiMatch ? emojiMatch[1].trim() : undefined;
// Filter out placeholder text like (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;
-17
View File
@@ -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', () => {
+1 -5
View File
@@ -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);
+1 -1
View File
@@ -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)')
+2
View File
@@ -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);
-99
View File
@@ -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
});
}
}
-38
View File
@@ -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`);
+3 -3
View File
@@ -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));
}
+3 -3
View File
@@ -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,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 -68
View File
@@ -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);
}
}
@@ -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,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,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 (
@@ -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';
+7 -68
View File
@@ -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,
},
-30
View File
@@ -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
-8
View File
@@ -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",
+2 -4
View File
@@ -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"
}
+5 -7
View File
@@ -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.
![](/blog/assets71fe5959cbc6f0a89243d7262f48fafc.webp)
## 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
@@ -1,8 +1,6 @@
---
title: 在 LobeHub 中调度 Claude Code 与 Codex
description: >-
在 LobeHub 中直接调度 Claude Code 与 Codex,全新首页、批量 git diff 的 Review
标签页、视觉理解工具,以及一批新模型。
description: 在 LobeHub 中直接调度 Claude Code 与 Codex,全新首页、批量 git diff 的 Review 标签页、视觉理解工具,以及一批新模型。
tags:
- 编程 Agent
- Claude Code
@@ -13,10 +11,6 @@ tags:
# 在 LobeHub 中调度 Claude Code 与 Codex
现在你可以在 LobeHub 内使用 Coding Agents。新建助手时选择你最喜欢的 Coding Agent 即可。此功能仅在桌面端可用。
![](/blog/assets71fe5959cbc6f0a89243d7262f48fafc.webp)
## 新功能
- 新增:在 LobeHub 中调度 Claude Code 与 Codex
@@ -1,43 +0,0 @@
---
title: Agent Tasks GA & Cloud Heterogeneous Agent
description: >-
Agent Tasks reaches GA with templates, cron, and batch runs; heterogeneous
agents now run in the cloud; bot platforms expand to Messenger, Line, and
Telegram.
tags:
- Agent Tasks
- Heterogeneous Agent
- Bots
- Models
---
# Agent Tasks GA & Cloud Heterogeneous Agent
## Tasks
Think of Agent Tasks like Linear, but with agents as your teammates. Create tasks the same way you'd file an issue — title, description, optional template — and assign them to an agent instead of a person. The agent picks up the task, executes the work, posts updates in comments, and moves the status forward (todo → in progress → done) as it makes progress.
Tasks can have subtasks with explicit dependencies, so a parent task can fan out work and the agent will run subtasks in dependency order. Recurring tasks can be wired to a cron schedule, parent assignments can be reshuffled at any time, and every task has its own thread of comments where you and the agent can coordinate.
Learn more in the [Task guide](/docs/usage/getting-started/task).
## Features
- Agent Tasks goes GA: the full task platform with templates, scheduled cron, comment tools, parent reassignment, and dependency-ordered batch subtask runs
- Nightly self-review: Agent Signal pipeline runs automatic self-review with skill-aware policies and pushes activity into briefs
- Cloud heterogeneous agents: Claude Code and Codex now execute server-side with persistent sessions that survive Vercel replica restarts
- `lh hetero exec` CLI: run a standalone heterogeneous agent from the terminal, with multimodal input support across desktop / CLI
- Claude Code can now pause and ask you a question mid-execution
- Inline agents in chat: `lobeAgents` markdown tag renders agent profile cards, and a newly created agent shows up as a clickable card
- Bot platforms expand: Messenger, Line, and Telegram integrations with DM pair policy and per-sender device tool gating
- New models: Gemini 3.1 Flash-Lite, SiliconCloud model sync, and DeepSeek V4 Pro as the new OSS default
## Improvements and fixes
- Inline document grounding in the KB tool via BM25 search and `docs_*` reads.
- Daily Brief redesigned with linkable welcome card and a paired input hint; resolved briefs now show a mute icon.
- Long tool-call parameters now wrap instead of truncating; tool execution time formatted as `Xmin Ys`.
- Visible divider between queued messages so it's clear which sends are pending.
- Copy session ID added to the topic dropdown menu.
- Home sidebar collapse state persists across reloads.
- Desktop app tray visibility is now a setting.
@@ -1,42 +0,0 @@
---
title: Agent 任务系统 GA 与云端异构 Agent
description: >-
Agent 任务系统正式发布,支持模板、Cron 与批量子任务;异构 Agent 进入云端;Bot 平台新增 Messenger、Line 与
Telegram。
tags:
- Agent 任务
- 异构 Agent
- Bot
- 模型
---
# Agent 任务系统 GA 与云端异构 Agent
## Agent 任务系统
Agent 任务系统的体感类似 Linear,但「队友」是 Agent。你像建 Issue 一样创建任务 —— 标题、描述、可选模板 —— 把它分配给 Agent 而不是某个人。Agent 接到任务后会执行工作、在评论中同步进展,并随着推进更新状态(待办 → 进行中 → 已完成)。
任务支持带显式依赖的子任务,父任务可以拆分工作,Agent 会按依赖顺序运行子任务。周期性任务可以挂接 Cron 计划;父任务的指派可以随时重新调整;每个任务都有自己的评论线,方便你和 Agent 协作沟通。
详见 [任务使用指南](/docs/usage/getting-started/task)。
## 新功能
- Agent 任务系统 GA:完整的任务平台,支持模板、Cron 定时、评论工具、父任务重指派,以及按依赖顺序的批量子任务运行
- 夜间自审:Agent Signal 流水线自动运行自审,结合技能感知策略并将活动推送到简报
- 云端异构 AgentClaude Code 与 Codex 在服务端运行,会话持久化可跨 Vercel 副本恢复
- `lh hetero exec` CLI:在终端独立运行异构 Agent,桌面端 / CLI 支持多模态输入
- AskUserQuestion 工具:Claude Code 可在执行过程中暂停并向你提问
- 聊天内联 Agent`lobeAgents` Markdown 标签渲染 Agent 卡片,新建的 Agent 会以可点击卡片形式出现
- Bot 平台扩展:新增 Messenger、Line、Telegram 接入,支持 DM 配对策略与按发送者识别的设备工具网关
- 新模型:Gemini 3.1 Flash-Lite、SiliconCloud 模型同步,DeepSeek V4 Pro 成为开源版默认模型
## 体验优化与修复
- 知识库工具支持通过 BM25 搜索与 `docs_*` 读取实现内联文档落地。
- 每日简报改版:欢迎卡片可链接、输入提示成对出现;已处理的简报展示静音图标。
- 工具调用参数过长时自动换行,不再截断;工具执行时间格式化为 `Xmin Ys`。
- 排队消息之间新增可见分隔线,方便辨认待发送的内容。
- 话题下拉菜单新增「复制会话 ID」操作。
- 首页侧边栏的折叠状态在刷新后会保留。
- 桌面应用托盘可见性现已纳入设置。
-8
View File
@@ -2,14 +2,6 @@
"$schema": "https://github.com/lobehub/lobe-chat/blob/main/docs/changelog/schema.json",
"cloud": [],
"community": [
{
"image": "/blog/assets4aa1732a45832afc780600e6e329860c.webp",
"id": "2026-05-11-agent-tasks-ga",
"date": "2026-05-11",
"versionRange": [
"2.1.57"
]
},
{
"image": "/blog/assetsb2bf4ddf0a45ff887a993c18cb7ab983.webp",
"id": "2026-05-04-task-scheduler",
+1 -115
View File
@@ -267,62 +267,6 @@ table agent_eval_test_cases {
}
}
table agent_operations {
id text [pk, not null]
user_id text [not null]
agent_id text
topic_id text
thread_id text
task_id text
chat_group_id text
parent_operation_id text
status text [not null]
completion_reason text
started_at "timestamp with time zone"
completed_at "timestamp with time zone"
step_count integer
max_steps integer
force_finish boolean
interruption jsonb
error jsonb
total_cost "numeric(20, 6)"
currency text [not null, default: 'USD']
total_input_tokens integer
total_output_tokens integer
total_tokens integer
llm_calls integer
tool_calls integer
human_interventions integer
processing_time_ms integer
human_waiting_time_ms integer
cost jsonb
usage jsonb
cost_limit jsonb
model text
provider text
model_runtime_config jsonb
trigger text
app_context jsonb
trace_s3_key text
metadata jsonb [default: `{}`]
accessed_at "timestamp with time zone" [not null, default: `now()`]
created_at "timestamp with time zone" [not null, default: `now()`]
updated_at "timestamp with time zone" [not null, default: `now()`]
indexes {
user_id [name: 'agent_operations_user_id_idx']
agent_id [name: 'agent_operations_agent_id_idx']
topic_id [name: 'agent_operations_topic_id_idx']
thread_id [name: 'agent_operations_thread_id_idx']
task_id [name: 'agent_operations_task_id_idx']
chat_group_id [name: 'agent_operations_chat_group_id_idx']
parent_operation_id [name: 'agent_operations_parent_operation_id_idx']
status [name: 'agent_operations_status_idx']
(user_id, created_at) [name: 'agent_operations_user_id_created_at_idx']
metadata [name: 'agent_operations_metadata_idx']
}
}
table agent_skills {
id text [pk, not null]
name text [not null]
@@ -924,48 +868,6 @@ table messages_files {
}
}
table messenger_account_links {
id uuid [pk, not null, default: `gen_random_uuid()`]
user_id text [not null]
platform varchar(50) [not null]
tenant_id varchar(255) [not null, default: '']
platform_user_id varchar(255) [not null]
platform_username text
active_agent_id text
accessed_at "timestamp with time zone" [not null, default: `now()`]
created_at "timestamp with time zone" [not null, default: `now()`]
updated_at "timestamp with time zone" [not null, default: `now()`]
indexes {
(platform, tenant_id, platform_user_id) [name: 'messenger_account_links_platform_tenant_user_unique', unique]
(user_id, platform, tenant_id) [name: 'messenger_account_links_user_platform_tenant_unique', unique]
active_agent_id [name: 'messenger_account_links_active_agent_idx']
}
}
table messenger_installations {
id uuid [pk, not null, default: `gen_random_uuid()`]
platform varchar(50) [not null]
tenant_id varchar(255) [not null]
application_id varchar(255) [not null]
account_id varchar(255)
credentials text [not null]
metadata jsonb [not null, default: `{}`]
token_expires_at "timestamp with time zone"
installed_by_user_id text
installed_by_platform_user_id varchar(255)
revoked_at "timestamp with time zone"
accessed_at "timestamp with time zone" [not null, default: `now()`]
created_at "timestamp with time zone" [not null, default: `now()`]
updated_at "timestamp with time zone" [not null, default: `now()`]
indexes {
(platform, application_id, tenant_id) [name: 'messenger_installations_platform_app_tenant_unique', unique]
(platform, tenant_id) [name: 'messenger_installations_platform_tenant_idx']
token_expires_at [name: 'messenger_installations_token_expires_at_idx']
}
}
table nextauth_accounts {
access_token text
expires_at integer
@@ -1493,23 +1395,6 @@ table sessions {
}
}
table system_bot_providers {
id uuid [pk, not null, default: `gen_random_uuid()`]
platform varchar(50) [not null]
enabled boolean [not null, default: true]
credentials text [not null]
application_id varchar(255)
settings jsonb [not null, default: `{}`]
connection_mode varchar(20)
accessed_at "timestamp with time zone" [not null, default: `now()`]
created_at "timestamp with time zone" [not null, default: `now()`]
updated_at "timestamp with time zone" [not null, default: `now()`]
indexes {
platform [name: 'system_bot_providers_platform_unique', unique]
}
}
table briefs {
id text [pk, not null]
user_id text [not null]
@@ -2061,6 +1946,7 @@ table user_memory_persona_documents {
}
}
ref: agent_skills.user_id - users.id
ref: agent_skills.zip_file_hash - global_files.hash_id
-149
View File
@@ -1,149 +0,0 @@
---
title: Task
description: >-
Learn how to use Tasks in LobeHub to delegate work to agents. Create tasks, assign them to agents, track status, comment for follow-ups, and run tasks one-off or on a recurring schedule.
tags:
- Task
- Issue Tracker
- Agent Assignment
- Recurring Task
- Workflow
---
# Task
![](https://hub-apac-1.lobeobjects.space/blog/assets4aa1732a45832afc780600e6e329860c.webp)
**Task** turns a conversation with an agent into trackable work. Instead of chatting in real time and copying results around, you write down what you want, assign it to an agent, and let the agent run it in the background. The agent posts progress, updates the status when it's done, and replies when you leave a comment.
If you've used Linear or GitHub Issues, the mental model is the same — only the assignee is an agent, and the agent actually does the work.
## When to Use a Task
Use a Task when you want an agent to:
- Do work that takes more than a few minutes to finish.
- Run on a schedule (every morning, every Monday, every month).
- Report back asynchronously while you focus on something else.
- Be re-assigned, commented on, or revisited later with full history preserved.
For quick, one-shot questions, stay in the regular chat. For anything you'd otherwise track in a todo list or ticket, create a Task.
## Task Lifecycle
Every task moves through a small set of statuses:
| Status | Meaning |
| ------------------ | --------------------------------------------------------------- |
| **Backlog** | Created but not yet picked up by the agent. |
| **In Progress** | The agent is actively working on the task. |
| **Pending Review** | The agent finished and is waiting for you to verify the result. |
| **Done** | You confirmed the result; the task is closed. |
| **Canceled** | You closed the task before completion. |
The agent moves a task from `Backlog` to `In Progress`, then to `Pending Review` when it thinks the work is done. The transition to `Done` is yours to make — see [Reviewing Results](#reviewing-results) below.
## Creating a Task
<Steps>
### Open the Tasks Panel
Click **Tasks** in the left sidebar to open the task list for your workspace.
### Create a New Task
Click **New Task** in the top right. Give it a clear title — the agent uses the title and description to understand what you want.
### Write a Description
Describe the work the same way you'd describe it to a teammate. Include any links, files, or constraints the agent needs. You can paste images and attach Resources just like in a regular chat.
### Assign an Agent
Pick an agent from the **Assignee** dropdown. Choose an agent whose capabilities match the task — for example, the **Research Agent** for reading and summarizing, or a custom agent you've built. You can reassign later if the first agent isn't the right fit.
### Choose a Schedule
Pick **Run once** for a one-off task, or **Repeat** to put it on a schedule. See [One-off vs. Recurring](#one-off-vs-recurring) below.
### Submit
Click **Create**. The task lands in **Backlog**, and the agent picks it up shortly after.
</Steps>
<Callout type="info">
You can create a Task directly from any chat message — open the message menu and choose **Turn
into Task**. The conversation context is carried over automatically.
</Callout>
## Working With the Agent
While the task is `In Progress`, the agent posts updates inside the task — every step it takes, every tool call, and every intermediate result. You don't have to watch in real time; open the task whenever you want to see where things stand.
### Reviewing Results
When the agent thinks it's finished, the task moves to `Pending Review`. Open the task detail page to verify the result. You have two options:
- **Confirm Complete** — if the result is good, click the **Confirm Complete** button. The task moves to `Done` and closes out.
- **Follow up** — if something needs adjustment, leave a comment instead. The agent picks the task back up and continues from where it left off.
A `Pending Review` task never auto-completes; you stay in control of when work is done.
### Comments and Follow-ups
Every task has a comment thread. Use comments to:
- **Clarify** when the agent asks a question mid-run.
- **Course-correct** if the agent is heading in the wrong direction.
- **Iterate** at review time — leave a comment like _"Same thing but exclude weekends"_ and the agent reopens the task and tries again.
The agent reads new comments automatically and follows up. There's no separate "send" — your comment is the instruction.
<Callout type="info">
If the agent is in the middle of a run, your comment is queued until the next checkpoint so it
doesn't interrupt mid-step.
</Callout>
### Artifacts
Pages the agent creates during execution — research notes, summaries, drafts, anything written to your workspace — are listed in the **Artifacts** section of the task detail page. Open, share, or keep editing them directly from there without leaving the task.
## One-off vs. Recurring
Tasks support two schedule modes.
### Run Once
The default. The agent runs the task immediately, posts a result, and moves it to `Pending Review` for you to confirm. Use this for everything that doesn't need to repeat.
### Repeat
Put the task on a schedule and the agent re-runs it automatically. Each run is appended to the same task as a new entry, so you build up a history you can compare across runs.
Supported intervals:
- **Hourly** — every _N_ hours.
- **Daily** — at a specific time each day.
- **Weekly** — on chosen days of the week.
- **Monthly** — on a specific day of the month.
- **Custom** — any cron expression.
<Callout type="warning">
Recurring tasks consume credits on every run. Check the estimated credit cost shown in the
scheduler before saving, and pause the task if you no longer need it.
</Callout>
You can pause, resume, or change the schedule at any time from the task detail page. Pausing keeps history intact; deleting removes the task and its run history.
## Examples
A few patterns that work well as Tasks:
- **Daily market digest** — a Research Agent that summarizes overnight news every weekday at 8 AM.
- **Weekly competitor scan** — an agent that visits five competitor sites and flags pricing changes.
- **One-off deep research** — a long-running task ("compare these 12 vector databases") you check on later.
- **Recurring data pull** — an agent that queries a database and posts the result on Mondays.
- **Triage queue** — an inbox-like project where you drop ideas and an agent prepares first-draft answers overnight.
-147
View File
@@ -1,147 +0,0 @@
---
title: 任务
description: >-
了解如何在 LobeHub 中使用任务(Task)将工作委派给 Agent。创建任务、分配给 Agent、跟踪状态、通过评论进行追问,以及一次性运行或按周期重复执行。
tags:
- Task
- 任务
- Issue 跟踪
- Agent 分配
- 周期任务
- 工作流
---
# 任务
![](https://hub-apac-1.lobeobjects.space/blog/assets4aa1732a45832afc780600e6e329860c.webp)
**任务(Task)** 把你和 Agent 的对话变成可追踪的工作。不必实时聊天再到处复制结果,你可以写下需求、把它指派给一个 Agent,让 Agent 在后台为你完成。Agent 会回报进度、在完成后更新状态,并在你留下评论时继续跟进。
如果你用过 Linear 或 GitHub Issues,思维模型完全一致 —— 只是这里的执行人是 Agent,并且它会真的把活干完。
## 什么时候用任务
当你希望 Agent 做下面这些事情时,就适合开任务:
- 完成耗时超过几分钟的工作。
- 按计划运行(每天早上、每周一、每月一次)。
- 异步反馈进度,让你可以同时处理其他事情。
- 留下完整历史,便于后续重新分配、评论或回溯。
对于一次性的、很快能答完的问题,留在普通对话里就好。任何你原本会记在待办列表或工单里的事情,都建议开一个任务。
## 任务生命周期
每个任务会在一组简单的状态之间流转:
| 状态 | 含义 |
| ------------------ | ---------------------------------- |
| **Backlog** | 已创建,Agent 还没开始处理。 |
| **In Progress** | Agent 正在执行该任务。 |
| **Pending Review** | Agent 完成了工作,等待你验收结果。 |
| **Done** | 你已确认结果,任务关闭。 |
| **Canceled** | 在完成前你主动关闭了该任务。 |
Agent 会自动把任务从 `Backlog` 推进到 `In Progress`,再到 `Pending Review`。任务什么时候变成 `Done`,由你决定 —— 详见下方的 [验收结果](#验收结果)。
## 创建任务
<Steps>
### 打开任务面板
点击左侧导航中的 **Tasks**,进入当前工作区的任务列表。
### 新建任务
在右上角点击 **New Task**。给任务起一个清晰的标题 —— Agent 会根据标题和描述来理解你的意图。
### 填写描述
像给同事派活一样描述工作内容,包含必要的链接、文件或限制条件。你可以像在普通对话里一样粘贴图片、附加 Resource。
### 指派 Agent
在 **Assignee** 下拉框中选择合适的 Agent。根据任务挑选能力匹配的 Agent —— 比如 **Research Agent** 适合阅读和总结,或者使用你自己创建的自定义 Agent。后续也可以重新分配。
### 选择运行方式
选择 **Run once** 进行一次性运行,或选择 **Repeat** 设置为周期任务。详见下方的 [一次性任务 vs. 周期任务](#一次性任务-vs-周期任务)。
### 提交
点击 **Create**。任务会进入 **Backlog** 状态,Agent 很快就会开始执行。
</Steps>
<Callout type="info">
你也可以从任意聊天消息直接创建任务 —— 打开消息菜单选择 **Turn into Task**,对话上下文会自动带入。
</Callout>
## 与 Agent 协作
当任务进入 `In Progress` 后,Agent 会在任务内部持续记录进度 —— 每一步动作、每一次工具调用、每一个中间结果。你不需要盯着看,随时打开任务就能看到当前状态。
### 验收结果
当 Agent 认为工作完成后,任务会进入 `Pending Review` 状态。打开任务详情页验收结果,你有两种选择:
- **Confirm Complete** —— 如果结果满意,点击 **Confirm Complete** 按钮。任务进入 `Done` 状态并归档。
- **追加评论** —— 如果还需要调整,直接留下评论。Agent 会从上次中断的地方继续推进。
`Pending Review` 任务不会自动完成 —— 任务是否结束完全由你决定。
### 评论与追问
每个任务都有一条评论线索,可以用来:
- **澄清**:当 Agent 在执行过程中提问时回复它。
- **纠偏**:当 Agent 走偏方向时及时拉回。
- **迭代**:在验收阶段留一条 _"同样的内容但排除周末"_ 这类评论,Agent 会重新打开任务再跑一次。
Agent 会自动读取新评论并继续跟进,不需要单独的 "发送" 动作 —— 你的评论就是新的指令。
<Callout type="info">
如果 Agent 正在执行某一步,你的评论会排在下一个检查点处理,避免中途打断它。
</Callout>
### 产出物(Artifacts
Agent 在任务执行过程中创建的所有页面 —— 研究笔记、摘要、初稿,以及任何写入工作区的内容 —— 都会列在任务详情页的 **Artifacts** 区域。你可以直接打开、分享或继续编辑,全程不离开任务。
## 一次性任务 vs. 周期任务
任务支持两种运行方式。
### 一次性运行(Run Once
默认方式。Agent 立即执行任务、提交结果,然后把任务推进到 `Pending Review` 等待你确认。绝大多数不需要重复的需求都用这种。
### 周期运行(Repeat
把任务设置成定时计划,Agent 会按计划自动重新执行。每次运行的结果都会追加到同一个任务里,形成一份可对比的历史记录。
支持的周期:
- **Hourly** —— 每隔 _N_ 小时。
- **Daily** —— 每天的指定时间。
- **Weekly** —— 每周指定的几天。
- **Monthly** —— 每月指定的一天。
- **Custom** —— 任意 cron 表达式。
<Callout type="warning">
周期任务每次运行都会消耗积分。保存前请查看调度面板上预估的积分开销;如果不再需要,记得及时暂停任务。
</Callout>
你可以在任务详情页随时暂停、恢复或修改计划。暂停会保留历史;删除会同时清除任务及其所有运行记录。
## 使用示例
下面这些场景特别适合做成任务:
- **每日市场摘要** —— 一个 Research Agent,在每个工作日早上 8 点汇总隔夜资讯。
- **每周竞品扫描** —— 一个 Agent 访问 5 个竞品网站并提示定价变化。
- **一次性深度研究** —— 一个长跑任务("对比这 12 个向量数据库"),你过一会儿再回来看结果。
- **周期数据拉取** —— 一个 Agent 每周一查询数据库并把结果发到任务里。
- **想法收件箱** —— 把临时灵感丢进任务列表,让 Agent 在夜里准备好初稿,第二天直接修改。
@@ -55,16 +55,3 @@ Feature: 发送消息与流式输出期间的视口滚动行为
And
And
Then
# Regression guard for the spacer-shrink issue: after streaming has ended,
# layout/virtual-list offset corrections can emit scroll events without any
# wheel, touch, keyboard, or pointer scroll input. Those synthetic negative
# offsets must not be treated as user scroll-up intent.
@AGENT-SCROLL-006 @P0 @journey
Scenario: 非用户触发的上移不应收缩底部补偿区域
Given Lobe AI
When
And
And
And 120
Then
+24 -96
View File
@@ -22,7 +22,6 @@ const AT_BOTTOM_EPSILON = 320;
const MANUAL_SCROLL_UP_DELTA = 200;
interface ScrollSnapshot {
bottomCompensationHeight: number;
clientHeight: number;
distanceToBottom: number;
scrollHeight: number;
@@ -43,18 +42,7 @@ async function getScrollSnapshot(world: CustomWorld): Promise<ScrollSnapshot | n
while (el) {
const style = window.getComputedStyle(el);
if (style.overflowY === 'auto' || style.overflowY === 'scroll') {
const bottomCompensationHeight = Math.max(
0,
...Array.from(el.querySelectorAll<HTMLElement>('div[aria-hidden="true"]'))
.filter((node) => {
const nodeStyle = window.getComputedStyle(node);
return nodeStyle.pointerEvents === 'none' && node.offsetWidth > 0;
})
.map((node) => node.getBoundingClientRect().height),
);
return {
bottomCompensationHeight,
clientHeight: el.clientHeight,
distanceToBottom: el.scrollHeight - el.scrollTop - el.clientHeight,
scrollHeight: el.scrollHeight,
@@ -67,41 +55,6 @@ async function getScrollSnapshot(world: CustomWorld): Promise<ScrollSnapshot | n
});
}
async function sendPrompt(world: CustomWorld, prompt: string, response: string): Promise<void> {
llmMockManager.setResponse(prompt, response);
await world.page.keyboard.type(prompt, { delay: 20 });
await world.page.waitForTimeout(200);
await world.page.keyboard.press('Enter');
}
async function waitForAssistantMessageToSettle(
world: CustomWorld,
minLength: number,
): Promise<void> {
const assistantMessage = world.page
.locator('.message-wrapper')
.filter({ has: world.page.locator('text=Lobe AI') })
.last();
await expect(assistantMessage).toBeVisible({ timeout: 15_000 });
let prevLen = 0;
let stableTicks = 0;
for (let i = 0; i < 60; i++) {
const len =
(await assistantMessage
.innerText()
.then((t) => t.length)
.catch(() => 0)) || 0;
if (len > minLength && len === prevLen) stableTicks += 1;
else stableTicks = 0;
prevLen = len;
if (stableTicks >= 3) break;
await world.page.waitForTimeout(250);
}
}
async function scrollBy(world: CustomWorld, deltaY: number): Promise<void> {
await world.page.evaluate((dy) => {
const msg = document.querySelector('.message-wrapper');
@@ -193,7 +146,11 @@ Given('流式响应被放慢以模拟长文输出', async function (this: Custom
When('用户发送长文消息并等待回复完成', { timeout: 45_000 }, async function (this: CustomWorld) {
const prompt = '请输出一篇很长的文章';
await sendPrompt(this, prompt, presetResponses.longScrollArticle);
llmMockManager.setResponse(prompt, presetResponses.longScrollArticle);
await this.page.keyboard.type(prompt, { delay: 20 });
await this.page.waitForTimeout(200);
await this.page.keyboard.press('Enter');
// Wait for assistant message to appear and its content to stabilize.
const messageWrappers = this.page.locator('.message-wrapper');
@@ -208,12 +165,29 @@ When('用户发送长文消息并等待回复完成', { timeout: 45_000 }, async
await expect(assistantMessage).toBeVisible({ timeout: 15_000 });
// Poll until text has grown past an obvious threshold, then plateaus.
await waitForAssistantMessageToSettle(this, 200);
let prevLen = 0;
let stableTicks = 0;
for (let i = 0; i < 40; i++) {
const len =
(await assistantMessage
.innerText()
.then((t) => t.length)
.catch(() => 0)) || 0;
if (len > 200 && len === prevLen) stableTicks += 1;
else stableTicks = 0;
prevLen = len;
if (stableTicks >= 3) break;
await this.page.waitForTimeout(250);
}
});
When('用户发送一条触发长文输出的消息', async function (this: CustomWorld) {
const prompt = '请输出一篇很长的文章';
await sendPrompt(this, prompt, presetResponses.longScrollArticle);
llmMockManager.setResponse(prompt, presetResponses.longScrollArticle);
await this.page.keyboard.type(prompt, { delay: 20 });
await this.page.waitForTimeout(200);
await this.page.keyboard.press('Enter');
// Wait long enough for pin's smooth scrollToIndex to finish. Virtua drives
// the smooth animation via rAF and would otherwise overwrite a manual
@@ -221,42 +195,6 @@ When('用户发送一条触发长文输出的消息', async function (this: Cust
await this.page.waitForTimeout(1200);
});
When(
'用户完成一轮用于垫高列表的长回复对话',
{ timeout: 45_000 },
async function (this: CustomWorld) {
const prompt = '请先输出一篇很长的文章用于垫高列表';
await sendPrompt(this, prompt, presetResponses.longScrollArticle);
await waitForAssistantMessageToSettle(this, 200);
},
);
When(
'用户发送一条触发短回复的消息并等待回复完成',
{ timeout: 30_000 },
async function (this: CustomWorld) {
const prompt = '请输出一段短回复用于测试底部补偿区域';
await sendPrompt(this, prompt, '这是一个短回复,用于让底部补偿区域保持可见。');
await waitForAssistantMessageToSettle(this, 10);
await this.page.waitForTimeout(400);
},
);
When('记录聊天列表底部补偿区域高度', async function (this: CustomWorld) {
const snap = await getScrollSnapshot(this);
expect(snap, 'failed to locate scroll container').not.toBeNull();
expect(snap!.bottomCompensationHeight).toBeGreaterThan(0);
expect(snap!.scrollTop).toBeGreaterThan(120);
this.testContext.scrollCompensationHeight = snap!.bottomCompensationHeight;
this.testContext.scrollHeightBeforeSyntheticOffset = snap!.scrollHeight;
});
When('模拟非用户触发的聊天列表上移 {int} 像素', async function (this: CustomWorld, px: number) {
await scrollBy(this, -Math.abs(px));
await this.page.waitForTimeout(400);
});
When('用户在流式响应进行中向上滚动 {int} 像素', async function (this: CustomWorld, px: number) {
const delta = Math.abs(px) || MANUAL_SCROLL_UP_DELTA;
// Mouse wheel over the list, more faithful to real-user interaction than
@@ -368,13 +306,3 @@ Then('用户消息应固定在聊天列表顶部', async function (this: CustomW
// Pin anchors with `align: 'start'` — tolerate ~150 px of slack for headers.
expect(Math.abs(rect!.delta)).toBeLessThanOrEqual(150);
});
Then('聊天列表底部补偿区域高度不应收缩', async function (this: CustomWorld) {
const before = this.testContext.scrollCompensationHeight as number | undefined;
expect(before, 'missing recorded bottom compensation height').toBeDefined();
const snap = await getScrollSnapshot(this);
expect(snap, 'failed to locate scroll container').not.toBeNull();
expect(snap!.bottomCompensationHeight).toBeGreaterThanOrEqual(before! - 2);
});
-4
View File
@@ -115,10 +115,6 @@
"channel.line.fetchBotInfoMissingToken": "أدخل رمز الوصول للقناة أولاً، ثم انقر على \"Fetch from LINE\".",
"channel.line.fetchBotInfoSuccess": "تم جلب معرّف المستخدم الوجهة",
"channel.line.webhookManualSetup": "لا يسمح LINE بالتسجيل البرمجي للويب هوك. انسخ هذا الرابط إلى وحدة تحكم مطوري LINE (واجهة برمجة تطبيقات المراسلة → رابط الويب هوك)، انقر على \"تحقق\"، وقم بتمكين \"استخدام الويب هوك\".",
"channel.messengerPromo.action": "جرّب Messenger",
"channel.messengerPromo.desc": "لا حاجة لإعداد الروبوت. تحدث مع LobeHub على Slack، Discord، Telegram.",
"channel.messengerPromo.dismiss": "تجاهل",
"channel.messengerPromo.title": "تجاوز الإعداد",
"channel.openPlatform": "منصة مفتوحة",
"channel.platforms": "المنصات",
"channel.publicKey": "المفتاح العام",
+1 -1
View File
@@ -35,7 +35,7 @@
"authModal.title": "انتهت الجلسة",
"betterAuth.captcha.continue": "استمر",
"betterAuth.captcha.description": "أكمل التحقق الأمني أدناه. سنواصل عملية التسجيل أو تسجيل الدخول تلقائيًا.",
"betterAuth.captcha.pendingDescription": "لم يكتمل التحقق. يرجى محاولة التحدي مرة أخرى.",
"betterAuth.captcha.pendingDescription": "يرجى إكمال التحقق أولاً، ثم المتابعة.",
"betterAuth.captcha.title": "مطلوب التحقق الأمني",
"betterAuth.errors.confirmPasswordRequired": "يرجى تأكيد كلمة المرور",
"betterAuth.errors.emailExists": "هذا البريد الإلكتروني مسجل بالفعل. يرجى تسجيل الدخول بدلاً من ذلك",
-1
View File
@@ -27,7 +27,6 @@
"codes.RATE_LIMIT_EXCEEDED": "عدد كبير جداً من الطلبات، يرجى المحاولة لاحقاً",
"codes.SESSION_EXPIRED": "انتهت صلاحية الجلسة، يرجى تسجيل الدخول مرة أخرى",
"codes.SOCIAL_ACCOUNT_ALREADY_LINKED": "هذا الحساب الاجتماعي مرتبط بالفعل بمستخدم آخر",
"codes.TEMPORARY_EMAIL_NOT_ALLOWED": "عناوين البريد الإلكتروني المؤقتة غير مدعومة. يرجى استخدام عنوان بريد إلكتروني عادي. قد تؤدي المحاولات المتكررة إلى حظر هذه الشبكة.",
"codes.UNEXPECTED_ERROR": "حدث خطأ غير متوقع، يرجى المحاولة مرة أخرى",
"codes.UNKNOWN": "حدث خطأ غير معروف، يرجى المحاولة مرة أخرى أو التواصل مع الدعم",
"codes.USER_ALREADY_EXISTS": "المستخدم موجود بالفعل",
+15 -48
View File
@@ -184,10 +184,6 @@
"groupWizard.searchTemplates": "البحث في القوالب...",
"groupWizard.title": "إنشاء مجموعة",
"groupWizard.useTemplate": "استخدام قالب",
"heteroAgent.cloudRepo.multiSelected": "{{count}} مستودعات محددة",
"heteroAgent.cloudRepo.noRepos": "لم يتم تكوين أي مستودعات. أضفها في إعدادات الوكيل.",
"heteroAgent.cloudRepo.notSet": "لم يتم تحديد أي مستودع",
"heteroAgent.cloudRepo.sectionTitle": "المستودعات",
"heteroAgent.fullAccess.label": "وصول كامل",
"heteroAgent.fullAccess.tooltip": "يعمل Claude Code محليًا مع صلاحية قراءة/كتابة كاملة في دليل العمل. تبديل أوضاع الصلاحيات غير متاح بعد.",
"heteroAgent.resumeReset.cwdChanged": "تم تغيير دليل العمل. لا يمكن استئناف جلسة Claude Code السابقة إلا من دليلها الأصلي، لذا بدأت محادثة جديدة.",
@@ -314,7 +310,7 @@
"openInNewWindow": "فتح في نافذة جديدة",
"operation.contextCompression": "السياق طويل جدًا، يتم ضغط السجل...",
"operation.execAgentRuntime": "جارٍ تحضير الرد",
"operation.execClientSubAgent": شغيل الوكيل الفرعي",
"operation.execClientTask": نفيذ المهمة",
"operation.execHeterogeneousAgent": "{{name}} قيد التشغيل",
"operation.execServerAgentRuntime": "جاري التشغيل… يمكنك تبديل المهام أو إغلاق الصفحة — ستستمر المهمة بالعمل.",
"operation.heterogeneousAgentFallback": "وكيل خارجي",
@@ -567,12 +563,8 @@
"taskList.contextMenu.copyLink": "نسخ الرابط",
"taskList.contextMenu.copyLinkSuccess": "تم نسخ الرابط",
"taskList.contextMenu.priority": "الأولوية",
"taskList.contextMenu.runNow": "تشغيل الآن",
"taskList.contextMenu.status": "الحالة",
"taskList.empty": "لا توجد مهام بعد",
"taskList.emptyHero.greeting": "ما الذي يجب أن نتعامل معه اليوم؟",
"taskList.emptyHero.subtitle": "صف مهمة لوكيلك، أو ابدأ من قالب أدناه.",
"taskList.emptyHero.templatesTitle": "قوالب مختارة لك",
"taskList.form.grouping": "التجميع",
"taskList.form.orderCompletedByRecency": "ترتيب المهام المكتملة حسب الأحدث",
"taskList.form.ordering": "الترتيب",
@@ -633,10 +625,8 @@
"taskSchedule.summary.daily": "يوميًا عند {{time}}",
"taskSchedule.summary.disabled": "الأتمتة متوقفة",
"taskSchedule.summary.everyNHours": "كل {{count}} ساعات{{minute}}",
"taskSchedule.summary.everyNHoursHalfPast": "كل {{count}} ساعة عند الثلاثين دقيقة",
"taskSchedule.summary.heartbeat": "يعمل كل {{interval}}",
"taskSchedule.summary.hourly": "كل ساعة{{minute}}",
"taskSchedule.summary.hourlyHalfPast": "كل ساعة عند الثلاثين دقيقة",
"taskSchedule.summary.weekly": "كل {{days}} عند {{time}}",
"taskSchedule.tag.add": "تعيين جدول",
"taskSchedule.tag.every": "كل {{interval}}",
@@ -644,8 +634,6 @@
"taskSchedule.tag.schedule": "الجدول · {{schedule}}{{timezone}}",
"taskSchedule.time": "الوقت",
"taskSchedule.timezone": "المنطقة الزمنية",
"taskSchedule.timezoneSearchEmpty": "لا توجد منطقة زمنية مطابقة",
"taskSchedule.timezoneSearchPlaceholder": "البحث عن المنطقة الزمنية",
"taskSchedule.title": "الجدول",
"taskSchedule.unit.hour_one": "{{count}} ساعة",
"taskSchedule.unit.hour_other": "{{count}} ساعات",
@@ -665,7 +653,6 @@
"thread.divider": "موضوع فرعي",
"thread.openSubagentThread": "عرض محادثة الوكيل الفرعي كاملة",
"thread.subagentBadge": "وكيل فرعي",
"thread.subagentReadOnlyHint": "المحادثات مع الوكيل الفرعي للقراءة فقط — يتم التنفيذ بواسطة الوكيل الرئيسي.",
"thread.threadMessageCount": "{{messageCount}} رسالة",
"thread.title": "موضوع فرعي",
"todoProgress.allCompleted": "تم إكمال جميع المهام",
@@ -686,32 +673,37 @@
"tokenTag.used": "المستخدم",
"tool.intervention.approvalMode": "وضع الموافقة",
"tool.intervention.approve": "موافقة",
"tool.intervention.approveAndRemember": "موافقة وتذكر",
"tool.intervention.approveOnce": "الموافقة هذه المرة فقط",
"tool.intervention.mode.allowList": "قائمة السماح",
"tool.intervention.mode.allowListDesc": "تنفيذ الأدوات المعتمدة فقط تلقائيًا",
"tool.intervention.mode.autoRun": "موافقة تلقائية",
"tool.intervention.mode.autoRunDesc": "الموافقة تلقائيًا على جميع تنفيذات الأدوات",
"tool.intervention.mode.manual": "يدوي",
"tool.intervention.mode.manualDesc": "يتطلب الموافقة اليدوية لكل استدعاء",
"tool.intervention.onboarding.agentIdentity.editHint": "يمكنك تعديل الاسم أو الصورة الرمزية مباشرة أدناه.",
"tool.intervention.onboarding.agentIdentity.namePlaceholder": "اسم الوكيل",
"tool.intervention.onboarding.agentIdentity.applyHint": "ستظهر الهوية الجديدة بعد الموافقة.",
"tool.intervention.onboarding.agentIdentity.description": "الموافقة على هذا التغيير ستحدّث الوكيل المعروض في البريد الوارد وفي محادثة الإعداد هذه.",
"tool.intervention.onboarding.agentIdentity.emoji": "صورة الوكيل",
"tool.intervention.onboarding.agentIdentity.eyebrow": "موافقة الإعداد",
"tool.intervention.onboarding.agentIdentity.name": "اسم الوكيل",
"tool.intervention.onboarding.agentIdentity.targetInbox": "وكيل البريد الوارد",
"tool.intervention.onboarding.agentIdentity.targetOnboarding": "وكيل الإعداد الحالي",
"tool.intervention.onboarding.agentIdentity.targets": "ينطبق على",
"tool.intervention.onboarding.agentIdentity.title": "تأكيد تحديث هوية الوكيل",
"tool.intervention.onboarding.agentIdentity.titleAvatarOnly": "سأقوم بتحديث صورتي الرمزية",
"tool.intervention.onboarding.agentIdentity.titleNameOnly": "سأقوم بتحديث اسمي",
"tool.intervention.onboarding.userProfile.applyHint": "سيتم حفظ هذه التفاصيل في ملفك الشخصي بعد الموافقة.",
"tool.intervention.onboarding.userProfile.description": "الموافقة على هذا التغيير ستحدث ملف تعريف الانضمام الخاص بك حتى يتمكن الوكيل من تخصيص الردود المستقبلية.",
"tool.intervention.onboarding.userProfile.eyebrow": "الموافقة على الانضمام",
"tool.intervention.onboarding.userProfile.fullName": "الاسم الكامل",
"tool.intervention.onboarding.userProfile.responseLanguage": "لغة الرد",
"tool.intervention.onboarding.userProfile.title": "تأكيد تحديث ملفك الشخصي",
"tool.intervention.optionApprove": "الموافقة",
"tool.intervention.pending": "قيد الانتظار",
"tool.intervention.reject": "رفض",
"tool.intervention.rejectAndContinue": "رفض وإعادة المحاولة",
"tool.intervention.rejectOnly": "رفض",
"tool.intervention.rejectReasonPlaceholder": "سيساعد السبب الوكيل على فهم حدودك وتحسين التصرفات المستقبلية",
"tool.intervention.rejectTitle": "رفض استدعاء المهارة",
"tool.intervention.rejectedWithReason": "تم رفض استدعاء المهارة: {{reason}}",
"tool.intervention.rememberSimilar": "لا تسأل مرة أخرى عن إجراءات مشابهة",
"tool.intervention.scrollToIntervention": "عرض",
"tool.intervention.submit": "إرسال",
"tool.intervention.toolAbort": "لقد ألغيت استدعاء المهارة",
"tool.intervention.toolRejected": "تم رفض استدعاء المهارة",
"tool.intervention.viewParameters": "عرض المعلمات ({{count}})",
@@ -772,8 +764,6 @@
"workflow.toolDisplayName.addPreferenceMemory": "الذاكرة المحفوظة",
"workflow.toolDisplayName.calculate": "محسوب",
"workflow.toolDisplayName.callAgent": "تم استدعاء وكيل",
"workflow.toolDisplayName.callSubAgent": "تم إرسال وكيل فرعي",
"workflow.toolDisplayName.callSubAgents": "تم إرسال وكلاء فرعيين",
"workflow.toolDisplayName.clearTodos": "تم مسح المهام",
"workflow.toolDisplayName.copyDocument": "تم نسخ مستند",
"workflow.toolDisplayName.crawlMultiPages": "الصفحات التي تم الزحف إليها",
@@ -788,6 +778,8 @@
"workflow.toolDisplayName.editTitle": "العنوان المُعدَّل",
"workflow.toolDisplayName.evaluate": "التعبير المُقيَّم",
"workflow.toolDisplayName.execScript": "تم تنفيذ برنامج نصي",
"workflow.toolDisplayName.execTask": "تم تنفيذ مهمة",
"workflow.toolDisplayName.execTasks": "المهام المنفذة",
"workflow.toolDisplayName.execute": "تم تنفيذ العملية الحسابية",
"workflow.toolDisplayName.executeCode": "تم تنفيذ الشيفرة",
"workflow.toolDisplayName.finishOnboarding": "إنهاء الإعداد التعريفي",
@@ -823,9 +815,7 @@
"workflow.toolDisplayName.searchLocalFiles": "الملفات التي تم البحث عنها",
"workflow.toolDisplayName.searchSkill": "المهارات التي تم البحث عنها",
"workflow.toolDisplayName.searchUserMemory": "تم البحث في الذاكرة",
"workflow.toolDisplayName.showAgentMarketplace": "فريق الوكلاء المجمع",
"workflow.toolDisplayName.solve": "حلّ المعادلة",
"workflow.toolDisplayName.submitAgentPick": "الوكلاء المختارون",
"workflow.toolDisplayName.updateAgent": "تم تحديث وكيل",
"workflow.toolDisplayName.updateDocument": "تم تحديث مستند",
"workflow.toolDisplayName.updateIdentityMemory": "تم تحديث الذاكرة",
@@ -864,41 +854,18 @@
"workingPanel.resources.renameEmpty": "Title cannot be empty",
"workingPanel.resources.renameError": "Failed to rename document",
"workingPanel.resources.renameSuccess": "Document renamed",
"workingPanel.resources.tree.createError": "فشل في الإنشاء",
"workingPanel.resources.tree.moveError": "فشل في النقل",
"workingPanel.resources.tree.newDocument": "مستند جديد",
"workingPanel.resources.tree.newFolder": "مجلد جديد",
"workingPanel.resources.tree.parentMissing": "المجلد الرئيسي غير متوفر",
"workingPanel.resources.tree.rename": "إعادة تسمية",
"workingPanel.resources.tree.untitledDocument": "مستند بدون عنوان",
"workingPanel.resources.tree.untitledFolder": "مجلد بدون عنوان",
"workingPanel.resources.updatedAt": "تم التحديث في {{time}}",
"workingPanel.resources.viewMode.list": "عرض القائمة",
"workingPanel.resources.viewMode.tree": "عرض الشجرة",
"workingPanel.review.baseRef.default": "افتراضي",
"workingPanel.review.baseRef.loading": "جارٍ تحميل الفروع...",
"workingPanel.review.baseRef.reset": "إعادة التعيين إلى الفرع الافتراضي",
"workingPanel.review.baseRef.unresolved": "اختر فرعًا أساسيًا",
"workingPanel.review.binary": "ملف ثنائي — لا يمكن عرض الفرق",
"workingPanel.review.collapseAll": "طي الكل",
"workingPanel.review.copied": "تم نسخ المسار",
"workingPanel.review.copyPath": "نسخ مسار الملف",
"workingPanel.review.empty": "لا توجد تغييرات في شجرة العمل",
"workingPanel.review.empty.branch": "لا توجد تغييرات مقابل {{baseRef}}",
"workingPanel.review.empty.noBaseRef": "تعذر تحديد الفرع الافتراضي البعيد. قم بتشغيل `git remote set-head origin --auto` في الطرفية.",
"workingPanel.review.error": "تعذر تحميل الفرق لهذا الملف",
"workingPanel.review.expandAll": "توسيع الكل",
"workingPanel.review.mode.branch": "فرع",
"workingPanel.review.mode.unstaged": "غير مُرتب",
"workingPanel.review.more": "خيارات إضافية",
"workingPanel.review.refresh": "تحديث",
"workingPanel.review.revert": "تجاهل التغييرات",
"workingPanel.review.revert.confirm.cancel": "إلغاء",
"workingPanel.review.revert.confirm.description": "سيتم تجاهل تغييرات شجرة العمل على {{filePath}} نهائيًا. ستُحذف الملفات غير المتعقبة من القرص.",
"workingPanel.review.revert.confirm.ok": "تجاهل",
"workingPanel.review.revert.confirm.title": "تجاهل التغييرات على هذا الملف؟",
"workingPanel.review.revert.failed": "تعذّر تجاهل التغييرات: {{error}}",
"workingPanel.review.revert.success": "تم تجاهل التغييرات على {{filePath}}",
"workingPanel.review.textDiff.disable": "تعطيل مقارنة النصوص المضمنة",
"workingPanel.review.textDiff.enable": "تمكين مقارنة النصوص المضمنة",
"workingPanel.review.title": "مراجعة",
+1 -3
View File
@@ -29,7 +29,7 @@
"batchDelete": "حذف جماعي",
"blog": "مدونة المنتج",
"botIntegrationBanner.dismiss": "إغلاق",
"botIntegrationBanner.title": حدث إلى Lobe AI عبر تطبيقات المراسلة المفضلة لديك",
"botIntegrationBanner.title": "إضافة قنوات إلى LobeAI",
"branching": "إنشاء موضوع فرعي",
"branchingDisable": "ميزة \"الموضوع الفرعي\" غير متاحة في الوضع الحالي. لاستخدام هذه الميزة، يرجى التبديل إلى وضع قاعدة بيانات Postgres/Pglite أو استخدام LobeHub Cloud.",
"branchingRequiresSavedTopic": "الموضوع الحالي غير محفوظ، يرجى حفظه أولاً لاستخدام ميزة الموضوع الفرعي",
@@ -349,8 +349,6 @@
"loading": "جارٍ التحميل...",
"mail.business": "تعاون تجاري",
"mail.support": "دعم عبر البريد الإلكتروني",
"messengerBanner.dismiss": "رفض",
"messengerBanner.title": "تحدث إلى Lobe AI عبر تطبيقات المراسلة المفضلة لديك",
"more": "المزيد",
"navPanel.agent": "الوكيل",
"navPanel.customizeSidebar": "تخصيص الشريط الجانبي",
-12
View File
@@ -40,18 +40,6 @@
"modifier.acceptAll": "الاحتفاظ بالجميع",
"modifier.reject": "تراجع",
"modifier.rejectAll": "تراجع عن الكل",
"skillFrontmatter.edit": "تحرير البيانات الوصفية",
"skillFrontmatter.empty": "لا توجد بيانات وصفية",
"skillFrontmatter.invalid.descriptionInvalid": "يجب أن تكون الوصف نصًا في سطر واحد.",
"skillFrontmatter.invalid.descriptionRequired": "الوصف مطلوب.",
"skillFrontmatter.invalid.mapping": "يجب أن تكون البيانات الوصفية بتنسيق YAML.",
"skillFrontmatter.invalid.nameInvalid": "يجب أن يتكون الاسم من أحرف صغيرة وأرقام وشرطات.",
"skillFrontmatter.invalid.nameLocked": "يجب أن يبقى الاسم {{name}}. قم بإعادة تسمية حزمة المهارة بدلاً من ذلك.",
"skillFrontmatter.invalid.nameRequired": "الاسم مطلوب.",
"skillFrontmatter.invalid.required": "البيانات الوصفية مطلوبة.",
"skillFrontmatter.invalid.syntax": "صيغة YAML غير صحيحة.",
"skillFrontmatter.saveFailed": "لم يتم حفظ البيانات الوصفية. حاول مرة أخرى أو استمر في التحرير.",
"skillFrontmatter.title": "بيانات وصفية للمهارة",
"slash.compact": "ضغط السياق",
"slash.h1": "عنوان 1",
"slash.h2": "عنوان 2",
-1
View File
@@ -114,7 +114,6 @@
"response.ProviderBizError": "حدث خطأ أثناء طلب خدمة {{provider}}، يرجى التحقق أو إعادة المحاولة.",
"response.ProviderContentModeration": "فشل التحقق من سياسة المحتوى. عدّل طلبك وحاول مرة أخرى.",
"response.ProviderContentModerationWarning": "تم رصد انتهاكات متكررة لسياسة الاستخدام. قد يؤدي أي سوء استخدام إضافي إلى تقييد حسابك.",
"response.ProviderImageContentModerationWarning": "تم اكتشاف رفضات متكررة لسلامة الصور. قد تؤدي الطلبات المشابهة إلى إيقاف مؤقت لتوليد الصور.",
"response.QuotaLimitReached": "عذرًا، تم الوصول إلى الحد الأقصى لاستخدام الرموز أو عدد الطلبات لهذا المفتاح. يرجى زيادة الحصة أو المحاولة لاحقًا.",
"response.QuotaLimitReachedCloud": "خدمة النموذج تحت ضغط كبير حاليًا. يرجى المحاولة مرة أخرى لاحقًا.",
"response.ServerAgentRuntimeError": "عذرًا، خدمة الوكيل غير متوفرة حاليًا. يرجى المحاولة لاحقًا أو التواصل معنا عبر البريد الإلكتروني.",
-5
View File
@@ -26,11 +26,6 @@
"brief.viewRun": "عرض التشغيل",
"project.create": "مشروع جديد",
"project.deleteConfirm": "سيتم حذف هذا المشروع ولن يمكن استعادته. أكد للمتابعة.",
"recommendations.heteroAgent.cta": "أضف الوكيل",
"recommendations.heteroAgent.description": "تم اكتشاف واجهة الأوامر {{name}} على هذا الجهاز — أضف وكيل {{name}} للدردشة معه من LobeHub.",
"recommendations.heteroAgent.tag": "وكيل البرمجة",
"recommendations.heteroAgent.title": "أضف وكيل {{name}}",
"recommendations.subtitle": "بعض التوصيات لإعدادك",
"starter.createAgent": "إنشاء وكيل",
"starter.createGroup": "إنشاء مجموعة",
"starter.deepResearch": "بحث معمق",

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