mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-16 20:46:08 +00:00
Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e613eb46c | |||
| 798644414a | |||
| 54bb83f229 | |||
| 65da232c64 | |||
| dacc7798ab | |||
| 9508807da7 | |||
| 6a7eb17cd2 | |||
| c5da34b680 | |||
| 2a37b77482 | |||
| b814cf2611 | |||
| c37817e2d8 | |||
| bbf239705c | |||
| 8a9f42596d | |||
| 29235dc1ed | |||
| e326400dbe | |||
| deeb97ab5b | |||
| d73858ef42 | |||
| 6b9584714d | |||
| b9a4a9093c | |||
| ef5be7e17c | |||
| a4235d3f68 | |||
| fa508f4259 | |||
| 94767fddcb | |||
| 685b17e59e | |||
| 9acb128943 | |||
| ee55d74dd4 | |||
| cca1050e82 | |||
| 92a848c69c | |||
| f32fff19dd | |||
| 376976849b | |||
| 38d7bdbd96 | |||
| a52104552a | |||
| 3e236ec36f | |||
| 57781850ce | |||
| a101957715 | |||
| 4e309e6f26 | |||
| fd9b0531ec | |||
| 91db61b74f | |||
| 1d7b81233a | |||
| 35c3d5e08d | |||
| a176288670 | |||
| f0ba92776b | |||
| d12e050157 | |||
| cc48e9ff8e | |||
| 939f20e783 | |||
| 8f6848fba2 | |||
| 8b22e55271 | |||
| 196c0a7650 | |||
| ec7e696587 | |||
| 9b48e24ded | |||
| 79d5d2286a | |||
| 998c22890d | |||
| d5315fe745 | |||
| 5c75b0865f | |||
| 7f6f77ec9d | |||
| 7c0203a9c7 | |||
| 84fd8da4a3 | |||
| f98a314cf5 | |||
| 35c43fb580 | |||
| 56bc216c5e | |||
| 66c25cce4b | |||
| 774e29e400 | |||
| eec89338da | |||
| 91cb2a8e65 | |||
| 61d27b46a0 | |||
| 01f6858cc1 | |||
| b3e993f7b1 | |||
| 22e6e1dbcc | |||
| f7205552e8 | |||
| 0077a7286a | |||
| 697ac3bf6e | |||
| fc12fac53b | |||
| ba59d85ae6 | |||
| a6cb200d5b | |||
| 87d7b41186 | |||
| 8e807c6b10 | |||
| 53c5a014ba | |||
| ba05c32489 | |||
| d4a12c0ebb | |||
| 7f025b9c5a | |||
| 35c9e1b224 | |||
| 043d2a81fb | |||
| f39392749a | |||
| b3dc59f77a | |||
| 9b6a60339f | |||
| b55cf6b936 | |||
| 933cfbf789 | |||
| 0e11d3d9c0 | |||
| 600f10fcea | |||
| 421427f3a2 | |||
| 5dc7c2592c | |||
| 57e3940bc6 |
@@ -0,0 +1,209 @@
|
||||
---
|
||||
name: agent-runtime-hooks
|
||||
description: "Agent runtime lifecycle hooks for observing and intercepting agent execution. Use when adding hooks to agent operations, mocking tool calls, logging step events, handling human intervention, sub-agent calls, context compression, or building eval/tracing integrations. Triggers on 'hooks', 'beforeToolCall', 'afterToolCall', 'beforeStep', 'afterStep', 'onComplete', 'onError', 'tool mock', 'agent lifecycle', 'human intervention', 'callAgent', 'compact'."
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# Agent Runtime Hooks
|
||||
|
||||
Lifecycle hooks for observing and intercepting agent execution. Hooks are registered per-operation via `execAgent({ hooks })` and dispatched by `HookDispatcher`.
|
||||
|
||||
## Hook Types
|
||||
|
||||
16 hook types across 5 categories:
|
||||
|
||||
```
|
||||
execAgent({ hooks })
|
||||
│
|
||||
├─ beforeStep ──────────── Before each step executes
|
||||
│ │
|
||||
│ ├─ [call_llm] LLM inference
|
||||
│ │
|
||||
│ ├─ [call_tool]
|
||||
│ │ ├─ beforeToolCall ── Before tool executes (supports mocking)
|
||||
│ │ ├─ (tool execution)
|
||||
│ │ ├─ afterToolCall ─── After tool completes (observation only)
|
||||
│ │ └─ onToolCallError ─ Tool threw an exception
|
||||
│ │
|
||||
│ ├─ [request_human_approve]
|
||||
│ │ ├─ beforeHumanIntervention ── Before agent pauses
|
||||
│ │ ├─ afterHumanIntervention ─── After approve/reject + resume
|
||||
│ │ └─ onStopByHumanIntervention ── User rejected, agent halted
|
||||
│ │
|
||||
│ ├─ [compress_context]
|
||||
│ │ ├─ beforeCompact ──── Before compression starts
|
||||
│ │ ├─ afterCompact ───── After compression completes
|
||||
│ │ └─ onCompactError ─── Compression failed
|
||||
│ │
|
||||
│ ├─ [callAgent] (via execSubAgentTask)
|
||||
│ │ ├─ beforeCallAgent ── Before sub-agent starts
|
||||
│ │ ├─ afterCallAgent ─── After sub-agent completes
|
||||
│ │ └─ onCallAgentError ── Sub-agent failed
|
||||
│ │
|
||||
│ └─ afterStep ──────────── After step completes
|
||||
│
|
||||
├─ (next step...)
|
||||
│
|
||||
├─ onComplete ───────────── Operation reaches terminal state
|
||||
└─ onError ──────────────── Error during execution
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Role |
|
||||
| ---------------------------------------------------------- | ------------------------------------------------------ |
|
||||
| `packages/agent-runtime/src/types/hooks.ts` | Type definitions (AgentHookType, all event interfaces) |
|
||||
| `src/server/services/agentRuntime/hooks/types.ts` | Server-side types (AgentHook, re-exports) |
|
||||
| `src/server/services/agentRuntime/hooks/HookDispatcher.ts` | Registration, dispatch, dispatchBeforeToolCall |
|
||||
| `src/server/modules/AgentRuntime/RuntimeExecutors.ts` | Tool/Compact/HumanIntervention hook dispatch |
|
||||
| `src/server/services/agentRuntime/AgentRuntimeService.ts` | Step hooks + HumanIntervention resume/reject |
|
||||
| `src/server/services/aiAgent/index.ts` | CallAgent hook dispatch |
|
||||
|
||||
## Registration Flow
|
||||
|
||||
```ts
|
||||
const hooks: AgentHook[] = [
|
||||
{ id: 'my-hook', type: 'afterStep', handler: async (event) => { ... } },
|
||||
];
|
||||
await aiAgentService.execAgent({ agentId, prompt, hooks });
|
||||
// Internally: hookDispatcher.register(operationId, hooks)
|
||||
// Cleanup: hookDispatcher.unregister(operationId)
|
||||
```
|
||||
|
||||
## Hook Reference
|
||||
|
||||
### Step Level
|
||||
|
||||
**`beforeStep`** — Before each step. `event: AgentHookEvent`
|
||||
**`afterStep`** — After each step. `event: AgentHookEvent` (content, toolsCalling, totalCost, etc.)
|
||||
**`onComplete`** — Terminal state. `event: AgentHookEvent` (reason: done/error/interrupted/max_steps/cost_limit)
|
||||
**`onError`** — Error occurred. `event: AgentHookEvent` (errorMessage, errorDetail)
|
||||
|
||||
### Tool Call Level
|
||||
|
||||
**`beforeToolCall`** — Before tool executes. **Supports mocking** via `event.mock()`.
|
||||
|
||||
```ts
|
||||
// event: ToolCallHookEvent
|
||||
{
|
||||
(identifier, apiName, args, callIndex, stepIndex, operationId, mock);
|
||||
}
|
||||
// Mock example:
|
||||
event.mock({ content: '{"error":"rate limited"}' });
|
||||
```
|
||||
|
||||
Dispatch method: `hookDispatcher.dispatchBeforeToolCall()` (returns mock result or null).
|
||||
|
||||
**`afterToolCall`** — After tool completes. Observation only.
|
||||
|
||||
```ts
|
||||
// event: AfterToolCallHookEvent
|
||||
{
|
||||
(identifier, apiName, args, callIndex, content, success, mocked, executionTimeMs, stepIndex);
|
||||
}
|
||||
```
|
||||
|
||||
**`onToolCallError`** — Tool threw an exception (catch block, not just `success=false`).
|
||||
|
||||
```ts
|
||||
// event: ToolCallErrorHookEvent
|
||||
{
|
||||
(identifier, apiName, args, callIndex, error, stepIndex);
|
||||
}
|
||||
```
|
||||
|
||||
### Human Intervention
|
||||
|
||||
**`beforeHumanIntervention`** — Before agent pauses for approval.
|
||||
|
||||
```ts
|
||||
// event: BeforeHumanInterventionHookEvent
|
||||
{ operationId, stepIndex, pendingTools: [{ identifier, apiName }] }
|
||||
```
|
||||
|
||||
**`afterHumanIntervention`** — After approve/reject, agent resumes.
|
||||
|
||||
```ts
|
||||
// event: AfterHumanInterventionHookEvent
|
||||
{ operationId, action: 'approve' | 'reject' | 'rejectAndContinue', toolCallId?, rejectionReason? }
|
||||
```
|
||||
|
||||
**`onStopByHumanIntervention`** — User rejected, agent halted.
|
||||
|
||||
```ts
|
||||
// event: StopByHumanInterventionHookEvent
|
||||
{ operationId, toolCallId?, rejectionReason? }
|
||||
```
|
||||
|
||||
### Context Compression
|
||||
|
||||
**`beforeCompact`** — Before compression starts.
|
||||
|
||||
```ts
|
||||
// event: BeforeCompactHookEvent
|
||||
{
|
||||
(operationId, stepIndex, messageCount, tokenCount);
|
||||
}
|
||||
```
|
||||
|
||||
**`afterCompact`** — After compression completes.
|
||||
|
||||
```ts
|
||||
// event: AfterCompactHookEvent
|
||||
{
|
||||
(operationId, stepIndex, groupId, messagesBefore, messagesAfter, summary);
|
||||
}
|
||||
```
|
||||
|
||||
**`onCompactError`** — Compression failed.
|
||||
|
||||
```ts
|
||||
// event: CompactErrorHookEvent
|
||||
{
|
||||
(operationId, stepIndex, tokenCount, error);
|
||||
}
|
||||
```
|
||||
|
||||
### Sub-Agent (CallAgent)
|
||||
|
||||
**`beforeCallAgent`** — Before calling sub-agent. Dispatched on **parent** operation.
|
||||
|
||||
```ts
|
||||
// event: BeforeCallAgentHookEvent
|
||||
{
|
||||
(operationId, agentId, instruction);
|
||||
}
|
||||
```
|
||||
|
||||
**`afterCallAgent`** — Sub-agent completed. Dispatched on **parent** operation.
|
||||
|
||||
```ts
|
||||
// event: AfterCallAgentHookEvent
|
||||
{
|
||||
(operationId, agentId, subOperationId, threadId, success);
|
||||
}
|
||||
```
|
||||
|
||||
**`onCallAgentError`** — Sub-agent failed. Dispatched on **parent** operation.
|
||||
|
||||
```ts
|
||||
// event: CallAgentErrorHookEvent
|
||||
{
|
||||
(operationId, agentId, error);
|
||||
}
|
||||
```
|
||||
|
||||
Note: CallAgent hooks require `parentOperationId` in `ExecSubAgentTaskParams`.
|
||||
|
||||
## Design Notes
|
||||
|
||||
- **Fire-and-forget**: All handlers return `Promise<void>`. Errors are non-fatal.
|
||||
- **Exception**: `beforeToolCall` supports mock via `event.mock()` — uses `dispatchBeforeToolCall()` which returns the mock result.
|
||||
- **Sequential**: Same-type hooks run in registration order.
|
||||
- **Local only**: `beforeToolCall` mock only works in local mode (in-memory hooks). Webhook mode does not support mocking.
|
||||
- **Scoped per operation**: Auto-cleaned via `hookDispatcher.unregister()` on completion.
|
||||
- **Sandbox/MCP**: No separate hooks — they go through `executeTool`, so `beforeToolCall`/`afterToolCall` cover them. Use `event.identifier` to filter.
|
||||
|
||||
## Real-World Example: agent-evals
|
||||
|
||||
See `devtools/agent-evals/helpers/runner.ts` — `createEvalHooks()` uses `afterStep`, `onComplete`, `afterToolCall`, and `beforeToolCall` (for mock).
|
||||
@@ -8,16 +8,20 @@ Generate text, images, videos, speech, and transcriptions.
|
||||
|
||||
```
|
||||
lh generate (alias: gen)
|
||||
├── text <prompt> # Text generation
|
||||
├── image <prompt> # Image generation
|
||||
├── video <prompt> # Video generation
|
||||
├── tts <text> # Text-to-speech
|
||||
├── asr <audioFile> # Audio-to-text (speech recognition)
|
||||
├── download <genId> <taskId> # Wait & download generation result
|
||||
├── status <genId> <taskId> # Check async task status
|
||||
└── list # List generation topics
|
||||
├── text <prompt> # Text generation
|
||||
├── image <prompt> # Image generation
|
||||
├── video <prompt> # Video generation
|
||||
├── tts <text> # Text-to-speech
|
||||
├── asr <audioFile> # Audio-to-text (speech recognition)
|
||||
├── download <generationId> <asyncTaskId> # Wait & download generation result
|
||||
├── status <generationId> <asyncTaskId> # Check async task status
|
||||
└── list # List generation topics
|
||||
```
|
||||
|
||||
> ⚠️ **Important**: `status` and `download` require an `asyncTaskId` (UUID format, e.g.
|
||||
> `7ad0eb13-e9a5-4403-8070-1f7fe95b2f95`), **not** the generation ID (`gen_xxx`).
|
||||
> The asyncTaskId is printed after "→ Task" in the `video` / `image` command output.
|
||||
|
||||
---
|
||||
|
||||
## `lh generate text <prompt>` / `lh gen text <prompt>`
|
||||
@@ -54,7 +58,7 @@ cat README.md | lh gen text "summarize this" --pipe
|
||||
|
||||
## `lh generate image <prompt>` / `lh gen image <prompt>`
|
||||
|
||||
Generate images from text prompt. This is an async operation — the command submits the task and returns a generation ID + task ID for tracking.
|
||||
Generate images from text prompt. This is an async operation — the command submits the task and returns a generation ID + async task ID for tracking.
|
||||
|
||||
**Source**: `apps/cli/src/commands/generate/image.ts`
|
||||
|
||||
@@ -80,17 +84,22 @@ lh gen image "A cute cat" --model dall-e-3 --provider openai --json
|
||||
✓ Image generation started
|
||||
Batch ID: gb_xxx
|
||||
1 image(s) queued
|
||||
Generation gen_xxx → Task <taskId>
|
||||
Generation gen_xxx → Task 7ad0eb13-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
This is the asyncTaskId — use this for status/download
|
||||
|
||||
Use "lh generate status <generationId> <taskId>" to check progress.
|
||||
Use "lh generate status <generationId> <asyncTaskId>" to check progress.
|
||||
```
|
||||
|
||||
**Typical workflow**:
|
||||
|
||||
```bash
|
||||
# Generate image, then wait & download
|
||||
# 1. Submit generation — note down BOTH IDs from the output
|
||||
lh gen image "A cute cat"
|
||||
lh gen download <generationId> <taskId> -o cat.png
|
||||
# Generation gen_abc123 → Task 7ad0eb13-e9a5-4403-8070-1f7fe95b2f95
|
||||
|
||||
# 2. Wait & download using generationId + asyncTaskId (the UUID)
|
||||
lh gen download gen_abc123 7ad0eb13-e9a5-4403-8070-1f7fe95b2f95 -o cat.png
|
||||
```
|
||||
|
||||
---
|
||||
@@ -102,7 +111,7 @@ Generate video from text prompt. This is an async operation.
|
||||
**Source**: `apps/cli/src/commands/generate/video.ts`
|
||||
|
||||
```bash
|
||||
lh gen video "A cat playing piano" -m < model > -p < provider > [options]
|
||||
lh gen video "A cat playing piano" -m <model> -p <provider> [options]
|
||||
```
|
||||
|
||||
| Option | Description | Required |
|
||||
@@ -122,9 +131,26 @@ lh gen video "A cat playing piano" -m < model > -p < provider > [options]
|
||||
```
|
||||
✓ Video generation started
|
||||
Batch ID: gb_xxx
|
||||
Generation gen_xxx → Task <taskId>
|
||||
Generation gen_xxx → Task 7ad0eb13-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
This is the asyncTaskId — use this for status/download
|
||||
|
||||
Use "lh generate status <generationId> <taskId>" to check progress.
|
||||
Use "lh generate status <generationId> <asyncTaskId>" to check progress.
|
||||
```
|
||||
|
||||
**Typical workflow**:
|
||||
|
||||
```bash
|
||||
# 1. Find available video models for a provider
|
||||
lh model list volcengine --json | grep -i seedance
|
||||
|
||||
# 2. Submit generation — note down BOTH IDs from the output
|
||||
lh gen video "A cat on a runway" -m doubao-seedance-2-0-260128 -p volcengine \
|
||||
--aspect-ratio 9:16 --duration 5 --resolution 1080p
|
||||
# Generation gen_abc123 → Task 7ad0eb13-e9a5-4403-8070-1f7fe95b2f95
|
||||
|
||||
# 3. Wait & download using generationId + asyncTaskId (the UUID)
|
||||
lh gen download gen_abc123 7ad0eb13-e9a5-4403-8070-1f7fe95b2f95 -o result.mp4 --timeout 600
|
||||
```
|
||||
|
||||
---
|
||||
@@ -153,15 +179,18 @@ lh gen asr recording.wav [options]
|
||||
|
||||
---
|
||||
|
||||
## `lh generate download <generationId> <taskId>`
|
||||
## `lh generate download <generationId> <asyncTaskId>`
|
||||
|
||||
Wait for an async generation task to complete and download the result file.
|
||||
|
||||
**Source**: `apps/cli/src/commands/generate/index.ts`
|
||||
|
||||
> ⚠️ `<asyncTaskId>` is the UUID printed after "→ Task" in the video/image output.
|
||||
> Do **not** pass the generation ID (`gen_xxx`) here — that will cause a server error.
|
||||
|
||||
```bash
|
||||
lh gen download <generationId> <taskId> [-o output.png]
|
||||
lh gen download gen_xxx task_xxx -o ~/Desktop/result.mp4 --timeout 600
|
||||
lh gen download <generationId> <asyncTaskId> [-o output.png]
|
||||
lh gen download gen_xxx 7ad0eb13-xxxx-xxxx-xxxx-xxxxxxxxxxxx -o ~/Desktop/result.mp4 --timeout 600
|
||||
```
|
||||
|
||||
| Option | Description | Default |
|
||||
@@ -175,30 +204,21 @@ lh gen download gen_xxx task_xxx -o ~/Desktop/result.mp4 --timeout 600
|
||||
1. Polls `generation.getGenerationStatus` at the specified interval
|
||||
2. Shows live progress: `⋯ Status: processing... (42s)`
|
||||
3. On success: downloads asset URL to local file
|
||||
4. On error: displays error message and exits
|
||||
4. On error / wrong ID: displays a clear message pointing to the correct ID format
|
||||
5. On timeout: suggests using `lh gen status` to check later
|
||||
|
||||
**Typical workflow**:
|
||||
|
||||
```bash
|
||||
# One-shot: generate and download
|
||||
lh gen image "A sunset"
|
||||
# Copy the generation ID and task ID from output
|
||||
lh gen download gen_xxx taskId_xxx -o sunset.png
|
||||
|
||||
# Video (longer timeout)
|
||||
lh gen video "A cat running" -m model -p provider
|
||||
lh gen download gen_xxx taskId_xxx -o cat.mp4 --timeout 600
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `lh generate status <generationId> <taskId>`
|
||||
## `lh generate status <generationId> <asyncTaskId>`
|
||||
|
||||
Check the status of an async generation task.
|
||||
|
||||
> ⚠️ `<asyncTaskId>` is the UUID printed after "→ Task" in the video/image output.
|
||||
> Do **not** pass the generation ID (`gen_xxx`) here — that will cause a server error.
|
||||
|
||||
```bash
|
||||
lh gen status <generationId> <taskId> [--json]
|
||||
lh gen status <generationId> <asyncTaskId> [--json]
|
||||
lh gen status gen_xxx 7ad0eb13-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
@@ -235,12 +255,17 @@ Image and video generation use an async task pattern:
|
||||
- Triggers async background task (image via `createAsyncCaller`, video via `initModelRuntimeFromDB`)
|
||||
- Returns `{ data: { batch, generations }, success }` with `asyncTaskId` in each generation
|
||||
3. **Poll status** → `generation.getGenerationStatus`
|
||||
- Input: `{ generationId, asyncTaskId }` — both are required, and `asyncTaskId` must be the
|
||||
UUID from the `async_tasks` table, not `gen_xxx`
|
||||
- Returns `{ status, error, generation }` (generation includes asset URLs on success)
|
||||
- Before querying, calls `checkTimeoutTasks` which marks tasks as `error` if they have been
|
||||
`pending` or `processing` for more than ~5 minutes (`ASYNC_TASK_TIMEOUT = 298s`)
|
||||
|
||||
**Server routes**:
|
||||
|
||||
- `src/server/routers/lambda/image/index.ts` — image creation (uses `authedProcedure` + `serverDatabase`)
|
||||
- `src/server/routers/lambda/video/index.ts` — video creation (uses `authedProcedure` + `serverDatabase`)
|
||||
- `src/server/routers/lambda/generation.ts` — status checking
|
||||
- `packages/database/src/models/asyncTask.ts` — `AsyncTaskModel` including `checkTimeoutTasks`
|
||||
|
||||
**Note**: Image/video routes do NOT use the `keyVaults` middleware — they read API keys from the database via `initModelRuntimeFromDB` or `createAsyncCaller`.
|
||||
|
||||
@@ -1,58 +1,51 @@
|
||||
---
|
||||
name: code-review
|
||||
description: 'Code review checklist for LobeHub. Use when reviewing PRs, diffs, or code changes. Covers correctness, security, quality, and project-specific patterns.'
|
||||
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.'
|
||||
---
|
||||
|
||||
# Code Review Guide
|
||||
# Review Checklist
|
||||
|
||||
## Before You Start
|
||||
|
||||
1. Read `/typescript` and `/testing` skills for code style and test conventions
|
||||
2. Get the diff (skip if already in context, e.g., injected by GitHub review app): `git diff` or `git diff origin/canary..HEAD`
|
||||
|
||||
## Checklist
|
||||
|
||||
### Correctness
|
||||
## Correctness
|
||||
|
||||
- Leftover `console.log` / `console.debug` — should use `debug` package or remove
|
||||
- Missing `return await` in try/catch — see <https://typescript-eslint.io/rules/return-await/> (not in our ESLint config yet, requires type info)
|
||||
- Can the fix/implementation be more concise, efficient, or have better compatibility?
|
||||
|
||||
### Security
|
||||
## Security
|
||||
|
||||
- No sensitive data (API keys, tokens, credentials) in `console.*` or `debug()` output
|
||||
- No base64 output to terminal — extremely long, freezes output
|
||||
- No hardcoded secrets — use environment variables
|
||||
|
||||
### Testing
|
||||
## Testing
|
||||
|
||||
- Bug fixes must include tests covering the fixed scenario
|
||||
- New logic (services, store actions, utilities) should have test coverage
|
||||
- Existing tests still cover the changed behavior?
|
||||
- Prefer `vi.spyOn` over `vi.mock` (see `/testing` skill)
|
||||
|
||||
### i18n
|
||||
## i18n
|
||||
|
||||
- New user-facing strings use i18n keys, not hardcoded text
|
||||
- Keys added to `src/locales/default/{namespace}.ts` with `{feature}.{context}.{action|status}` naming
|
||||
- For PRs: `locales/` translations for all languages updated (`pnpm i18n`)
|
||||
|
||||
### SPA / routing
|
||||
## SPA / routing
|
||||
|
||||
- **`desktopRouter` pair:** If the diff touches `src/spa/router/desktopRouter.config.tsx`, does it also update `src/spa/router/desktopRouter.config.desktop.tsx` with the same route paths and nesting? Single-file edits often cause drift and blank screens.
|
||||
|
||||
### Reuse
|
||||
## Reuse
|
||||
|
||||
- Newly written code duplicates existing utilities in `packages/utils` or shared modules?
|
||||
- Copy-pasted blocks with slight variation — extract into shared function
|
||||
- `antd` imports replaceable with `@lobehub/ui` wrapped components (`Input`, `Button`, `Modal`, `Avatar`, etc.)
|
||||
- Use `antd-style` token system, not hardcoded colors; prefer `createStaticStyles` + `cssVar.*` over `createStyles` + `token` unless runtime computation is required
|
||||
|
||||
### Database
|
||||
## Database
|
||||
|
||||
- Migration scripts must be idempotent (`IF NOT EXISTS`, `IF EXISTS` guards)
|
||||
|
||||
### Cloud Impact
|
||||
## Cloud Impact
|
||||
|
||||
A downstream cloud deployment depends on this repo. Flag changes that may require cloud-side updates:
|
||||
|
||||
@@ -61,13 +54,3 @@ A downstream cloud deployment depends on this repo. Flag changes that may requir
|
||||
- **Dependency versions bumped** — e.g., upgrading `next` or `drizzle-orm` in `package.json`
|
||||
- **`@lobechat/business-*` exports changed** — e.g., renaming a function in `src/business/` or changing type signatures in `packages/business/`
|
||||
- `src/business/` and `packages/business/` must not expose cloud commercial logic in comments or code
|
||||
|
||||
## Output Format
|
||||
|
||||
For local CLI review only (GitHub review app posts inline PR comments instead):
|
||||
|
||||
- Number all findings sequentially
|
||||
- Indicate priority: `[high]` / `[medium]` / `[low]`
|
||||
- Include file path and line number for each finding
|
||||
- Only list problems — no summary, no praise
|
||||
- Re-read full source for each finding to verify it's real, then output "All findings verified."
|
||||
@@ -238,13 +238,34 @@ Use `---` separators between major blocks for long releases.
|
||||
- Keep concise.
|
||||
- Must include `Migration overview`, operator impact, and rollback/backup note.
|
||||
|
||||
### Contributor Ordering
|
||||
|
||||
Render contributors as a **single flat list** (no separate "Community" / "Core Team" subsections). Order: **community contributors first, team members after**. Within each group, sort by PR count desc. Bots (`@lobehubbot`, `renovate[bot]`) go on a separate "maintenance" line.
|
||||
|
||||
**LobeHub team roster** — anyone in this list is a team member; anyone not in this list is a community contributor:
|
||||
|
||||
- @arvinxx
|
||||
- @Innei
|
||||
- @tjx666 (commit author name: YuTengjing)
|
||||
- @LiJian
|
||||
- @Neko
|
||||
- @Rdmclin2
|
||||
- @AmAzing129
|
||||
- @sudongyuer
|
||||
- @rivertwilight
|
||||
- @CanisMinor
|
||||
|
||||
> **Resolving handles** — git author names (e.g. `YuTengjing`) are not always the GitHub handle. Verify via `gh pr view <PR> --json author` or `gh api search/users -f q='<email>'` before listing.
|
||||
|
||||
If a new contributor appears who is not on this list, treat them as community by default and ask the user whether to add them to the roster.
|
||||
|
||||
### GitHub Release Changelog Template
|
||||
|
||||
```md
|
||||
# 🚀 LobeHub v<x.y.z> (<YYYYMMDD>)
|
||||
|
||||
**Release Date:** <Month DD, YYYY>
|
||||
**Since <Previous Version>:** <N commits> · <N merged PRs> · <N resolved issues> · <N contributors>
|
||||
**Since <Previous Version>:** <N merged PRs> · <N resolved issues> · <N contributors>
|
||||
|
||||
> <One release thesis sentence: what this release unlocks in practice.>
|
||||
|
||||
@@ -296,12 +317,11 @@ Use `---` separators between major blocks for long releases.
|
||||
|
||||
## 👥 Contributors
|
||||
|
||||
**<N merged PRs>** from **<N contributors>** across **<N commits>**.
|
||||
Huge thanks to **<N contributors>** who shipped **<N merged PRs>** this cycle.
|
||||
|
||||
### Community Contributors
|
||||
@<community-handle> · @<community-handle> · @<team-handle> · @<team-handle>
|
||||
|
||||
- @<username> - <notable contribution area>
|
||||
- @<username> - <notable contribution area>
|
||||
Plus @lobehubbot and renovate[bot] for maintenance.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# 🚀 LobeHub v2.1.54 (20260427)
|
||||
|
||||
**Hotfix Scope:** Agent topic-switching regression — stale chat state on agent change
|
||||
|
||||
> Clears residual topic state when navigating between agents and restores blank-canvas behavior on agent switch.
|
||||
|
||||
## 🐛 What's Fixed
|
||||
|
||||
- **Stale topic on agent switch** — Switching from `/agent/agt_A/tpc_X` to `/agent/agt_B` no longer leaves the previous topic's messages on screen, and _Start new topic_ responds again. (#14231)
|
||||
- **Header & sidebar consistency** — Conversation header now shows the active subtopic's title, and the sidebar keeps the parent topic's thread list expanded while a thread is open.
|
||||
|
||||
## ⚙️ Upgrade
|
||||
|
||||
- Self-hosted: pull the new image and restart. No schema or env changes.
|
||||
- Cloud: applied automatically.
|
||||
|
||||
## 👥 Owner
|
||||
|
||||
@{pr-author}
|
||||
|
||||
> **Note for Claude**: Replace `{pr-author}` with the actual PR author. Retrieve via `gh pr view <number> --json author --jq '.author.login'`. Do not hardcode a username.
|
||||
@@ -59,7 +59,10 @@ git push -u origin hotfix/v{version}-{short-hash}
|
||||
|
||||
2. **Create PR to main** with a gitmoji prefix title (e.g. `🐛 fix: description`)
|
||||
|
||||
3. **After merge**: auto-tag-release detects `hotfix/*` branch → auto patch +1.
|
||||
3. **Write a short hotfix changelog** — See `changelog-example/hotfix.md`. Keep it minimal: scope line, 1-3 fix bullets (symptom + fix in one sentence), upgrade note, owner. No long root-cause section — that lives in the commit message.
|
||||
- **Hotfix owner**: Use the actual PR author (retrieve via `gh pr view <number> --json author --jq '.author.login'`), never hardcode a username.
|
||||
|
||||
4. **After merge**: auto-tag-release detects `hotfix/*` branch → auto patch +1.
|
||||
|
||||
### Script
|
||||
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
|
||||
## Quick Reference by Name
|
||||
|
||||
- **@arvinxx**: Last resort only, mention for priority:high issues, tool calling, mcp, database
|
||||
- **@arvinxx**: General/uncategorized issues (default assignee), priority:high issues, tool calling, mcp, database
|
||||
- **@canisminor1990**: Design, UI components, editor, markdown rendering
|
||||
- **@tjx666**: Image/video generation, vision, cloud version, documentation, TTS, auth, login/register, database
|
||||
- **@ONLY-yours**: Performance, streaming, settings, general bugs, web platform, marketplace, agent builder, schedule task
|
||||
- **@tjx666**: Model providers and configuration, new model additions, image/video generation, vision, cloud version, documentation, TTS, auth, login/register, database
|
||||
- **@ONLY-yours**: Performance, streaming, settings, web platform, marketplace, agent builder, schedule task
|
||||
- **@Innei**: Knowledge base, files (KB-related), group chat, Electron, desktop client, build system
|
||||
- **@nekomeowww**: Memory, backend, deployment, DevOps, database
|
||||
- **@sudongyuer**: Mobile app (React Native)
|
||||
- **@sxjeru**: Model providers and configuration
|
||||
- **@rdmclin2**: Team workspace, IM and bot integration
|
||||
- **@tcmonster**: Subscription, refund, recharge, business cooperation
|
||||
|
||||
@@ -21,7 +20,7 @@ Quick reference for assigning issues based on labels.
|
||||
|
||||
| Label | Owner | Notes |
|
||||
| ---------------- | ------- | -------------------------------------------- |
|
||||
| All `provider:*` | @sxjeru | Model configuration and provider integration |
|
||||
| All `provider:*` | @tjx666 | Model configuration and provider integration |
|
||||
|
||||
### Platform Labels (platform:\*)
|
||||
|
||||
@@ -100,11 +99,10 @@ Quick reference for assigning issues based on labels.
|
||||
|
||||
1. **Specific feature owner** - e.g., `feature:knowledge-base` → @RiverTwilight
|
||||
2. **Platform owner** - e.g., `platform:mobile` → @sudongyuer
|
||||
3. **Provider owner** - e.g., `provider:*` → @sxjeru
|
||||
3. **Provider owner** - e.g., `provider:*` → @tjx666
|
||||
4. **Component owner** - e.g., 💄 Design → @canisminor1990
|
||||
5. **Infrastructure owner** - e.g., `deployment:*` → @nekomeowww
|
||||
6. **General maintainer** - @ONLY-yours for general bugs/issues
|
||||
7. **Last resort** - @arvinxx (only if no clear owner)
|
||||
6. **Default assignee** - @arvinxx for general/uncategorized issues
|
||||
|
||||
### Special Cases
|
||||
|
||||
@@ -121,8 +119,7 @@ Quick reference for assigning issues based on labels.
|
||||
|
||||
**No clear owner:**
|
||||
|
||||
- Assign to @ONLY-yours for general issues
|
||||
- Only mention @arvinxx if critical and truly unclear
|
||||
- Assign to @arvinxx for general issues
|
||||
|
||||
## Comment Templates
|
||||
|
||||
|
||||
@@ -146,4 +146,5 @@ apps/desktop/resources/cli-package.json
|
||||
|
||||
# Superpowers plugin brainstorm/spec outputs (local only; do not commit)
|
||||
.superpowers/
|
||||
docs/superpowers/
|
||||
.heerogeneous-tracing
|
||||
|
||||
@@ -1,100 +1,128 @@
|
||||
# LobeHub Development Guidelines
|
||||
|
||||
This document serves as a comprehensive guide for all team members when developing LobeHub.
|
||||
|
||||
## Project Description
|
||||
|
||||
You are developing an open-source, modern-design AI Agent Workspace: LobeHub (previously LobeChat).
|
||||
Guidelines for using AI coding agents in this LobeHub repository.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Frontend**: Next.js 16, React 19, TypeScript
|
||||
- **UI Components**: Ant Design, @lobehub/ui, antd-style
|
||||
- **State Management**: Zustand, SWR
|
||||
- **Database**: PostgreSQL, PGLite, Drizzle ORM
|
||||
- **Testing**: Vitest, Testing Library
|
||||
- **Package Manager**: pnpm (monorepo structure)
|
||||
- Next.js 16 + React 19 + TypeScript
|
||||
- SPA inside Next.js with `react-router-dom`
|
||||
- `@lobehub/ui`, antd for components; antd-style for CSS-in-JS — **prefer `createStaticStyles` with `cssVar.*`** (zero-runtime); only fall back to `createStyles` + `token` when styles genuinely need runtime computation. See `.cursor/docs/createStaticStyles_migration_guide.md`.
|
||||
- react-i18next for i18n; zustand for state management
|
||||
- SWR for data fetching; TRPC for type-safe backend
|
||||
- Drizzle ORM with PostgreSQL; Vitest for testing
|
||||
|
||||
## Directory Structure
|
||||
## Project Structure
|
||||
|
||||
```plaintext
|
||||
lobehub/
|
||||
├── apps/desktop/ # Electron desktop app
|
||||
├── apps/
|
||||
│ ├── desktop/ # Electron desktop app
|
||||
│ ├── cli/ # LobeHub CLI
|
||||
│ └── device-gateway/ # Device gateway service
|
||||
├── packages/ # Shared packages (@lobechat/*)
|
||||
│ ├── database/ # Database schemas, models, repositories
|
||||
│ ├── agent-runtime/ # Agent runtime
|
||||
│ └── ...
|
||||
├── src/
|
||||
│ ├── app/ # Next.js app router
|
||||
│ ├── spa/ # SPA entry points (entry.*.tsx) and router config
|
||||
│ ├── routes/ # SPA page components (roots)
|
||||
│ ├── features/ # Business components by domain
|
||||
│ ├── app/ # Next.js App Router (backend API + auth)
|
||||
│ │ ├── (backend)/ # API routes (trpc, webapi, etc.)
|
||||
│ │ ├── spa/ # SPA HTML template service
|
||||
│ │ └── [variants]/(auth)/ # Auth pages (SSR required)
|
||||
│ ├── routes/ # SPA page components (Vite)
|
||||
│ │ ├── (main)/ # Desktop pages
|
||||
│ │ ├── (mobile)/ # Mobile pages
|
||||
│ │ ├── (desktop)/ # Desktop-specific pages
|
||||
│ │ ├── (popup)/ # Popup window pages
|
||||
│ │ ├── onboarding/ # Onboarding pages
|
||||
│ │ └── share/ # Share pages
|
||||
│ ├── spa/ # SPA entry points and router config
|
||||
│ │ ├── entry.web.tsx # Web entry
|
||||
│ │ ├── entry.mobile.tsx
|
||||
│ │ ├── entry.desktop.tsx
|
||||
│ │ ├── entry.popup.tsx
|
||||
│ │ └── router/ # React Router configuration
|
||||
│ ├── store/ # Zustand stores
|
||||
│ ├── services/ # Client services
|
||||
│ ├── server/ # Server services and routers
|
||||
│ └── ...
|
||||
├── .agents/skills/ # AI development skills
|
||||
└── e2e/ # E2E tests (Cucumber + Playwright)
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
## SPA Routes and Features
|
||||
|
||||
SPA-related code is grouped under `src/spa/` (entries + router) and `src/routes/` (page segments). We use a **roots vs features** split: route trees only hold page segments; business logic and UI live in features.
|
||||
|
||||
- **`src/spa/`** – SPA entry points (`entry.web.tsx`, `entry.mobile.tsx`, `entry.desktop.tsx`, `entry.popup.tsx`) and React Router config (`router/`, with `desktopRouter.config.*`, `mobileRouter.config.tsx`, `popupRouter.config.tsx`). Keeps router config next to entries to avoid confusion with `src/routes/`.
|
||||
|
||||
- **`src/routes/` (roots)**\
|
||||
Only page-segment files: `_layout/index.tsx`, `index.tsx` (or `page.tsx`), and dynamic segments like `[id]/index.tsx`. Keep these **thin**: they should only import from `@/features/*` and compose layout/page, with no business logic or heavy UI.
|
||||
|
||||
- **`src/features/`**\
|
||||
Business components by **domain** (e.g. `Pages`, `PageEditor`, `Home`). Put layout chunks (sidebar, header, body), hooks, and domain-specific UI here. Each feature exposes an `index.ts` (or `index.tsx`) with clear exports.
|
||||
|
||||
When adding or changing SPA routes:
|
||||
|
||||
1. In `src/routes/`, add only the route segment files (layout + page) that delegate to features.
|
||||
2. Implement layout and page content under `src/features/<Domain>/` and export from there.
|
||||
3. In route files, use `import { X } from '@/features/<Domain>'` (or `import Y from '@/features/<Domain>/...'`). Do not add new `features/` folders inside `src/routes/`.
|
||||
4. **Register the desktop route tree in both configs:** `src/spa/router/desktopRouter.config.tsx` and `src/spa/router/desktopRouter.config.desktop.tsx` must stay in sync (same paths and nesting). Updating only one can cause **blank screens** if the other build path expects the route. `desktopRouter.sync.test.tsx` guards this invariant — keep it passing.
|
||||
|
||||
See the **spa-routes** skill (`.agents/skills/spa-routes/SKILL.md`) for the full convention and file-division rules.
|
||||
|
||||
## Development
|
||||
|
||||
### Starting the Dev Environment
|
||||
|
||||
```bash
|
||||
# SPA dev mode (frontend only, proxies API to localhost:3010)
|
||||
bun run dev:spa
|
||||
|
||||
# Full-stack dev (Next.js + Vite SPA concurrently)
|
||||
bun run dev
|
||||
```
|
||||
|
||||
After `dev:spa` starts, the terminal prints a **Debug Proxy** URL:
|
||||
|
||||
```plaintext
|
||||
Debug Proxy: https://app.lobehub.com/_dangerous_local_dev_proxy?debug-host=http%3A%2F%2Flocalhost%3A9876
|
||||
```
|
||||
|
||||
Open this URL to develop locally against the production backend (app.lobehub.com). The proxy page loads your local Vite dev server's SPA into the online environment, enabling HMR with real server config.
|
||||
|
||||
### Git Workflow
|
||||
|
||||
- **Branch strategy**: `canary` is the development branch (cloud production); `main` is the release branch (periodically cherry-picks from canary)
|
||||
- New branches should be created from `canary`; PRs should target `canary`
|
||||
- Use rebase for git pull
|
||||
- Git commit messages should prefix with gitmoji
|
||||
- Git branch name format: `feat/feature-name`
|
||||
- Use `.github/PULL_REQUEST_TEMPLATE.md` for PR descriptions
|
||||
- **Protection of local changes**: Never use `git restore`, `git checkout --`, `git reset --hard`, or any other command or workflow that can forcibly overwrite, discard, or silently replace user-owned uncommitted changes. Before any revert or restoration affecting existing files, inspect the working tree carefully and obtain explicit user confirmation.
|
||||
- Use rebase for `git pull`
|
||||
- Commit messages: prefix with gitmoji
|
||||
- Branch format: `<type>/<feature-name>`
|
||||
|
||||
### Package Management
|
||||
|
||||
- Use `pnpm` as the primary package manager
|
||||
- Use `bun` to run npm scripts
|
||||
- Use `bunx` to run executable npm packages
|
||||
- `pnpm` for dependency management
|
||||
- `bun` to run npm scripts
|
||||
- `bunx` for executable npm packages
|
||||
|
||||
### Code Style Guidelines
|
||||
|
||||
#### TypeScript
|
||||
|
||||
- Prefer interfaces over types for object shapes
|
||||
|
||||
### Testing Strategy
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Web tests
|
||||
bunx vitest run --silent='passed-only' '[file-path-pattern]'
|
||||
# Run specific test (NEVER run `bun run test` - takes ~10 minutes)
|
||||
bunx vitest run --silent='passed-only' '[file-path]'
|
||||
|
||||
# Package tests (e.g., database)
|
||||
cd packages/[package-name] && bunx vitest run --silent='passed-only' '[file-path-pattern]'
|
||||
# Database package
|
||||
cd packages/database && bunx vitest run --silent='passed-only' '[file]'
|
||||
```
|
||||
|
||||
**Important Notes**:
|
||||
|
||||
- Wrap file paths in single quotes to avoid shell expansion
|
||||
- Never run `bun run test` - this runs all tests and takes \~10 minutes
|
||||
|
||||
### Type Checking
|
||||
|
||||
- Use `bun run type-check` to check for type errors
|
||||
- Prefer `vi.spyOn` over `vi.mock`
|
||||
- Tests must pass type check: `bun run type-check`
|
||||
- After 2 failed fix attempts, stop and ask for help
|
||||
|
||||
### i18n
|
||||
|
||||
- **Keys**: Add to `src/locales/default/namespace.ts`
|
||||
- **Dev**: Translate `locales/zh-CN/namespace.json` locale file only for preview
|
||||
- DON'T run `pnpm i18n`, let CI auto handle it
|
||||
- Add keys to a namespace file under `src/locales/default/` (e.g. `agent.ts`, `auth.ts`)
|
||||
- For dev preview: translate `locales/zh-CN/` and `locales/en-US/`
|
||||
- `pnpm i18n` is slow; run it manually when locale keys need updating (e.g. before opening a PR).
|
||||
|
||||
## SPA Routes and Features
|
||||
### Code Review
|
||||
|
||||
- **`src/routes/`** holds only page segments (`_layout/index.tsx`, `index.tsx`, `[id]/index.tsx`). Keep route files **thin** — import from `@/features/*` and compose, no business logic.
|
||||
- **`src/features/`** holds business components by **domain** (e.g. `Pages`, `PageEditor`, `Home`). Layout pieces, hooks, and domain UI go here.
|
||||
- **Desktop router parity:** When changing the main SPA route tree, update **both** `src/spa/router/desktopRouter.config.tsx` (dynamic imports) and `src/spa/router/desktopRouter.config.desktop.tsx` (sync imports) so paths and nesting match. Changing only one can leave routes unregistered and cause **blank screens**.
|
||||
- See the **spa-routes** skill (`.agents/skills/spa-routes/SKILL.md`) for the full convention and file-division rules.
|
||||
|
||||
## Skills (Auto-loaded)
|
||||
|
||||
All AI development skills are available in `.agents/skills/` directory and auto-loaded by Claude Code when relevant.
|
||||
|
||||
**IMPORTANT**: When reviewing PRs or code diffs, ALWAYS read `.agents/skills/code-review/SKILL.md` first.
|
||||
Before reviewing a PR / diff / branch change, read the **review-checklist** skill (`.agents/skills/review-checklist/SKILL.md`) — it lists the recurring mistakes specific to this codebase.
|
||||
|
||||
@@ -2,6 +2,31 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
### [Version 2.1.52](https://github.com/lobehub/lobe-chat/compare/v2.1.51...v2.1.52)
|
||||
|
||||
<sup>Released on **2026-04-20**</sup>
|
||||
|
||||
#### 👷 Build System
|
||||
|
||||
- **database**: add topic status and tasks automation mode.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### Build System
|
||||
|
||||
- **database**: add topic status and tasks automation mode, closes [#13994](https://github.com/lobehub/lobe-chat/issues/13994) ([3bcd581](https://github.com/lobehub/lobe-chat/commit/3bcd581))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## [Version 2.1.51](https://github.com/lobehub/lobe-chat/compare/v0.0.0-nightly.pr13850.8503...v2.1.51)
|
||||
|
||||
<sup>Released on **2026-04-16**</sup>
|
||||
|
||||
@@ -1,123 +1 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Guidelines for using Claude Code in this LobeHub repository.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- Next.js 16 + React 19 + TypeScript
|
||||
- SPA inside Next.js with `react-router-dom`
|
||||
- `@lobehub/ui`, antd for components; antd-style for CSS-in-JS — **prefer `createStaticStyles` with `cssVar.*`** (zero-runtime); only fall back to `createStyles` + `token` when styles genuinely need runtime computation. See `.cursor/docs/createStaticStyles_migration_guide.md`.
|
||||
- react-i18next for i18n; zustand for state management
|
||||
- SWR for data fetching; TRPC for type-safe backend
|
||||
- Drizzle ORM with PostgreSQL; Vitest for testing
|
||||
|
||||
## Project Structure
|
||||
|
||||
```plaintext
|
||||
lobehub/
|
||||
├── apps/desktop/ # Electron desktop app
|
||||
├── packages/ # Shared packages (@lobechat/*)
|
||||
│ ├── database/ # Database schemas, models, repositories
|
||||
│ ├── agent-runtime/ # Agent runtime
|
||||
│ └── ...
|
||||
├── src/
|
||||
│ ├── app/ # Next.js App Router (backend API + auth)
|
||||
│ │ ├── (backend)/ # API routes (trpc, webapi, etc.)
|
||||
│ │ ├── spa/ # SPA HTML template service
|
||||
│ │ └── [variants]/(auth)/ # Auth pages (SSR required)
|
||||
│ ├── routes/ # SPA page components (Vite)
|
||||
│ │ ├── (main)/ # Desktop pages
|
||||
│ │ ├── (mobile)/ # Mobile pages
|
||||
│ │ ├── (desktop)/ # Desktop-specific pages
|
||||
│ │ ├── onboarding/ # Onboarding pages
|
||||
│ │ └── share/ # Share pages
|
||||
│ ├── spa/ # SPA entry points and router config
|
||||
│ │ ├── entry.web.tsx # Web entry
|
||||
│ │ ├── entry.mobile.tsx
|
||||
│ │ ├── entry.desktop.tsx
|
||||
│ │ └── router/ # React Router configuration
|
||||
│ ├── store/ # Zustand stores
|
||||
│ ├── services/ # Client services
|
||||
│ ├── server/ # Server services and routers
|
||||
│ └── ...
|
||||
└── e2e/ # E2E tests (Cucumber + Playwright)
|
||||
```
|
||||
|
||||
## SPA Routes and Features
|
||||
|
||||
SPA-related code is grouped under `src/spa/` (entries + router) and `src/routes/` (page segments). We use a **roots vs features** split: route trees only hold page segments; business logic and UI live in features.
|
||||
|
||||
- **`src/spa/`** – SPA entry points (`entry.web.tsx`, `entry.mobile.tsx`, `entry.desktop.tsx`) and React Router config (`router/`). Keeps router config next to entries to avoid confusion with `src/routes/`.
|
||||
|
||||
- **`src/routes/` (roots)**\
|
||||
Only page-segment files: `_layout/index.tsx`, `index.tsx` (or `page.tsx`), and dynamic segments like `[id]/index.tsx`. Keep these **thin**: they should only import from `@/features/*` and compose layout/page, with no business logic or heavy UI.
|
||||
|
||||
- **`src/features/`**\
|
||||
Business components by **domain** (e.g. `Pages`, `PageEditor`, `Home`). Put layout chunks (sidebar, header, body), hooks, and domain-specific UI here. Each feature exposes an `index.ts` (or `index.tsx`) with clear exports.
|
||||
|
||||
When adding or changing SPA routes:
|
||||
|
||||
1. In `src/routes/`, add only the route segment files (layout + page) that delegate to features.
|
||||
2. Implement layout and page content under `src/features/<Domain>/` and export from there.
|
||||
3. In route files, use `import { X } from '@/features/<Domain>'` (or `import Y from '@/features/<Domain>/...'`). Do not add new `features/` folders inside `src/routes/`.
|
||||
4. **Register the desktop route tree in both configs:** `src/spa/router/desktopRouter.config.tsx` and `src/spa/router/desktopRouter.config.desktop.tsx` must stay in sync (same paths and nesting). Updating only one can cause **blank screens** if the other build path expects the route.
|
||||
|
||||
See the **spa-routes** skill (`.agents/skills/spa-routes/SKILL.md`) for the full convention and file-division rules.
|
||||
|
||||
## Development
|
||||
|
||||
### Starting the Dev Environment
|
||||
|
||||
```bash
|
||||
# SPA dev mode (frontend only, proxies API to localhost:3010)
|
||||
bun run dev:spa
|
||||
|
||||
# Full-stack dev (Next.js + Vite SPA concurrently)
|
||||
bun run dev
|
||||
```
|
||||
|
||||
After `dev:spa` starts, the terminal prints a **Debug Proxy** URL:
|
||||
|
||||
```plaintext
|
||||
Debug Proxy: https://app.lobehub.com/_dangerous_local_dev_proxy?debug-host=http%3A%2F%2Flocalhost%3A9876
|
||||
```
|
||||
|
||||
Open this URL to develop locally against the production backend (app.lobehub.com). The proxy page loads your local Vite dev server's SPA into the online environment, enabling HMR with real server config.
|
||||
|
||||
### Git Workflow
|
||||
|
||||
- **Branch strategy**: `canary` is the development branch (cloud production); `main` is the release branch (periodically cherry-picks from canary)
|
||||
- New branches should be created from `canary`; PRs should target `canary`
|
||||
- Use rebase for `git pull`
|
||||
- Commit messages: prefix with gitmoji
|
||||
- Branch format: `<type>/<feature-name>`
|
||||
|
||||
### Package Management
|
||||
|
||||
- `pnpm` for dependency management
|
||||
- `bun` to run npm scripts
|
||||
- `bunx` for executable npm packages
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Run specific test (NEVER run `bun run test` - takes ~10 minutes)
|
||||
bunx vitest run --silent='passed-only' '[file-path]'
|
||||
|
||||
# Database package
|
||||
cd packages/database && bunx vitest run --silent='passed-only' '[file]'
|
||||
```
|
||||
|
||||
- Prefer `vi.spyOn` over `vi.mock`
|
||||
- Tests must pass type check: `bun run type-check`
|
||||
- After 2 failed fix attempts, stop and ask for help
|
||||
|
||||
### i18n
|
||||
|
||||
- Add keys to `src/locales/default/namespace.ts`
|
||||
- For dev preview: translate `locales/zh-CN/` and `locales/en-US/`
|
||||
- Don't run `pnpm i18n` - CI handles it
|
||||
|
||||
## Skills (Auto-loaded by Claude)
|
||||
|
||||
Claude Code automatically loads relevant skills from `.agents/skills/`.
|
||||
@AGENTS.md
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
|
||||
.\" Manual command details come from the Commander command tree.
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.8" "User Commands"
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.9" "User Commands"
|
||||
.SH NAME
|
||||
lh \- LobeHub CLI \- manage and connect to LobeHub services
|
||||
.SH SYNOPSIS
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lobehub/cli",
|
||||
"version": "0.0.8",
|
||||
"version": "0.0.9",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"lh": "./dist/index.js",
|
||||
|
||||
@@ -7,12 +7,14 @@ const CLIENT_ID = 'lobehub-cli';
|
||||
* Get a valid access token, refreshing if expired.
|
||||
* Returns null if no credentials or refresh fails.
|
||||
*/
|
||||
export async function getValidToken(): Promise<{ credentials: StoredCredentials } | null> {
|
||||
export async function getValidToken(
|
||||
bufferSeconds = 60,
|
||||
): Promise<{ credentials: StoredCredentials } | null> {
|
||||
const credentials = loadCredentials();
|
||||
if (!credentials) return null;
|
||||
|
||||
// Check if token is still valid (with 60s buffer)
|
||||
if (credentials.expiresAt && Date.now() / 1000 < credentials.expiresAt - 60) {
|
||||
// Check if token is still valid (with configurable buffer)
|
||||
if (credentials.expiresAt && Date.now() / 1000 < credentials.expiresAt - bufferSeconds) {
|
||||
return { credentials };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../auth/refresh', () => ({
|
||||
getValidToken: vi.fn().mockResolvedValue({
|
||||
credentials: { accessToken: 'test-token', expiresAt: undefined, refreshToken: 'test-refresh' },
|
||||
}),
|
||||
}));
|
||||
vi.mock('../auth/resolveToken', () => ({
|
||||
resolveToken: vi.fn().mockResolvedValue({
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
@@ -83,16 +88,21 @@ vi.mock('@lobechat/device-gateway-client', () => ({
|
||||
on: vi.fn().mockImplementation((event: string, handler: (...args: any[]) => any) => {
|
||||
clientEventHandlers[event] = handler;
|
||||
}),
|
||||
reconnect: vi.fn().mockResolvedValue(undefined),
|
||||
sendSystemInfoResponse: vi.fn().mockImplementation((data: any) => {
|
||||
lastSentSystemInfoResponse = data;
|
||||
}),
|
||||
sendToolCallResponse: vi.fn().mockImplementation((data: any) => {
|
||||
lastSentToolResponse = data;
|
||||
}),
|
||||
updateToken: vi.fn(),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import-x/first
|
||||
import { GatewayClient } from '@lobechat/device-gateway-client';
|
||||
|
||||
// eslint-disable-next-line import-x/first
|
||||
import { resolveToken } from '../auth/resolveToken';
|
||||
// eslint-disable-next-line import-x/first
|
||||
@@ -242,13 +252,33 @@ describe('connect command', () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'connect']);
|
||||
|
||||
clientEventHandlers['auth_failed']?.('invalid token');
|
||||
await clientEventHandlers['auth_failed']?.('invalid token');
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Authentication failed'));
|
||||
expect(cleanupAllProcesses).toHaveBeenCalled();
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should retry auth_failed with token refresh when new token available', async () => {
|
||||
vi.mocked(resolveToken).mockResolvedValueOnce({
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
token: 'refreshed-token',
|
||||
tokenType: 'jwt',
|
||||
userId: 'test-user',
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'connect']);
|
||||
|
||||
const mockClient = vi.mocked(GatewayClient).mock.results[0].value;
|
||||
|
||||
await clientEventHandlers['auth_failed']?.('token expired');
|
||||
|
||||
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Token refreshed'));
|
||||
expect(mockClient.updateToken).toHaveBeenCalledWith('refreshed-token');
|
||||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle auth_expired', async () => {
|
||||
vi.mocked(resolveToken).mockResolvedValueOnce({
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
import { GatewayClient } from '@lobechat/device-gateway-client';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { getValidToken } from '../auth/refresh';
|
||||
import { resolveToken } from '../auth/resolveToken';
|
||||
import { CLI_API_KEY_ENV } from '../constants/auth';
|
||||
import { OFFICIAL_GATEWAY_URL } from '../constants/urls';
|
||||
@@ -284,8 +285,44 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
updateStatus('reconnecting');
|
||||
});
|
||||
|
||||
// Handle auth failed
|
||||
client.on('auth_failed', (reason) => {
|
||||
// Proactive token refresh — schedule before JWT expires
|
||||
const startProactiveRefresh = () =>
|
||||
scheduleProactiveRefresh(
|
||||
auth,
|
||||
(refreshed) => {
|
||||
client.updateToken(refreshed.token);
|
||||
auth = refreshed;
|
||||
// Schedule next refresh based on the new token
|
||||
cancelRefreshTimer = startProactiveRefresh();
|
||||
},
|
||||
info,
|
||||
error,
|
||||
);
|
||||
let cancelRefreshTimer = startProactiveRefresh();
|
||||
|
||||
// Handle auth failed — attempt token refresh once before giving up
|
||||
// (e.g., auto-reconnect may send an expired JWT before proactive refresh fires)
|
||||
let authFailedRefreshAttempted = false;
|
||||
client.on('auth_failed', async (reason) => {
|
||||
if (auth.tokenType === 'jwt' && !authFailedRefreshAttempted) {
|
||||
authFailedRefreshAttempted = true;
|
||||
info(`Authentication failed (${reason}). Attempting token refresh...`);
|
||||
try {
|
||||
const refreshed = await resolveToken({});
|
||||
if (refreshed && refreshed.token !== auth.token) {
|
||||
info('Token refreshed successfully. Reconnecting...');
|
||||
client.updateToken(refreshed.token);
|
||||
auth = refreshed;
|
||||
authFailedRefreshAttempted = false;
|
||||
cancelRefreshTimer = startProactiveRefresh();
|
||||
await client.reconnect();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
error(`Authentication failed: ${reason}`);
|
||||
error(
|
||||
`Run 'lh login', or set ${CLI_API_KEY_ENV} and run 'lh login --server <url>' to configure API key authentication.`,
|
||||
@@ -308,8 +345,8 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
if (refreshed) {
|
||||
info('Token refreshed successfully. Reconnecting...');
|
||||
client.updateToken(refreshed.token);
|
||||
// Update cached auth so subsequent refreshes use the latest token
|
||||
auth = refreshed;
|
||||
cancelRefreshTimer = startProactiveRefresh();
|
||||
await client.reconnect();
|
||||
return;
|
||||
}
|
||||
@@ -330,6 +367,7 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
// Graceful shutdown
|
||||
const cleanup = () => {
|
||||
info('Shutting down...');
|
||||
cancelRefreshTimer?.();
|
||||
cleanupAllProcesses();
|
||||
client.disconnect();
|
||||
removeStatus();
|
||||
@@ -374,6 +412,69 @@ function formatUptime(startedAt: Date): string {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
// How far before expiry to proactively refresh (1 hour)
|
||||
const PROACTIVE_REFRESH_BUFFER = 60 * 60;
|
||||
|
||||
/**
|
||||
* Parse the `exp` claim from a JWT without verifying the signature.
|
||||
*/
|
||||
function parseJwtExp(token: string): number | undefined {
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString());
|
||||
return typeof payload.exp === 'number' ? payload.exp : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a proactive token refresh before the JWT expires.
|
||||
* Returns a cleanup function that cancels the scheduled timer.
|
||||
*/
|
||||
function scheduleProactiveRefresh(
|
||||
auth: { token: string; tokenType: string },
|
||||
onRefreshed: (newAuth: Awaited<ReturnType<typeof resolveToken>>) => void,
|
||||
info: (msg: string) => void,
|
||||
error: (msg: string) => void,
|
||||
): (() => void) | null {
|
||||
if (auth.tokenType !== 'jwt') return null;
|
||||
|
||||
const exp = parseJwtExp(auth.token);
|
||||
if (!exp) return null;
|
||||
|
||||
const refreshAt = (exp - PROACTIVE_REFRESH_BUFFER) * 1000;
|
||||
const delay = refreshAt - Date.now();
|
||||
|
||||
if (delay < 0) {
|
||||
// Already past the refresh window — refresh immediately on next tick
|
||||
void doRefresh();
|
||||
return null;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => void doRefresh(), delay);
|
||||
return () => clearTimeout(timer);
|
||||
|
||||
async function doRefresh() {
|
||||
try {
|
||||
// Use the same buffer so getValidToken actually triggers a refresh
|
||||
const result = await getValidToken(PROACTIVE_REFRESH_BUFFER);
|
||||
if (!result) {
|
||||
error('Proactive token refresh failed — no valid credentials.');
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshed = await resolveToken({});
|
||||
// Only notify if the token actually changed to avoid reschedule loops
|
||||
if (refreshed.token !== auth.token) {
|
||||
info('Proactively refreshed token.');
|
||||
onRefreshed(refreshed);
|
||||
}
|
||||
} catch {
|
||||
error('Proactive token refresh failed.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectSystemInfo(): DeviceSystemInfo {
|
||||
const home = os.homedir();
|
||||
const platform = process.platform;
|
||||
|
||||
@@ -111,7 +111,7 @@ describe('cron command', () => {
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentCronJob.create.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'a1', name: 'My Job', schedule: '* * * * *' }),
|
||||
expect.objectContaining({ agentId: 'a1', cronPattern: '* * * * *', name: 'My Job' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,10 +125,10 @@ export function registerCronCommand(program: Command) {
|
||||
|
||||
const input: Record<string, any> = {
|
||||
agentId: options.agentId,
|
||||
schedule: options.schedule,
|
||||
cronPattern: options.schedule,
|
||||
};
|
||||
if (options.name) input.name = options.name;
|
||||
if (options.prompt) input.prompt = options.prompt;
|
||||
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);
|
||||
@@ -168,8 +168,8 @@ export function registerCronCommand(program: Command) {
|
||||
) => {
|
||||
const data: Record<string, any> = {};
|
||||
if (options.name) data.name = options.name;
|
||||
if (options.schedule) data.schedule = options.schedule;
|
||||
if (options.prompt) data.prompt = options.prompt;
|
||||
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;
|
||||
|
||||
@@ -9,6 +9,61 @@ import { registerTextCommand } from './text';
|
||||
import { registerTtsCommand } from './tts';
|
||||
import { registerVideoCommand } from './video';
|
||||
|
||||
/**
|
||||
* Parse a tRPC/server error and return a user-friendly message for gen status/download.
|
||||
*
|
||||
* getGenerationStatus throws NOT_FOUND in two distinct cases:
|
||||
* 1. "Async task not found" → asyncTaskId is wrong (user passed gen_xxx instead of UUID)
|
||||
* 2. "Generation not found" → generationId is wrong
|
||||
*
|
||||
* INTERNAL_SERVER_ERROR with a message mentioning "async_tasks" also indicates a bad asyncTaskId
|
||||
* (e.g. the server SQL query fails when a non-UUID is passed).
|
||||
*/
|
||||
function parseGenStatusError(
|
||||
err: any,
|
||||
generationId: string,
|
||||
asyncTaskId: string,
|
||||
command: 'status' | 'download',
|
||||
): string | null {
|
||||
const code = err?.data?.code || err?.shape?.data?.code;
|
||||
const message: string = err?.message || err?.shape?.message || '';
|
||||
|
||||
const isAsyncTaskNotFound =
|
||||
(code === 'NOT_FOUND' && message.includes('Async task not found')) ||
|
||||
(code === 'INTERNAL_SERVER_ERROR' && message.includes('async_tasks'));
|
||||
|
||||
const isGenerationNotFound = code === 'NOT_FOUND' && message.includes('Generation not found');
|
||||
|
||||
if (isAsyncTaskNotFound) {
|
||||
return (
|
||||
`${pc.red('✗')} Async task not found: ${pc.bold(asyncTaskId)}\n` +
|
||||
`\n` +
|
||||
` The second argument must be the ${pc.bold('asyncTaskId')} — the UUID printed after\n` +
|
||||
` "→ Task" in the video/image output, not the generation ID (gen_xxx).\n` +
|
||||
`\n` +
|
||||
` Example output from "lh gen video":\n` +
|
||||
` Generation ${pc.bold('gen_abc123')} → Task ${pc.dim('7ad0eb13-e9a5-4403-8070-1f7fe95b2f95')}\n` +
|
||||
`\n` +
|
||||
` Correct usage:\n` +
|
||||
` ${pc.cyan(`lh gen ${command} gen_abc123 7ad0eb13-e9a5-4403-8070-1f7fe95b2f95`)}`
|
||||
);
|
||||
}
|
||||
|
||||
if (isGenerationNotFound) {
|
||||
return (
|
||||
`${pc.red('✗')} Generation not found: ${pc.bold(generationId)}\n` +
|
||||
`\n` +
|
||||
` The first argument must be the ${pc.bold('generationId')} (gen_xxx) from the\n` +
|
||||
` video/image output.\n` +
|
||||
`\n` +
|
||||
` Correct usage:\n` +
|
||||
` ${pc.cyan(`lh gen ${command} <generationId> <asyncTaskId>`)}`
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function registerGenerateCommand(program: Command) {
|
||||
const generate = program
|
||||
.command('generate')
|
||||
@@ -23,15 +78,26 @@ export function registerGenerateCommand(program: Command) {
|
||||
|
||||
// ── status ──────────────────────────────────────────
|
||||
generate
|
||||
.command('status <generationId> <taskId>')
|
||||
.command('status <generationId> <asyncTaskId>')
|
||||
.description('Check generation task status')
|
||||
.option('--json', 'Output raw JSON')
|
||||
.action(async (generationId: string, taskId: string, options: { json?: boolean }) => {
|
||||
.action(async (generationId: string, asyncTaskId: string, options: { json?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.generation.getGenerationStatus.query({
|
||||
asyncTaskId: taskId,
|
||||
generationId,
|
||||
});
|
||||
|
||||
let result: any;
|
||||
try {
|
||||
result = await client.generation.getGenerationStatus.query({
|
||||
asyncTaskId,
|
||||
generationId,
|
||||
});
|
||||
} catch (err: any) {
|
||||
const msg = parseGenStatusError(err, generationId, asyncTaskId, 'status');
|
||||
if (msg) {
|
||||
console.error(msg);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
@@ -53,7 +119,7 @@ export function registerGenerateCommand(program: Command) {
|
||||
|
||||
// ── download ──────────────────────────────────────────
|
||||
generate
|
||||
.command('download <generationId> <taskId>')
|
||||
.command('download <generationId> <asyncTaskId>')
|
||||
.description('Wait for generation to complete and download the result')
|
||||
.option('-o, --output <path>', 'Output file path (default: auto-detect from asset)')
|
||||
.option('--interval <sec>', 'Polling interval in seconds', '5')
|
||||
@@ -61,7 +127,7 @@ export function registerGenerateCommand(program: Command) {
|
||||
.action(
|
||||
async (
|
||||
generationId: string,
|
||||
taskId: string,
|
||||
asyncTaskId: string,
|
||||
options: { interval?: string; output?: string; timeout?: string },
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
@@ -73,10 +139,20 @@ export function registerGenerateCommand(program: Command) {
|
||||
|
||||
// Poll for completion
|
||||
while (true) {
|
||||
const result = (await client.generation.getGenerationStatus.query({
|
||||
asyncTaskId: taskId,
|
||||
generationId,
|
||||
})) as any;
|
||||
let result: any;
|
||||
try {
|
||||
result = await client.generation.getGenerationStatus.query({
|
||||
asyncTaskId,
|
||||
generationId,
|
||||
});
|
||||
} catch (err: any) {
|
||||
const msg = parseGenStatusError(err, generationId, asyncTaskId, 'download');
|
||||
if (msg) {
|
||||
console.error(`\n${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (result.status === 'success' && result.generation) {
|
||||
const gen = result.generation;
|
||||
@@ -125,7 +201,7 @@ export function registerGenerateCommand(program: Command) {
|
||||
console.log(
|
||||
`${pc.red('✗')} Timed out after ${options.timeout}s. Task still ${result.status}.`,
|
||||
);
|
||||
console.log(pc.dim(`Run "lh gen status ${generationId} ${taskId}" to check later.`));
|
||||
console.log(pc.dim(`Run "lh gen status ${generationId} ${asyncTaskId}" to check later.`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
"cookie": "^1.1.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"diff": "^8.0.4",
|
||||
"electron": "41.1.0",
|
||||
"electron": "41.3.0",
|
||||
"electron-builder": "^26.8.1",
|
||||
"electron-devtools-installer": "4.0.0",
|
||||
"electron-is": "^3.0.0",
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "خدمات",
|
||||
"macOS.unhide": "إظهار الكل",
|
||||
"tray.open": "فتح {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "خروج",
|
||||
"tray.show": "عرض {{appName}}",
|
||||
"view.forceReload": "إعادة تحميل قسري",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "تصغير",
|
||||
"window.title": "نافذة",
|
||||
"window.toggleFullscreen": "تبديل وضع ملء الشاشة",
|
||||
"window.zoom": "تكبير",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "تكبير"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Услуги",
|
||||
"macOS.unhide": "Покажи всичко",
|
||||
"tray.open": "Отвори {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Изход",
|
||||
"tray.show": "Покажи {{appName}}",
|
||||
"view.forceReload": "Принудително презареждане",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Минимизирай",
|
||||
"window.title": "Прозорец",
|
||||
"window.toggleFullscreen": "Превключи на цял екран",
|
||||
"window.zoom": "Мащаб",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Мащаб"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Dienste",
|
||||
"macOS.unhide": "Alle anzeigen",
|
||||
"tray.open": "{{appName}} öffnen",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Beenden",
|
||||
"tray.show": "{{appName}} anzeigen",
|
||||
"view.forceReload": "Erzwinge Neuladen",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Minimieren",
|
||||
"window.title": "Fenster",
|
||||
"window.toggleFullscreen": "Vollbild umschalten",
|
||||
"window.zoom": "Zoom",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Zoom"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Servicios",
|
||||
"macOS.unhide": "Mostrar todo",
|
||||
"tray.open": "Abrir {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Salir",
|
||||
"tray.show": "Mostrar {{appName}}",
|
||||
"view.forceReload": "Recargar forzosamente",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Minimizar",
|
||||
"window.title": "Ventana",
|
||||
"window.toggleFullscreen": "Alternar pantalla completa",
|
||||
"window.zoom": "Zoom",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Zoom"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "خدمات",
|
||||
"macOS.unhide": "نمایش همه",
|
||||
"tray.open": "باز کردن {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "خروج",
|
||||
"tray.show": "نمایش {{appName}}",
|
||||
"view.forceReload": "بارگذاری اجباری",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "کوچک کردن",
|
||||
"window.title": "پنجره",
|
||||
"window.toggleFullscreen": "تغییر به حالت تمام صفحه",
|
||||
"window.zoom": "زوم",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "زوم"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Services",
|
||||
"macOS.unhide": "Tout afficher",
|
||||
"tray.open": "Ouvrir {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Quitter",
|
||||
"tray.show": "Afficher {{appName}}",
|
||||
"view.forceReload": "Recharger de force",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Réduire",
|
||||
"window.title": "Fenêtre",
|
||||
"window.toggleFullscreen": "Basculer en plein écran",
|
||||
"window.zoom": "Zoom",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Zoom"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Servizi",
|
||||
"macOS.unhide": "Mostra tutto",
|
||||
"tray.open": "Apri {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Esci",
|
||||
"tray.show": "Mostra {{appName}}",
|
||||
"view.forceReload": "Ricarica forzata",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Minimizza",
|
||||
"window.title": "Finestra",
|
||||
"window.toggleFullscreen": "Attiva/disattiva schermo intero",
|
||||
"window.zoom": "Zoom",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Zoom"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "サービス",
|
||||
"macOS.unhide": "すべて表示",
|
||||
"tray.open": "{{appName}} を開く",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "終了",
|
||||
"tray.show": "{{appName}} を表示",
|
||||
"view.forceReload": "強制再読み込み",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "最小化",
|
||||
"window.title": "ウィンドウ",
|
||||
"window.toggleFullscreen": "フルスクリーン切替",
|
||||
"window.zoom": "ズーム",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "ズーム"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "서비스",
|
||||
"macOS.unhide": "모두 표시",
|
||||
"tray.open": "{{appName}} 열기",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "종료",
|
||||
"tray.show": "{{appName}} 표시",
|
||||
"view.forceReload": "강제 새로 고침",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "최소화",
|
||||
"window.title": "창",
|
||||
"window.toggleFullscreen": "전체 화면 전환",
|
||||
"window.zoom": "줌",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "줌"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Diensten",
|
||||
"macOS.unhide": "Toon alles",
|
||||
"tray.open": "Open {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Afsluiten",
|
||||
"tray.show": "Toon {{appName}}",
|
||||
"view.forceReload": "Forceer herladen",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Minimaliseren",
|
||||
"window.title": "Venster",
|
||||
"window.toggleFullscreen": "Schakel volledig scherm in/uit",
|
||||
"window.zoom": "Inzoomen",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Inzoomen"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Usługi",
|
||||
"macOS.unhide": "Pokaż wszystko",
|
||||
"tray.open": "Otwórz {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Zakończ",
|
||||
"tray.show": "Pokaż {{appName}}",
|
||||
"view.forceReload": "Wymuś ponowne załadowanie",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Zminimalizuj",
|
||||
"window.title": "Okno",
|
||||
"window.toggleFullscreen": "Przełącz tryb pełnoekranowy",
|
||||
"window.zoom": "Powiększenie",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Powiększenie"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Serviços",
|
||||
"macOS.unhide": "Mostrar Todos",
|
||||
"tray.open": "Abrir {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Sair",
|
||||
"tray.show": "Mostrar {{appName}}",
|
||||
"view.forceReload": "Recarregar Forçadamente",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Minimizar",
|
||||
"window.title": "Janela",
|
||||
"window.toggleFullscreen": "Alternar Tela Cheia",
|
||||
"window.zoom": "Zoom",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Zoom"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Сервисы",
|
||||
"macOS.unhide": "Показать все",
|
||||
"tray.open": "Открыть {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Выйти",
|
||||
"tray.show": "Показать {{appName}}",
|
||||
"view.forceReload": "Принудительная перезагрузка",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Свернуть",
|
||||
"window.title": "Окно",
|
||||
"window.toggleFullscreen": "Переключить полноэкранный режим",
|
||||
"window.zoom": "Масштаб",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Масштаб"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Hizmetler",
|
||||
"macOS.unhide": "Hepsini Göster",
|
||||
"tray.open": "{{appName}}'i Aç",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Çık",
|
||||
"tray.show": "{{appName}}'i Göster",
|
||||
"view.forceReload": "Zorla Yenile",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Küçült",
|
||||
"window.title": "Pencere",
|
||||
"window.toggleFullscreen": "Tam Ekrana Geç",
|
||||
"window.zoom": "Yakınlaştır",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Yakınlaştır"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "Dịch vụ",
|
||||
"macOS.unhide": "Hiện tất cả",
|
||||
"tray.open": "Mở {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Thoát",
|
||||
"tray.show": "Hiện {{appName}}",
|
||||
"view.forceReload": "Tải lại cưỡng bức",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "Thu nhỏ",
|
||||
"window.title": "Cửa sổ",
|
||||
"window.toggleFullscreen": "Chuyển đổi toàn màn hình",
|
||||
"window.zoom": "Thu phóng",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "Thu phóng"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"macOS.services": "服務",
|
||||
"macOS.unhide": "全部顯示",
|
||||
"tray.open": "打開 {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "退出",
|
||||
"tray.show": "顯示 {{appName}}",
|
||||
"view.forceReload": "強制重新載入",
|
||||
@@ -86,6 +87,5 @@
|
||||
"window.minimize": "最小化",
|
||||
"window.title": "視窗",
|
||||
"window.toggleFullscreen": "切換全螢幕",
|
||||
"window.zoom": "縮放",
|
||||
"tray.openMiniToolbar": "Quick Composer"
|
||||
"window.zoom": "縮放"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { access, appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { Readable, Writable } from 'node:stream';
|
||||
|
||||
@@ -51,6 +51,21 @@ const CODEX_RESUME_CWD_MISMATCH_PATTERNS = [
|
||||
|
||||
/** Directory under appStoragePath for caching downloaded files */
|
||||
const FILE_CACHE_DIR = 'heteroAgent/files';
|
||||
const CLI_TRACE_DIR = '.heerogeneous-tracing';
|
||||
const IMAGE_EXTENSIONS_BY_MIME = {
|
||||
'image/gif': '.gif',
|
||||
'image/jpg': '.jpg',
|
||||
'image/jpeg': '.jpg',
|
||||
'image/pjpeg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/webp': '.webp',
|
||||
'image/x-png': '.png',
|
||||
} as const satisfies Record<string, string>;
|
||||
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const CODEX_STDERR_STATUS_LINE = 'Reading prompt from stdin...';
|
||||
const CODEX_WARN_LOG_PATTERN = /^\d{4}-\d{2}-\d{2}T\S+\s+WARN\s+/;
|
||||
const CODEX_LOG_PATTERN = /^\d{4}-\d{2}-\d{2}T\S+\s+(?:DEBUG|ERROR|INFO|TRACE|WARN)\s+/;
|
||||
const CLI_ERROR_LINE_PATTERN = /^(?:error:|Error:|Usage:)/;
|
||||
|
||||
// ─── IPC types ───
|
||||
|
||||
@@ -120,6 +135,11 @@ interface AgentSession {
|
||||
|
||||
type SessionErrorPayload = HeterogeneousAgentSessionError | string;
|
||||
|
||||
interface CliTraceSession {
|
||||
dir: string;
|
||||
writeQueue: Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* External Agent Controller — manages external agent CLI processes via Electron IPC.
|
||||
*
|
||||
@@ -306,6 +326,49 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
private getRelevantCodexStderr(stderr: string): string {
|
||||
const keptLines: string[] = [];
|
||||
let droppingWarnBlock = false;
|
||||
|
||||
for (const line of stderr.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed === CODEX_STDERR_STATUS_LINE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (CODEX_WARN_LOG_PATTERN.test(trimmed)) {
|
||||
droppingWarnBlock = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (CODEX_LOG_PATTERN.test(trimmed)) {
|
||||
droppingWarnBlock = false;
|
||||
keptLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (droppingWarnBlock && !CLI_ERROR_LINE_PATTERN.test(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
droppingWarnBlock = false;
|
||||
keptLines.push(line);
|
||||
}
|
||||
|
||||
return keptLines.join('\n').trim();
|
||||
}
|
||||
|
||||
private getExitErrorMessage(
|
||||
code: number | null,
|
||||
session: AgentSession,
|
||||
stderrOutput: string,
|
||||
): string {
|
||||
const relevantStderr =
|
||||
session.agentType === 'codex' ? this.getRelevantCodexStderr(stderrOutput) : stderrOutput;
|
||||
|
||||
return relevantStderr || `Agent exited with code ${code}`;
|
||||
}
|
||||
|
||||
private async getSpawnPreflightError(
|
||||
session: AgentSession,
|
||||
): Promise<HeterogeneousAgentSessionError | undefined> {
|
||||
@@ -332,6 +395,168 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
return cliMissingError;
|
||||
}
|
||||
|
||||
private get shouldTraceCliOutput(): boolean {
|
||||
return process.env.NODE_ENV !== 'test' && !electronApp.isPackaged;
|
||||
}
|
||||
|
||||
private formatTraceTimestamp(date: Date): string {
|
||||
const pad = (value: number) => value.toString().padStart(2, '0');
|
||||
|
||||
return [
|
||||
date.getFullYear(),
|
||||
pad(date.getMonth() + 1),
|
||||
pad(date.getDate()),
|
||||
'-',
|
||||
pad(date.getHours()),
|
||||
pad(date.getMinutes()),
|
||||
pad(date.getSeconds()),
|
||||
].join('');
|
||||
}
|
||||
|
||||
private sanitizeTracePathSegment(value: string): string {
|
||||
const sanitized = value
|
||||
.replaceAll(path.sep, '-')
|
||||
.replaceAll(/[^\w.-]+/g, '-')
|
||||
.replaceAll(/^-+|-+$/g, '')
|
||||
.slice(0, 80);
|
||||
|
||||
return sanitized || 'unknown';
|
||||
}
|
||||
|
||||
private getAttachmentTraceSummary(image: HeterogeneousAgentImageAttachment) {
|
||||
let urlKind = 'unknown';
|
||||
|
||||
try {
|
||||
urlKind = new URL(image.url).protocol.replace(/:$/, '') || urlKind;
|
||||
} catch {
|
||||
urlKind = image.url.startsWith('data:') ? 'data' : 'unknown';
|
||||
}
|
||||
|
||||
return {
|
||||
id: image.id,
|
||||
urlKind,
|
||||
};
|
||||
}
|
||||
|
||||
private async createCliTraceSession({
|
||||
cliArgs,
|
||||
cwd,
|
||||
imageList,
|
||||
session,
|
||||
stdinPayload,
|
||||
}: {
|
||||
cliArgs: string[];
|
||||
cwd: string;
|
||||
imageList: HeterogeneousAgentImageAttachment[];
|
||||
session: AgentSession;
|
||||
stdinPayload?: string;
|
||||
}): Promise<CliTraceSession | undefined> {
|
||||
if (!this.shouldTraceCliOutput) return;
|
||||
|
||||
// Don't materialize the cwd via mkdir — if the caller passed a stale or
|
||||
// typo'd path, we want spawn() to fail loudly instead of silently running
|
||||
// the agent in an empty auto-created directory.
|
||||
try {
|
||||
await access(cwd);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const createdAt = new Date();
|
||||
const rootDir = path.join(cwd, CLI_TRACE_DIR);
|
||||
const agentDir = path.join(rootDir, this.sanitizeTracePathSegment(session.agentType));
|
||||
const traceId = `${this.formatTraceTimestamp(createdAt)}-${this.sanitizeTracePathSegment(
|
||||
session.sessionId,
|
||||
)}`;
|
||||
const dir = path.join(agentDir, traceId);
|
||||
|
||||
try {
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(path.join(rootDir, '.last-live-trace'), `${dir}\n`);
|
||||
await writeFile(path.join(dir, 'stdout.jsonl'), '');
|
||||
await writeFile(path.join(dir, 'stderr.log'), '');
|
||||
if (stdinPayload !== undefined) {
|
||||
await writeFile(path.join(dir, 'stdin.txt'), '');
|
||||
}
|
||||
await writeFile(
|
||||
path.join(dir, 'meta.json'),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
agentSessionId: session.agentSessionId,
|
||||
agentType: session.agentType,
|
||||
args: cliArgs,
|
||||
attachments: imageList.map((image) => this.getAttachmentTraceSummary(image)),
|
||||
command: session.command,
|
||||
createdAt: createdAt.toISOString(),
|
||||
cwd,
|
||||
envKeys: session.env ? Object.keys(session.env).sort() : [],
|
||||
resumeSessionId: session.resumeSessionId,
|
||||
sessionId: session.sessionId,
|
||||
stdinBytes: stdinPayload === undefined ? 0 : Buffer.byteLength(stdinPayload),
|
||||
stdinFile: stdinPayload === undefined ? undefined : 'stdin.txt',
|
||||
stderrFile: 'stderr.log',
|
||||
stdoutFile: 'stdout.jsonl',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
return { dir, writeQueue: Promise.resolve() };
|
||||
} catch (error) {
|
||||
logger.warn('Failed to initialize CLI trace directory:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private queueCliTraceWrite(
|
||||
trace: CliTraceSession | undefined,
|
||||
write: () => Promise<void>,
|
||||
): Promise<void> | undefined {
|
||||
if (!trace) return;
|
||||
|
||||
trace.writeQueue = trace.writeQueue.then(write).catch((error) => {
|
||||
logger.warn('Failed to write CLI trace file:', error);
|
||||
});
|
||||
|
||||
return trace.writeQueue;
|
||||
}
|
||||
|
||||
private appendCliTraceFile(
|
||||
trace: CliTraceSession | undefined,
|
||||
fileName: string,
|
||||
data: Buffer | string,
|
||||
): Promise<void> | undefined {
|
||||
if (!trace) return;
|
||||
|
||||
const filePath = path.join(trace.dir, fileName);
|
||||
|
||||
return this.queueCliTraceWrite(trace, () => appendFile(filePath, data));
|
||||
}
|
||||
|
||||
private writeCliTraceFile(
|
||||
trace: CliTraceSession | undefined,
|
||||
fileName: string,
|
||||
data: string,
|
||||
): Promise<void> | undefined {
|
||||
if (!trace) return;
|
||||
|
||||
const filePath = path.join(trace.dir, fileName);
|
||||
|
||||
return this.queueCliTraceWrite(trace, () => writeFile(filePath, data));
|
||||
}
|
||||
|
||||
private writeCliTraceJson(
|
||||
trace: CliTraceSession | undefined,
|
||||
fileName: string,
|
||||
payload: unknown,
|
||||
): Promise<void> | undefined {
|
||||
return this.writeCliTraceFile(trace, fileName, `${JSON.stringify(payload, null, 2)}\n`);
|
||||
}
|
||||
|
||||
private async flushCliTrace(trace: CliTraceSession | undefined): Promise<void> {
|
||||
await trace?.writeQueue;
|
||||
}
|
||||
|
||||
// ─── Broadcast ───
|
||||
|
||||
private broadcast<T>(channel: string, data: T) {
|
||||
@@ -401,26 +626,42 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
return { buffer, mimeType };
|
||||
}
|
||||
|
||||
private normalizeMimeType(mimeType: string): string {
|
||||
return mimeType.split(';')[0]?.trim().toLowerCase() || '';
|
||||
}
|
||||
|
||||
private guessImageExtensionByBuffer(buffer: Buffer): string | undefined {
|
||||
if (buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) return '.png';
|
||||
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return '.jpg';
|
||||
|
||||
const gifSignature = buffer.subarray(0, 6).toString('ascii');
|
||||
if (gifSignature === 'GIF87a' || gifSignature === 'GIF89a') return '.gif';
|
||||
|
||||
if (
|
||||
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||
) {
|
||||
return '.webp';
|
||||
}
|
||||
}
|
||||
|
||||
private guessImageExtension(
|
||||
mimeType: string,
|
||||
image: HeterogeneousAgentImageAttachment,
|
||||
buffer: Buffer,
|
||||
): string | undefined {
|
||||
const knownByMime: Record<string, string> = {
|
||||
'image/gif': '.gif',
|
||||
'image/jpeg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/webp': '.webp',
|
||||
};
|
||||
|
||||
if (knownByMime[mimeType]) return knownByMime[mimeType];
|
||||
const knownByMime = IMAGE_EXTENSIONS_BY_MIME[this.normalizeMimeType(mimeType)];
|
||||
if (knownByMime) return knownByMime;
|
||||
|
||||
try {
|
||||
const pathname = new URL(image.url).pathname;
|
||||
const ext = path.extname(pathname);
|
||||
return ext || undefined;
|
||||
const ext = path.extname(pathname).toLowerCase();
|
||||
if (ext) return ext === '.jpeg' ? '.jpg' : ext;
|
||||
} catch {
|
||||
return undefined;
|
||||
// Fall through to byte sniffing below.
|
||||
}
|
||||
|
||||
return this.guessImageExtensionByBuffer(buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -430,7 +671,11 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
private async resolveCliImagePath(image: HeterogeneousAgentImageAttachment): Promise<string> {
|
||||
const { buffer, mimeType } = await this.resolveImage(image);
|
||||
const cacheKey = this.getImageCacheKey(image.id);
|
||||
const ext = this.guessImageExtension(mimeType, image) || '';
|
||||
const ext = this.guessImageExtension(mimeType, image, buffer);
|
||||
if (!ext) {
|
||||
throw new Error(`Unsupported image type for ${image.id}`);
|
||||
}
|
||||
|
||||
const filePath = path.join(this.fileCacheDir, `${cacheKey}${ext}`);
|
||||
|
||||
try {
|
||||
@@ -446,18 +691,31 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
private async resolveCliImagePaths(
|
||||
imageList: HeterogeneousAgentImageAttachment[] = [],
|
||||
): Promise<string[]> {
|
||||
const resolved = await Promise.all(
|
||||
imageList.map(async (image) => {
|
||||
try {
|
||||
return await this.resolveCliImagePath(image);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to materialize image ${image.id} for CLI:`, err);
|
||||
return undefined;
|
||||
}
|
||||
}),
|
||||
const results = await Promise.allSettled(
|
||||
imageList.map((image) => this.resolveCliImagePath(image)),
|
||||
);
|
||||
|
||||
return resolved.filter(Boolean) as string[];
|
||||
const imagePaths: string[] = [];
|
||||
const failures: string[] = [];
|
||||
|
||||
for (const [index, result] of results.entries()) {
|
||||
const imageId = imageList[index]?.id ?? `image-${index + 1}`;
|
||||
|
||||
if (result.status === 'fulfilled') {
|
||||
imagePaths.push(result.value);
|
||||
continue;
|
||||
}
|
||||
|
||||
const message = this.getErrorMessage(result.reason) || 'Unknown error';
|
||||
logger.error(`Failed to materialize image ${imageId} for CLI:`, result.reason);
|
||||
failures.push(`${imageId}: ${message}`);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
throw new Error(`Failed to attach image(s) to CLI: ${failures.join('; ')}`);
|
||||
}
|
||||
|
||||
return imagePaths;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -551,14 +809,20 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
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) => {
|
||||
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');
|
||||
|
||||
logger.info('Spawning agent:', session.command, cliArgs.join(' '), `(cwd: ${cwd})`);
|
||||
|
||||
// `detached: true` on Unix puts the child in a new process group so we
|
||||
@@ -580,6 +844,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
|
||||
// In stdin mode, write the prepared payload and close stdin.
|
||||
if (useStdin && spawnPlan.stdinPayload !== undefined && proc.stdin) {
|
||||
void this.writeCliTraceFile(traceSession, 'stdin.txt', spawnPlan.stdinPayload);
|
||||
const stdin = proc.stdin as Writable;
|
||||
stdin.write(spawnPlan.stdinPayload, () => {
|
||||
stdin.end();
|
||||
@@ -618,6 +883,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
// Stream stdout events as raw provider payloads to Renderer.
|
||||
const stdout = proc.stdout as Readable;
|
||||
stdout.on('data', (chunk: Buffer) => {
|
||||
void this.appendCliTraceFile(traceSession, 'stdout.jsonl', chunk);
|
||||
broadcastParsedOutputs(streamProcessor.push(chunk));
|
||||
});
|
||||
stdout.on('end', () => {
|
||||
@@ -628,11 +894,17 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
const stderrChunks: string[] = [];
|
||||
const stderr = proc.stderr as Readable;
|
||||
stderr.on('data', (chunk: Buffer) => {
|
||||
void this.appendCliTraceFile(traceSession, 'stderr.log', chunk);
|
||||
stderrChunks.push(chunk.toString('utf8'));
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
logger.error('Agent process error:', err);
|
||||
void this.writeCliTraceJson(traceSession, 'process-error.json', {
|
||||
message: err.message,
|
||||
name: err.name,
|
||||
});
|
||||
void this.flushCliTrace(traceSession);
|
||||
const sessionError = this.getSessionErrorPayload(err, session);
|
||||
this.broadcast('heteroAgentSessionError', {
|
||||
error: sessionError,
|
||||
@@ -642,7 +914,14 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
});
|
||||
|
||||
proc.on('exit', (code, signal) => {
|
||||
void stdoutBroadcastQueue.finally(() => {
|
||||
void stdoutBroadcastQueue.finally(async () => {
|
||||
void this.writeCliTraceJson(traceSession, 'exit.json', {
|
||||
code,
|
||||
finishedAt: new Date().toISOString(),
|
||||
signal,
|
||||
});
|
||||
await this.flushCliTrace(traceSession);
|
||||
|
||||
logger.info('Agent process exited:', { code, sessionId: session.sessionId, signal });
|
||||
session.process = undefined;
|
||||
|
||||
@@ -662,7 +941,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
resolve();
|
||||
} else {
|
||||
const stderrOutput = stderrChunks.join('').trim();
|
||||
const errorMsg = stderrOutput || `Agent exited with code ${code}`;
|
||||
const errorMsg = this.getExitErrorMessage(code, session, stderrOutput);
|
||||
const sessionError = this.getSessionErrorPayload(errorMsg, session);
|
||||
this.broadcast('heteroAgentSessionError', {
|
||||
error: sessionError,
|
||||
|
||||
@@ -155,6 +155,9 @@ export default class NotificationCtr extends ControllerModule {
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
mainWindow.show();
|
||||
mainWindow.browserWindow.focus();
|
||||
if (params.navigate?.path) {
|
||||
mainWindow.broadcast('navigate', params.navigate);
|
||||
}
|
||||
});
|
||||
|
||||
notification.on('close', () => {
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { UploadFileParams } from '@lobechat/electron-client-ipc';
|
||||
|
||||
import FileService from '@/services/fileSrv';
|
||||
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
export default class UploadFileCtr extends ControllerModule {
|
||||
static override readonly groupName = 'upload';
|
||||
private get fileService() {
|
||||
return this.app.getService(FileService);
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async uploadFile(params: UploadFileParams) {
|
||||
return this.fileService.uploadFile(params);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ vi.mock('electron', () => ({
|
||||
BrowserWindow: { getAllWindows: () => [] },
|
||||
app: {
|
||||
getPath: vi.fn((name: string) => (name === 'desktop' ? FAKE_DESKTOP_PATH : `/fake/${name}`)),
|
||||
isPackaged: false,
|
||||
on: vi.fn(),
|
||||
},
|
||||
ipcMain: { handle: vi.fn() },
|
||||
@@ -56,9 +57,11 @@ vi.mock('node:child_process', async (importOriginal) => {
|
||||
*/
|
||||
const createFakeProc = ({
|
||||
exitCode = 0,
|
||||
stderrLines = [],
|
||||
stdoutLines = [],
|
||||
}: {
|
||||
exitCode?: number;
|
||||
stderrLines?: string[];
|
||||
stdoutLines?: string[];
|
||||
} = {}) => {
|
||||
const proc = new EventEmitter() as any;
|
||||
@@ -86,6 +89,9 @@ const createFakeProc = ({
|
||||
for (const line of stdoutLines) {
|
||||
stdout.write(line);
|
||||
}
|
||||
for (const line of stderrLines) {
|
||||
stderr.write(line);
|
||||
}
|
||||
stdout.end();
|
||||
stderr.end();
|
||||
proc.emit('exit', exitCode);
|
||||
@@ -381,8 +387,9 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
expect(command).toBe('codex');
|
||||
expect(cliArgs).not.toContain(prompt);
|
||||
expect(cliArgs).toEqual(
|
||||
expect.arrayContaining(['exec', '--json', '--skip-git-repo-check', '--full-auto', '-']),
|
||||
expect.arrayContaining(['exec', '--json', '--skip-git-repo-check', '--full-auto']),
|
||||
);
|
||||
expect(cliArgs).not.toContain('-');
|
||||
expect(writes).toEqual([prompt]);
|
||||
});
|
||||
|
||||
@@ -398,8 +405,11 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
const imagePaths = getFlagValues(cliArgs, '--image');
|
||||
|
||||
expect(cliArgs).not.toContain('describe these screenshots');
|
||||
expect(cliArgs).not.toContain('-');
|
||||
expect(cliArgs.filter((arg) => arg === '--image')).toHaveLength(2);
|
||||
expect(imagePaths).toHaveLength(2);
|
||||
expect(imagePaths).not.toContain('-');
|
||||
expect(cliArgs.at(-1)).toBe(imagePaths[1]);
|
||||
expect(imagePaths[0]).toMatch(/\.png$/);
|
||||
expect(imagePaths[1]).toMatch(/\.jpg$/);
|
||||
expect(
|
||||
@@ -413,22 +423,94 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
expect(writes).toEqual(['describe these screenshots']);
|
||||
});
|
||||
|
||||
it('skips images that fail to materialize and still forwards the remaining --image args', async () => {
|
||||
it('normalizes parameterized image MIME types before choosing the CLI file extension', async () => {
|
||||
const imageList = [
|
||||
{ id: 'image-with-params', url: 'data:image/png;charset=utf-8;base64,UE5HX1RFU1Q=' },
|
||||
];
|
||||
const { cliArgs } = await runSendPrompt('describe this screenshot', {}, [], { imageList });
|
||||
|
||||
const imagePaths = getFlagValues(cliArgs, '--image');
|
||||
|
||||
expect(imagePaths).toHaveLength(1);
|
||||
expect(imagePaths[0]).toMatch(/\.png$/);
|
||||
await expect(readFile(imagePaths[0], 'utf8')).resolves.toBe('PNG_TEST');
|
||||
});
|
||||
|
||||
it('sniffs image bytes when MIME and URL do not expose a usable extension', async () => {
|
||||
const pngBytes = Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
Buffer.from('PNG_TEST'),
|
||||
]);
|
||||
const imageList = [
|
||||
{
|
||||
id: 'image-octet',
|
||||
url: `data:application/octet-stream;base64,${pngBytes.toString('base64')}`,
|
||||
},
|
||||
];
|
||||
const { cliArgs } = await runSendPrompt('describe this screenshot', {}, [], { imageList });
|
||||
|
||||
const imagePaths = getFlagValues(cliArgs, '--image');
|
||||
|
||||
expect(imagePaths).toHaveLength(1);
|
||||
expect(imagePaths[0]).toMatch(/\.png$/);
|
||||
await expect(readFile(imagePaths[0])).resolves.toEqual(pngBytes);
|
||||
});
|
||||
|
||||
it('fails before spawning Codex when any image cannot be materialized', async () => {
|
||||
const imageList = [
|
||||
{ id: 'good-image', url: 'data:image/png;base64,VkFMSURfSU1BR0U=' },
|
||||
{ id: 'bad-image', url: 'bad://broken-image' },
|
||||
];
|
||||
const { cliArgs, writes } = await runSendPrompt('inspect the valid screenshot only', {}, [], {
|
||||
imageList,
|
||||
const { proc } = createFakeProc();
|
||||
nextFakeProc = proc;
|
||||
const ctr = new HeterogeneousAgentCtr({
|
||||
appStoragePath,
|
||||
storeManager: { get: vi.fn() },
|
||||
} as any);
|
||||
const { sessionId } = await ctr.startSession({
|
||||
agentType: 'codex',
|
||||
command: 'codex',
|
||||
});
|
||||
|
||||
const imagePaths = getFlagValues(cliArgs, '--image');
|
||||
await expect(
|
||||
ctr.sendPrompt({
|
||||
imageList,
|
||||
prompt: 'inspect the screenshots',
|
||||
sessionId,
|
||||
}),
|
||||
).rejects.toThrow('Failed to attach image(s) to CLI');
|
||||
expect(spawnCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
expect(cliArgs.filter((arg) => arg === '--image')).toHaveLength(1);
|
||||
expect(imagePaths).toHaveLength(1);
|
||||
expect(imagePaths[0]).toMatch(/\.png$/);
|
||||
await expect(readFile(imagePaths[0], 'utf8')).resolves.toBe('VALID_IMAGE');
|
||||
expect(writes).toEqual(['inspect the valid screenshot only']);
|
||||
it('does not surface Codex stderr status and warn logs as the terminal error', async () => {
|
||||
const { proc } = createFakeProc({
|
||||
exitCode: 1,
|
||||
stderrLines: [
|
||||
'Reading prompt from stdin...\n',
|
||||
'2026-04-25T09:24:08.165782Z WARN codex_core::session_startup_prewarm: startup websocket prewarm setup failed\n',
|
||||
'<html>\n',
|
||||
' <body>challenge page</body>\n',
|
||||
'</html>\n',
|
||||
],
|
||||
stdoutLines: [
|
||||
`${JSON.stringify({ thread_id: 'thread_codex_123', type: 'thread.started' })}\n`,
|
||||
`${JSON.stringify({ type: 'turn.started' })}\n`,
|
||||
`${JSON.stringify({ message: 'real Codex JSONL error', type: 'error' })}\n`,
|
||||
],
|
||||
});
|
||||
nextFakeProc = proc;
|
||||
const ctr = new HeterogeneousAgentCtr({
|
||||
appStoragePath,
|
||||
storeManager: { get: vi.fn() },
|
||||
} as any);
|
||||
const { sessionId } = await ctr.startSession({
|
||||
agentType: 'codex',
|
||||
command: 'codex',
|
||||
});
|
||||
|
||||
await expect(ctr.sendPrompt({ prompt: 'hello', sessionId })).rejects.toThrow(
|
||||
'Agent exited with code 1',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses codex exec resume syntax when continuing an existing thread', async () => {
|
||||
@@ -437,9 +519,73 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
expect(cliArgs.slice(0, 2)).toEqual(['exec', 'resume']);
|
||||
expect(cliArgs).toContain('thread_abc');
|
||||
expect(cliArgs).not.toContain('--resume');
|
||||
expect(cliArgs.at(-2)).toBe('thread_abc');
|
||||
expect(cliArgs.at(-1)).toBe('-');
|
||||
});
|
||||
|
||||
it('writes raw CLI streams to a dev trace directory grouped by agent type', async () => {
|
||||
const originalNodeEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'development';
|
||||
|
||||
try {
|
||||
const prompt = 'trace this run';
|
||||
const rawLine = `${JSON.stringify({
|
||||
thread_id: 'thread_codex_trace',
|
||||
type: 'thread.started',
|
||||
})}\n`;
|
||||
const { sessionId } = await runSendPrompt(prompt, { cwd: appStoragePath }, [rawLine], {
|
||||
imageList: [{ id: 'image-1', url: 'data:image/png;base64,UE5HX1RFU1Q=' }],
|
||||
});
|
||||
const traceRoot = path.join(appStoragePath, '.heerogeneous-tracing');
|
||||
const agentTraceRoot = path.join(traceRoot, 'codex');
|
||||
const traceDirs = await readdir(agentTraceRoot);
|
||||
|
||||
expect(traceDirs).toHaveLength(1);
|
||||
|
||||
const traceDir = path.join(agentTraceRoot, traceDirs[0]);
|
||||
|
||||
await expect(readFile(path.join(traceRoot, '.last-live-trace'), 'utf8')).resolves.toBe(
|
||||
`${traceDir}\n`,
|
||||
);
|
||||
await expect(readFile(path.join(traceDir, 'stdin.txt'), 'utf8')).resolves.toBe(prompt);
|
||||
await expect(readFile(path.join(traceDir, 'stdout.jsonl'), 'utf8')).resolves.toBe(rawLine);
|
||||
await expect(readFile(path.join(traceDir, 'stderr.log'), 'utf8')).resolves.toBe('');
|
||||
await expect(readFile(path.join(traceDir, 'exit.json'), 'utf8')).resolves.toContain(
|
||||
'"code": 0',
|
||||
);
|
||||
|
||||
const meta = JSON.parse(await readFile(path.join(traceDir, 'meta.json'), 'utf8'));
|
||||
|
||||
expect(meta).toMatchObject({
|
||||
agentType: 'codex',
|
||||
command: 'codex',
|
||||
cwd: appStoragePath,
|
||||
sessionId,
|
||||
stdinBytes: Buffer.byteLength(prompt),
|
||||
stdoutFile: 'stdout.jsonl',
|
||||
});
|
||||
expect(meta.args).not.toContain('-');
|
||||
expect(meta.attachments).toEqual([{ id: 'image-1', urlKind: 'data' }]);
|
||||
} finally {
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it('skips trace creation (and never auto-creates the cwd) when the cwd is missing', async () => {
|
||||
const originalNodeEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'development';
|
||||
|
||||
const missingCwd = path.join(appStoragePath, 'does-not-exist');
|
||||
|
||||
try {
|
||||
await runSendPrompt('trace this run', { cwd: missingCwd });
|
||||
|
||||
await expect(access(missingCwd)).rejects.toThrow();
|
||||
} finally {
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it('captures the Codex thread id from json output for later resume', async () => {
|
||||
const { ctr, sessionId } = await runSendPrompt('hello', {}, [
|
||||
`${JSON.stringify({ thread_id: 'thread_codex_123', type: 'thread.started' })}\n`,
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
import { IpcHandler } from '@/utils/ipc/base';
|
||||
|
||||
import UploadFileCtr from '../UploadFileCtr';
|
||||
|
||||
const { ipcHandlers, ipcMainHandleMock } = vi.hoisted(() => {
|
||||
const handlers = new Map<string, (event: any, ...args: any[]) => any>();
|
||||
const handle = vi.fn((channel: string, handler: any) => {
|
||||
handlers.set(channel, handler);
|
||||
});
|
||||
return { ipcHandlers: handlers, ipcMainHandleMock: handle };
|
||||
});
|
||||
|
||||
const invokeIpc = async <T = any>(channel: string, payload?: any): Promise<T> => {
|
||||
const handler = ipcHandlers.get(channel);
|
||||
if (!handler) throw new Error(`IPC handler for ${channel} not found`);
|
||||
|
||||
const fakeEvent = { sender: { id: 'test' } as any };
|
||||
if (payload === undefined) return handler(fakeEvent);
|
||||
return handler(fakeEvent, payload);
|
||||
};
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: ipcMainHandleMock,
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock FileService module to prevent electron dependency issues
|
||||
vi.mock('@/services/fileSrv', () => ({
|
||||
default: class MockFileService {},
|
||||
}));
|
||||
|
||||
// Mock FileService instance methods
|
||||
const mockFileService = {
|
||||
uploadFile: vi.fn(),
|
||||
};
|
||||
|
||||
const mockApp = {
|
||||
getService: vi.fn(() => mockFileService),
|
||||
} as unknown as App;
|
||||
|
||||
describe('UploadFileCtr', () => {
|
||||
let _controller: UploadFileCtr;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ipcHandlers.clear();
|
||||
ipcMainHandleMock.mockClear();
|
||||
(IpcHandler.getInstance() as any).registeredChannels?.clear();
|
||||
_controller = new UploadFileCtr(mockApp);
|
||||
});
|
||||
|
||||
describe('uploadFile', () => {
|
||||
it('should upload file successfully', async () => {
|
||||
const params = {
|
||||
hash: 'abc123',
|
||||
path: '/test/file.txt',
|
||||
content: new ArrayBuffer(16),
|
||||
filename: 'file.txt',
|
||||
type: 'text/plain',
|
||||
};
|
||||
const expectedResult = { id: 'file-id-123', url: '/files/file-id-123' };
|
||||
mockFileService.uploadFile.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await invokeIpc('upload.uploadFile', params);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(mockFileService.uploadFile).toHaveBeenCalledWith(params);
|
||||
});
|
||||
|
||||
it('should handle upload error', async () => {
|
||||
const params = {
|
||||
hash: 'abc123',
|
||||
path: '/test/file.txt',
|
||||
content: new ArrayBuffer(16),
|
||||
filename: 'file.txt',
|
||||
type: 'text/plain',
|
||||
};
|
||||
const error = new Error('Upload failed');
|
||||
mockFileService.uploadFile.mockRejectedValue(error);
|
||||
|
||||
await expect(invokeIpc('upload.uploadFile', params)).rejects.toThrow('Upload failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,6 @@ import SystemController from './SystemCtr';
|
||||
import ToolDetectorCtr from './ToolDetectorCtr';
|
||||
import TrayMenuCtr from './TrayMenuCtr';
|
||||
import UpdaterCtr from './UpdaterCtr';
|
||||
import UploadFileCtr from './UploadFileCtr';
|
||||
|
||||
export const controllerIpcConstructors = [
|
||||
HeterogeneousAgentCtr,
|
||||
@@ -47,7 +46,6 @@ export const controllerIpcConstructors = [
|
||||
ToolDetectorCtr,
|
||||
TrayMenuCtr,
|
||||
UpdaterCtr,
|
||||
UploadFileCtr,
|
||||
] as const satisfies readonly IpcServiceConstructor[];
|
||||
|
||||
type DesktopControllerIpcConstructors = typeof controllerIpcConstructors;
|
||||
|
||||
@@ -21,7 +21,7 @@ const buildCodexOptionArgs = async ({
|
||||
const imageArgs = imagePaths.flatMap((filePath) => ['--image', filePath]);
|
||||
const autoExecutionArgs = hasAnyFlag(args, CODEX_AUTO_EXECUTION_FLAGS) ? [] : ['--full-auto'];
|
||||
|
||||
return [...CODEX_REQUIRED_ARGS, ...autoExecutionArgs, ...imageArgs, ...args];
|
||||
return [...CODEX_REQUIRED_ARGS, ...autoExecutionArgs, ...args, ...imageArgs];
|
||||
};
|
||||
|
||||
export const codexDriver: HeterogeneousAgentDriver = {
|
||||
@@ -37,7 +37,7 @@ export const codexDriver: HeterogeneousAgentDriver = {
|
||||
return {
|
||||
args: resumeSessionId
|
||||
? ['exec', 'resume', ...optionArgs, resumeSessionId, '-']
|
||||
: ['exec', ...optionArgs, '-'],
|
||||
: ['exec', ...optionArgs],
|
||||
stdinPayload: prompt,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -17,7 +17,7 @@ const isUrl = (value: string) => URL_PATTERN.test(value);
|
||||
const firstGlyph = (value?: string | null) => {
|
||||
if (!value) return '?';
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? Array.from(trimmed)[0] ?? '?' : '?';
|
||||
return trimmed ? (Array.from(trimmed)[0] ?? '?') : '?';
|
||||
};
|
||||
|
||||
const OverlayAvatar = memo<OverlayAvatarProps>(({ avatar, background, size = 18, title }) => {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
resolveCommittedSelectionRect,
|
||||
shouldHideChatPanel,
|
||||
} from './overlaySelectionState';
|
||||
import { resolveCommittedSelectionRect, shouldHideChatPanel } from './overlaySelectionState';
|
||||
|
||||
describe('overlaySelectionState', () => {
|
||||
it('keeps the pending selection rect visible until the committed selection arrives', () => {
|
||||
|
||||
@@ -15,8 +15,7 @@ export interface DockResult {
|
||||
top: number;
|
||||
}
|
||||
|
||||
const clamp = (v: number, lo: number, hi: number): number =>
|
||||
Math.max(lo, Math.min(hi, v));
|
||||
const clamp = (v: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, v));
|
||||
|
||||
export function computeDockPosition({
|
||||
rect,
|
||||
|
||||
@@ -3,9 +3,7 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getTopmostWindowAtPoint } from './useWindowHighlight';
|
||||
|
||||
const createWindow = (
|
||||
overrides: Partial<ScreenCaptureWindowInfo>,
|
||||
): ScreenCaptureWindowInfo => ({
|
||||
const createWindow = (overrides: Partial<ScreenCaptureWindowInfo>): ScreenCaptureWindowInfo => ({
|
||||
appName: 'Test App',
|
||||
bounds: { height: 300, width: 400, x: 1000, y: 200 },
|
||||
order: 0,
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
[
|
||||
{
|
||||
"children": {},
|
||||
"date": "2026-04-20",
|
||||
"version": "2.1.52"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["fix minify cli.", "recent delete."]
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"https://file.rene.wang/clipboard-1770266335710-1fec523143aab.png": "/blog/assets636c78daf95c590cd7d80284c68eb6d9.webp",
|
||||
"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/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",
|
||||
@@ -468,7 +469,5 @@
|
||||
"https://github.com/user-attachments/assets/facdc83c-e789-4649-8060-7f7a10a1b1dd": "/blog/assets05b20e40c03ced0ec8707fed2e8e0f25.webp",
|
||||
"https://github.com/user-attachments/assets/fcdfb9c5-819a-488f-b28d-0857fe861219": "/blog/assets8477415ecec1f37e38ab38ff1217d0a7.webp",
|
||||
"https://github.com/user-attachments/assets/fd60ab55-ead2-4930-ad00-fdf77662f5a0": "/blog/assets276a4e8748e9bd300b30dcd9d0e24980.webp",
|
||||
"https://file.rene.wang/clipboard-1775701725582-123f8f8cf73f8.png": "/blog/assets7ea204859aeb5aa9be5810a20ba1669a.webp",
|
||||
"https://file.rene.wang/changlog-04-14.png": "/blog/assets300abe7e259d293da6c5ed4f642a1be6.webp",
|
||||
"https://file.rene.wang/clipboard-1776909505252-94b051f3ea0a7.png": "/blog/assetsdfda32866c4bc59af0526e52f31d1da2.webp"
|
||||
}
|
||||
"https://file.rene.wang/clipboard-1777343750668-9b3dcb0dfff86.png": "/blog/assetsfa267a02f20bc5ba6f1273bcf27b7c9f.webp"
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
---
|
||||
title: 'Plugin System: Extend Your Agents with Community Skills'
|
||||
description: LobeHub now supports a plugin ecosystem that lets Agents access real-time information, interact with external services, and handle specialized tasks without leaving the conversation.
|
||||
description: >-
|
||||
LobeHub now supports a plugin ecosystem that lets Agents access real-time
|
||||
information, interact with external services, and handle specialized tasks
|
||||
without leaving the conversation.
|
||||
tags:
|
||||
- LobeHub
|
||||
- Plugins
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: '插件系统:用社区技能扩展你的助理'
|
||||
title: 插件系统:用社区技能扩展你的助理
|
||||
description: LobeHub 现已支持插件生态,让助理能够获取实时信息、与外部服务交互,并在对话中处理各种专业任务。
|
||||
tags:
|
||||
- LobeHub
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
---
|
||||
title: 'Visual Recognition: Chat With Images, Not Just Text'
|
||||
description: LobeHub now supports multimodal models including GPT-4 Vision, Google Gemini Pro Vision, and GLM-4 Vision. Upload or drag images into conversations and your Agent will understand and respond to visual content.
|
||||
description: >-
|
||||
LobeHub now supports multimodal models including GPT-4 Vision, Google Gemini
|
||||
Pro Vision, and GLM-4 Vision. Upload or drag images into conversations and
|
||||
your Agent will understand and respond to visual content.
|
||||
tags:
|
||||
- Visual Recognition
|
||||
- LobeHub
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
title: '视觉识别:与图片对话,不只是文字'
|
||||
description: LobeHub 现已支持多模态模型,包括 GPT-4 Vision、Google Gemini Pro Vision 和 GLM-4 Vision。上传或拖拽图片到对话中,助理将理解视觉内容并作出回应。
|
||||
title: 视觉识别:与图片对话,不只是文字
|
||||
description: >-
|
||||
LobeHub 现已支持多模态模型,包括 GPT-4 Vision、Google Gemini Pro Vision 和 GLM-4
|
||||
Vision。上传或拖拽图片到对话中,助理将理解视觉内容并作出回应。
|
||||
tags:
|
||||
- 视觉识别
|
||||
- 多模态交互
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
---
|
||||
title: 'Voice Conversations: Talk Naturally With Your Agents'
|
||||
description: LobeHub now supports Text-to-Speech (TTS) and Speech-to-Text (STT), enabling natural voice interactions. Speak with your Agents and hear responses in clear, personalized voices.
|
||||
description: >-
|
||||
LobeHub now supports Text-to-Speech (TTS) and Speech-to-Text (STT), enabling
|
||||
natural voice interactions. Speak with your Agents and hear responses in
|
||||
clear, personalized voices.
|
||||
tags:
|
||||
- TTS
|
||||
- STT
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: '语音会话:与你的助理自然对话'
|
||||
title: 语音会话:与你的助理自然对话
|
||||
description: LobeHub 现已支持文字转语音(TTS)和语音转文字(STT),实现自然的语音交互。与助理对话并听到清晰、个性化的语音回复。
|
||||
tags:
|
||||
- TTS
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
---
|
||||
title: 'Text-to-Image: Create Visuals Directly in Chat'
|
||||
description: LobeHub now supports text-to-image generation. Invoke DALL-E 3, MidJourney, or Pollinations directly during conversations to turn your ideas into images without leaving the chat.
|
||||
description: >-
|
||||
LobeHub now supports text-to-image generation. Invoke DALL-E 3, MidJourney, or
|
||||
Pollinations directly during conversations to turn your ideas into images
|
||||
without leaving the chat.
|
||||
tags:
|
||||
- Text-to-Image
|
||||
- LobeHub
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
title: '文生图:在对话中直接创作视觉内容'
|
||||
description: LobeHub 现已支持文本到图片生成。在对话中直接调用 DALL-E 3、MidJourney 或 Pollinations,无需离开聊天界面即可将想法转化为图像。
|
||||
title: 文生图:在对话中直接创作视觉内容
|
||||
description: >-
|
||||
LobeHub 现已支持文本到图片生成。在对话中直接调用 DALL-E 3、MidJourney 或
|
||||
Pollinations,无需离开聊天界面即可将想法转化为图像。
|
||||
tags:
|
||||
- Text to Image
|
||||
- 文生图
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: 灵活适配的认证体系:Clerk 与 Next-Auth 双方案支持
|
||||
description: >-
|
||||
LobeHub 现已支持 Clerk 和 Next-Auth 两种认证方案,让团队可以根据部署模式和安全需求选择最适合的身份验证方式。
|
||||
description: LobeHub 现已支持 Clerk 和 Next-Auth 两种认证方案,让团队可以根据部署模式和安全需求选择最适合的身份验证方式。
|
||||
tags:
|
||||
- 用户管理
|
||||
- 身份验证
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: 本地模型与云端 AI 并行使用
|
||||
description: >-
|
||||
LobeHub v0.127.0 新增 Ollama 支持,让你可以用与云端模型相同的界面运行本地大语言模型。
|
||||
description: LobeHub v0.127.0 新增 Ollama 支持,让你可以用与云端模型相同的界面运行本地大语言模型。
|
||||
tags:
|
||||
- Ollama AI
|
||||
- LobeHub
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
title: LobeHub 1.0:为持久化、多用户协作而生的新架构
|
||||
description: >-
|
||||
LobeHub 1.0 引入服务端数据库支持和完善的用户管理体系,实现知识库、跨设备同步和团队协作能力。
|
||||
LobeHub Cloud 同步开启 Beta 测试,内置全部新特性。
|
||||
LobeHub 1.0 引入服务端数据库支持和完善的用户管理体系,实现知识库、跨设备同步和团队协作能力。 LobeHub Cloud 同步开启 Beta
|
||||
测试,内置全部新特性。
|
||||
tags:
|
||||
- LobeHub
|
||||
- 服务端数据库
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
title: LobeHub v1.6:GPT-4o mini 成为默认模型选项
|
||||
description: >-
|
||||
LobeHub v1.6 新增 GPT-4o mini 支持,同时 LobeHub Cloud 将默认模型升级为
|
||||
GPT-4o mini,让开箱即用的对话体验更进一步。
|
||||
LobeHub v1.6 新增 GPT-4o mini 支持,同时 LobeHub Cloud 将默认模型升级为 GPT-4o
|
||||
mini,让开箱即用的对话体验更进一步。
|
||||
tags:
|
||||
- LobeHub
|
||||
- GPT-4o mini
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: 'LobeHub Enters the Era of Artifacts'
|
||||
title: LobeHub Enters the Era of Artifacts
|
||||
description: >-
|
||||
LobeHub v1.19 brings significant updates, including full feature support for
|
||||
Claude Artifacts, a brand new discovery page design, and support for GitHub
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: '重磅更新:LobeHub 迎来 Artifacts 时代'
|
||||
title: 重磅更新:LobeHub 迎来 Artifacts 时代
|
||||
description: >-
|
||||
LobeHub v1.19 带来了重大更新,包括 Claude Artifacts 完整特性支持、全新的发现页面设计,以及 GitHub Models
|
||||
服务商支持,让 AI 助手的能力得到显著提升。
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
title: Export Conversations as Markdown or OpenAI JSON
|
||||
description: >-
|
||||
LobeHub v1.28.0 adds Markdown and OpenAI-format JSON exports, making it
|
||||
easier to turn conversations into documentation, debugging payloads, or
|
||||
training datasets.
|
||||
LobeHub v1.28.0 adds Markdown and OpenAI-format JSON exports, making it easier
|
||||
to turn conversations into documentation, debugging payloads, or training
|
||||
datasets.
|
||||
tags:
|
||||
- Text Format Export
|
||||
- Markdown Export
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
---
|
||||
title: 支持导出对话为 Markdown 或 OpenAI JSON 格式
|
||||
description: >-
|
||||
LobeHub v1.28.0 新增 Markdown 与 OpenAI 格式 JSON 导出,方便将对话转为文档、
|
||||
调试数据或训练语料。
|
||||
description: LobeHub v1.28.0 新增 Markdown 与 OpenAI 格式 JSON 导出,方便将对话转为文档、 调试数据或训练语料。
|
||||
tags:
|
||||
- 文本格式导出
|
||||
- Markdown 导出
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
---
|
||||
title: 11 月更新 - 新增 4 家模型服务商
|
||||
description: >-
|
||||
LobeHub 新增支持 Gitee AI、InternLM、xAI 和 Cloudflare Workers AI,
|
||||
为团队提供更多模型接入选择。
|
||||
description: LobeHub 新增支持 Gitee AI、InternLM、xAI 和 Cloudflare Workers AI, 为团队提供更多模型接入选择。
|
||||
tags:
|
||||
- LobeHub
|
||||
- AI 模型服务
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
title: DeepSeek R1 Integration with Chain-of-Thought Transparency
|
||||
description: LobeHub now supports DeepSeek R1 with real-time reasoning display, making complex problem-solving more transparent and easier to follow.
|
||||
description: >-
|
||||
LobeHub now supports DeepSeek R1 with real-time reasoning display, making
|
||||
complex problem-solving more transparent and easier to follow.
|
||||
tags:
|
||||
- LobeHub
|
||||
- DeepSeek
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
title: "50+ New Models and 10+ Providers Added to the Ecosystem"
|
||||
description: LobeHub expands its AI ecosystem with 50+ new models and 10+ providers, making it easier to access diverse AI capabilities without changing your workflow.
|
||||
title: 50+ New Models and 10+ Providers Added to the Ecosystem
|
||||
description: >-
|
||||
LobeHub expands its AI ecosystem with 50+ new models and 10+ providers, making
|
||||
it easier to access diverse AI capabilities without changing your workflow.
|
||||
tags:
|
||||
- LobeHub
|
||||
- Model Providers
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: "AI 生态扩展:新增 50+ 模型与 10+ 服务商"
|
||||
title: AI 生态扩展:新增 50+ 模型与 10+ 服务商
|
||||
description: LobeHub 完成史上最大规模 AI 生态扩展,新增 50+ 模型和 10+ 服务商,让你无需改变工作流程即可接入更多 AI 能力。
|
||||
tags:
|
||||
- LobeHub
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
title: "Customizable Hotkeys, Data Export, and Provider Expansion"
|
||||
description: LobeHub adds customizable hotkeys, data export functionality, and expands provider support to make daily workflows smoother and more portable.
|
||||
title: 'Customizable Hotkeys, Data Export, and Provider Expansion'
|
||||
description: >-
|
||||
LobeHub adds customizable hotkeys, data export functionality, and expands
|
||||
provider support to make daily workflows smoother and more portable.
|
||||
tags:
|
||||
- LobeHub
|
||||
- Hotkeys
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: "快捷键自定义、数据导出与服务商扩展"
|
||||
title: 快捷键自定义、数据导出与服务商扩展
|
||||
description: LobeHub 新增快捷键自定义、数据导出功能,并扩展服务商支持,让日常使用更顺手、数据更可迁移。
|
||||
tags:
|
||||
- LobeHub
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
title: "Lobe UI v2 Design System and Desktop App Launch"
|
||||
description: LobeHub launches a refreshed visual design with Lobe UI v2 and officially releases the desktop app for Windows and macOS.
|
||||
title: Lobe UI v2 Design System and Desktop App Launch
|
||||
description: >-
|
||||
LobeHub launches a refreshed visual design with Lobe UI v2 and officially
|
||||
releases the desktop app for Windows and macOS.
|
||||
tags:
|
||||
- Desktop App
|
||||
- LobeHub
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: "Lobe UI v2 设计系统与桌面端正式发布"
|
||||
title: Lobe UI v2 设计系统与桌面端正式发布
|
||||
description: LobeHub 推出基于 Lobe UI v2 的全新视觉设计,并正式发布 Windows 与 macOS 桌面端应用。
|
||||
tags:
|
||||
- 桌面端
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
title: "Prompt Variables and Claude 4 Reasoning Model Support"
|
||||
description: LobeHub introduces prompt variables for reusable templates and adds full support for Claude 4 reasoning models with web search integration.
|
||||
title: Prompt Variables and Claude 4 Reasoning Model Support
|
||||
description: >-
|
||||
LobeHub introduces prompt variables for reusable templates and adds full
|
||||
support for Claude 4 reasoning models with web search integration.
|
||||
tags:
|
||||
- Prompt Variables
|
||||
- Claude 4
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: "提示词变量与 Claude 4 推理模型支持"
|
||||
title: 提示词变量与 Claude 4 推理模型支持
|
||||
description: LobeHub 引入提示词变量实现模板复用,并完整支持 Claude 4 推理模型及网页搜索集成。
|
||||
tags:
|
||||
- 提示词变量
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
---
|
||||
title: "MCP Marketplace and Search Provider Expansion \U0001F50D"
|
||||
description: >-
|
||||
MCP Marketplace is now live with one-click plugin installation, alongside expanded search providers and new SSO options for easier team access.
|
||||
MCP Marketplace is now live with one-click plugin installation, alongside
|
||||
expanded search providers and new SSO options for easier team access.
|
||||
tags:
|
||||
- MCP Marketplace
|
||||
- Best MCP
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
---
|
||||
title: "Image Generation, Desktop, and Auth Updates \U0001F3A8"
|
||||
description: >-
|
||||
Generate AI images across multiple providers, connect with expanded identity options, and run desktop workflows with fewer interruptions.
|
||||
Generate AI images across multiple providers, connect with expanded identity
|
||||
options, and run desktop workflows with fewer interruptions.
|
||||
tags:
|
||||
- Image Generation
|
||||
- Desktop App
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: 图像生成、桌面端与认证更新 🎨
|
||||
title: "图像生成、桌面端与认证更新 \U0001F3A8"
|
||||
description: 通过多个服务商生成 AI 图像,用更多身份系统完成接入,并在桌面端享受更顺畅的工作流。
|
||||
tags:
|
||||
- 图像生成
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
---
|
||||
title: "Gemini Image Generation and Non-Streaming Mode Support \U0001F3A8"
|
||||
description: >-
|
||||
Gemini 2.5 Flash Image generation, non-streaming response mode, and expanded model coverage give you more flexibility in how you generate and receive content.
|
||||
Gemini 2.5 Flash Image generation, non-streaming response mode, and expanded
|
||||
model coverage give you more flexibility in how you generate and receive
|
||||
content.
|
||||
tags:
|
||||
- Gemini
|
||||
- Nano Banana
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
---
|
||||
title: "Claude Sonnet 4.5 and Built-in Python Plugin \U0001F40D"
|
||||
description: >-
|
||||
Run Python directly in chat with the new built-in plugin, navigate long conversations faster, and work with Claude Sonnet 4.5 and other new models.
|
||||
Run Python directly in chat with the new built-in plugin, navigate long
|
||||
conversations faster, and work with Claude Sonnet 4.5 and other new models.
|
||||
tags:
|
||||
- Claude Sonnet 4.5
|
||||
- Chain of Thought
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
---
|
||||
title: ComfyUI Integration and Knowledge Base Improvements ⭐
|
||||
description: >-
|
||||
Run ComfyUI visual workflows directly in LobeHub, organize knowledge with waterfall layouts and auto-extraction, and share outputs as PDF.
|
||||
Run ComfyUI visual workflows directly in LobeHub, organize knowledge with
|
||||
waterfall layouts and auto-extraction, and share outputs as PDF.
|
||||
tags:
|
||||
- AI Knowledge Base
|
||||
- Workflow
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
---
|
||||
title: "MCP Cloud Endpoints and Model Library Expansion \U0001F50C"
|
||||
description: >-
|
||||
Connect to managed MCP tools from the marketplace without self-hosting, while new providers and knowledge base pages improve daily workflows.
|
||||
Connect to managed MCP tools from the marketplace without self-hosting, while
|
||||
new providers and knowledge base pages improve daily workflows.
|
||||
tags:
|
||||
- MCP
|
||||
- LobeHub
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
title: Coding Agent — Claude Code & Codex on Desktop
|
||||
description: >-
|
||||
Claude Code and Codex graduate to first-class desktop runtimes, alongside a
|
||||
new Agent Signal runtime and a wave of flagship models.
|
||||
tags:
|
||||
- Heterogeneous Agent
|
||||
- Desktop
|
||||
- Models
|
||||
---
|
||||
|
||||
# Claude Code & Codex on Desktop
|
||||
|
||||
## Features
|
||||
|
||||
- Topic remembers its own scroll position
|
||||
- User message stays pinned to the viewport top with long messages folded, the last user message can be edited and resent inline, and follow-up sends queue cleanly during a concurrent turn.
|
||||
- Delegating 3rd party coding agents such as Claude Code and Codex
|
||||
- Quick chat and capture your screen and ask LobeHun with desktop app
|
||||
- New models: GPT-5.5, DeepSeek V4 Flash and Pro with a reasoning-effort slider, LobeHub-hosted gpt-image-2, Kimi K2.6, MiMo-V2.5 and Pro
|
||||
- New providers: OpenCode Zen and OpenCode Go.
|
||||
|
||||
## Improvements and fixes
|
||||
|
||||
- Disabled markdown streaming on the first assistant block to avoid mid-stream layout shifts.
|
||||
- Conversation no longer repins to the bottom after a manual scroll.
|
||||
- Tool inspectors render correctly for Codex and heterogeneous-agent follow-ups.
|
||||
- FileEditor migrated from antd Modal to base-ui Modal for consistent focus and keyboard behavior.
|
||||
- QStash heartbeat self-reschedules to keep long-running tasks alive.
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
title: 编程 Agent —— Claude Code 与 Codex 进入桌面端
|
||||
description: Claude Code 与 Codex 成为桌面端的一等运行时,全新 Agent Signal 运行时上线,并迎来一批旗舰模型。
|
||||
tags:
|
||||
- 异构 Agent
|
||||
- 桌面端
|
||||
- 模型
|
||||
---
|
||||
|
||||
# Claude Code 与 Codex 进入桌面端
|
||||
|
||||
## 新功能
|
||||
|
||||
- 话题级别记忆滚动位置
|
||||
- 用户消息固定在视口顶部,过长内容自动折叠;最后一条用户消息可直接编辑并重发;并发对话期间的后续发送会顺序排队
|
||||
- 接入 Claude Code、Codex 等第三方编程 Agent
|
||||
- 在桌面端通过 Quick Chat 与屏幕截图直接向 LobeHub 提问
|
||||
- 新模型:GPT-5.5、DeepSeek V4 Flash / Pro(带思考强度滑块)、LobeHub 托管的 gpt-image-2、Kimi K2.6、MiMo-V2.5 与 Pro
|
||||
- 新提供商:OpenCode Zen 与 OpenCode Go
|
||||
|
||||
## 体验优化与修复
|
||||
|
||||
- 第一条助手消息不再启用 Markdown 流式渲染,避免渲染过程中的布局抖动。
|
||||
- 手动滚动后不再重新自动钉住对话底部。
|
||||
- 修复了 Codex 与异构 Agent 后续轮次中工具检查器渲染异常的问题。
|
||||
- FileEditor 从 antd Modal 迁移到 base-ui Modal,焦点与键盘行为更一致。
|
||||
- QStash 心跳支持自我重调度,长任务运行更稳定。
|
||||
@@ -2,6 +2,14 @@
|
||||
"$schema": "https://github.com/lobehub/lobe-chat/blob/main/docs/changelog/schema.json",
|
||||
"cloud": [],
|
||||
"community": [
|
||||
{
|
||||
"image": "/blog/assetsfa267a02f20bc5ba6f1273bcf27b7c9f.webp",
|
||||
"id": "2026-04-27-heterogeneous-agent",
|
||||
"date": "2026-04-27",
|
||||
"versionRange": [
|
||||
"2.1.53"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assetsdfda32866c4bc59af0526e52f31d1da2.webp",
|
||||
"id": "2026-04-20-daily-brief",
|
||||
|
||||
@@ -91,14 +91,13 @@ Configuration details:
|
||||
|
||||
Available system agents and their functions:
|
||||
|
||||
| System Agent | Key Name | Function Description |
|
||||
| ------------------- | ----------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| Topic Generation | `topic` | Automatically generates topic names and summaries based on chat content |
|
||||
| Translation | `translation` | Handles text translation between multiple languages |
|
||||
| Metadata Generation | `agentMeta` | Generates descriptive information and metadata for assistants |
|
||||
| History Compression | `historyCompress` | Compresses and organizes history for long conversations, optimizing context management |
|
||||
| Query Rewrite | `queryRewrite` | Rewrites follow-up questions as standalone questions with context, improving conversation coherence |
|
||||
| Thread Management | `thread` | Handles the creation and management of conversation threads |
|
||||
| System Agent | Key Name | Function Description |
|
||||
| ------------------- | ----------------- | -------------------------------------------------------------------------------------- |
|
||||
| Topic Generation | `topic` | Automatically generates topic names and summaries based on chat content |
|
||||
| Translation | `translation` | Handles text translation between multiple languages |
|
||||
| Metadata Generation | `agentMeta` | Generates descriptive information and metadata for assistants |
|
||||
| History Compression | `historyCompress` | Compresses and organizes history for long conversations, optimizing context management |
|
||||
| Thread Management | `thread` | Handles the creation and management of conversation threads |
|
||||
|
||||
### `FEATURE_FLAGS`
|
||||
|
||||
|
||||
@@ -88,14 +88,13 @@ LobeHub 在部署时提供了一些额外的配置项,你可以使用环境变
|
||||
|
||||
可配置的系统助手及其作用:
|
||||
|
||||
| 系统助手 | 键名 | 作用描述 |
|
||||
| ------- | ----------------- | --------------------------- |
|
||||
| 主题生成 | `topic` | 根据聊天内容自动生成主题名称和摘要 |
|
||||
| 翻译 | `translation` | 文本翻译使用的助手 |
|
||||
| 元数据生成 | `agentMeta` | 为助手生成描述性信息和元数据 |
|
||||
| 历史记录压缩 | `historyCompress` | 压缩和整理长对话的历史记录,优化上下文管理 |
|
||||
| 知识库查询重写 | `queryRewrite` | 将后续问题改写为包含上下文的独立问题,提升对话的连贯性 |
|
||||
| 分支对话 | `thread` | 自定生成分支对话的标题 |
|
||||
| 系统助手 | 键名 | 作用描述 |
|
||||
| ------ | ----------------- | --------------------- |
|
||||
| 主题生成 | `topic` | 根据聊天内容自动生成主题名称和摘要 |
|
||||
| 翻译 | `translation` | 文本翻译使用的助手 |
|
||||
| 元数据生成 | `agentMeta` | 为助手生成描述性信息和元数据 |
|
||||
| 历史记录压缩 | `historyCompress` | 压缩和整理长对话的历史记录,优化上下文管理 |
|
||||
| 分支对话 | `thread` | 自定生成分支对话的标题 |
|
||||
|
||||
### `FEATURE_FLAGS`
|
||||
|
||||
|
||||
@@ -104,15 +104,17 @@ By connecting a Discord channel to your LobeHub agent, users can interact with t
|
||||
Under **Bot Permissions**, select:
|
||||
|
||||
- View Channels
|
||||
- Send Messages
|
||||
- Read Message History
|
||||
- Send Messages
|
||||
- Create Public Threads
|
||||
- Send Messages in Threads
|
||||
- Embed Links
|
||||
- Attach Files
|
||||
- Add Reactions (optional)
|
||||
|
||||
### Authorize the Bot
|
||||
|
||||

|
||||

|
||||
|
||||
Copy the generated URL, open it in your browser, select the server you want to add the bot to, and click **Authorize**.
|
||||
</Steps>
|
||||
@@ -121,15 +123,73 @@ By connecting a Discord channel to your LobeHub agent, users can interact with t
|
||||
|
||||
Back in LobeHub's channel settings for Discord, click **Test Connection** to verify everything is configured correctly. Then send a message to your bot in Discord to confirm it responds.
|
||||
|
||||
## Step 5: Set Your Platform Identity (Recommended)
|
||||
|
||||
Two optional fields under **Advanced Settings** carry a lot of weight in day-to-day use — fill them in once and most surprises go away.
|
||||
|
||||
### Your Platform User ID
|
||||
|
||||
This is your own Discord user ID, used by:
|
||||
|
||||
- **Pairing approval** — required when **DM Policy** is set to **Pairing**, since `/approve <code>` is the owner's command and the runtime checks the sender against this ID.
|
||||
- **AI tools push** — lets the agent reach you proactively (reminders, notifications) by mapping its internal user reference to your Discord account.
|
||||
- **Anti-lockout** — auto-trusted by **Allowed Users**, so scoping the bot to friends won't accidentally lock you out.
|
||||
|
||||
To get it: in Discord, open **User Settings → Advanced** and turn on **Developer Mode**. Then right-click your own username anywhere in Discord and choose **Copy User ID**. Paste the numeric ID into **Your Platform User ID** in LobeHub's Advanced Settings.
|
||||
|
||||
### Default Server
|
||||
|
||||
The Discord guild ID the bot's AI tools should default to when you ask it to "list channels", "send to #announcements", or anything else that needs a server context without naming one explicitly. Doesn't affect access control — that's **Group Policy**'s job.
|
||||
|
||||
To get it: with **Developer Mode** on, right-click the server name in your server list and choose **Copy Server ID**. Paste it into **Default Server** in LobeHub's Advanced Settings.
|
||||
|
||||
## Access Policies
|
||||
|
||||
LobeHub gates inbound traffic with three layered settings, all under **Advanced Settings** and all defaulting to permissive.
|
||||
|
||||
### Allowed User IDs (global)
|
||||
|
||||
A *global* user-level gate. When populated, **only** the listed users can interact with the bot — DM, group @mention, threads, all of it — regardless of DM Policy / Group Policy mode. Empty means "no user-level filter; let per-scope policies decide". Right-click a user → **Copy User ID** (Developer Mode must be enabled in **Settings → Advanced**) to grab an ID.
|
||||
|
||||
DMs from non-allowlisted users get a "you aren't authorized" notice. Group @mentions from non-allowlisted users get the same kind of notice posted **inside the auto-created reply thread** Discord makes for the @-mention — the parent channel stays quiet.
|
||||
|
||||
### DM Policy
|
||||
|
||||
Controls 1:1 direct messages.
|
||||
|
||||
- **Open (default)** — Anyone who shares a server with the bot can DM it (subject to the global allowlist when set).
|
||||
- **Allowlist** — DMs require the sender to be in **Allowed User IDs**. Differs from `Open` only when the list is empty: `Allowlist` fails closed (no DMs), `Open` still lets anyone DM.
|
||||
- **Pairing** — Same gate as `Allowlist`, but a non-listed sender receives a one-time pairing code instead of a flat rejection. Approve via `/approve <code>` and the applicant is auto-appended to **Allowed User IDs**. Requires **Your Platform User ID** to be set (the runtime checks the `/approve` sender against it) and a configured Redis backend.
|
||||
- **Disabled** — The bot ignores all DMs. Senders get a one-line notice pointing them at @mentioning the bot in a shared channel instead.
|
||||
|
||||
> Discord bots can be reached by anyone in any shared server, so consider populating **Allowed User IDs**, switching DM Policy to **Pairing** for self-service approval, or **Disabled** if your bot is meant to be private.
|
||||
|
||||
### Group Policy
|
||||
|
||||
Controls @mentions in server channels and threads.
|
||||
|
||||
- **Open (default)** — Respond to @mentions anywhere the bot can read.
|
||||
- **Allowlist** — Respond only in channels listed in **Allowed Channel IDs**. Right-click a channel → **Copy Channel ID** to grab one.
|
||||
- **Disabled** — Ignore all server traffic; the bot only responds to DMs.
|
||||
|
||||
> The two policies are independent. You can run a DM-only bot with `groupPolicy=disabled`, a channel-only bot with `dmPolicy=disabled`, or scope both with allowlists.
|
||||
|
||||
See the [Channels overview](/docs/usage/channels/overview#direct-message-policy) for cross-platform details.
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
| Field | Required | Description |
|
||||
| ------------------ | -------- | ------------------------------------------------ |
|
||||
| **Application ID** | Yes | Your Discord application's ID |
|
||||
| **Bot Token** | Yes | Authentication token for your Discord bot |
|
||||
| **Public Key** | Yes | Used to verify interaction requests from Discord |
|
||||
| Field | Required | Description |
|
||||
| ----------------------- | -------- | ------------------------------------------------------------------------------------------------- |
|
||||
| **Application ID** | Yes | Your Discord application's ID |
|
||||
| **Bot Token** | Yes | Authentication token for your Discord bot |
|
||||
| **Public Key** | Yes | Used to verify interaction requests from Discord |
|
||||
| **Allowed User IDs** | No | Comma- or whitespace-separated Discord user IDs. Global gate — applies to DMs and group @mentions |
|
||||
| **DM Policy** | No | `open` (default), `allowlist`, `pairing`, or `disabled` — who is allowed to DM the bot |
|
||||
| **Group Policy** | No | `open` (default), `allowlist`, or `disabled` — where the bot responds to @mentions |
|
||||
| **Allowed Channel IDs** | No | Comma- or whitespace-separated Discord channel IDs. Used when Group Policy is Allowlist |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Bot not responding in server:** Confirm the bot has been invited to the server with the correct permissions, and Message Content Intent is enabled.
|
||||
- **Bot not responding in server:** Confirm the bot has been invited to the server with the correct permissions, and Message Content Intent is enabled. If **Group Policy** is `Disabled` or `Allowlist`, double-check the channel is in **Allowed Channel IDs**. If **Allowed User IDs** is set, the sender's user ID must be in it.
|
||||
- **Bot not responding to DMs:** Open **Advanced Settings** and confirm **DM Policy** is not set to `Disabled`. If **Allowed User IDs** is set, make sure the sender's Discord user ID is in it.
|
||||
- **Test Connection failed:** Double-check the Application ID, Bot Token, and Public Key are correct.
|
||||
|
||||
@@ -103,15 +103,17 @@ tags:
|
||||
在 **机器人权限** 下选择:
|
||||
|
||||
- 查看频道
|
||||
- 阅读消息历史记录
|
||||
- 发送消息
|
||||
- 读取消息历史
|
||||
- 创建公共子区
|
||||
- 在子区内发送消息
|
||||
- 嵌入链接
|
||||
- 附加文件
|
||||
- 添加文件
|
||||
- 添加反应(可选)
|
||||
|
||||
### 授权机器人
|
||||
|
||||

|
||||

|
||||
|
||||
复制生成的链接,在浏览器中打开,选择您希望添加机器人的服务器,然后点击 **授权**。
|
||||
</Steps>
|
||||
@@ -120,15 +122,73 @@ tags:
|
||||
|
||||
返回 LobeHub 的 Discord 渠道设置,点击 **测试连接** 以验证配置是否正确。然后在 Discord 中向您的机器人发送消息,确认其是否响应。
|
||||
|
||||
## 第五步:填写你的平台身份(推荐)
|
||||
|
||||
**高级设置**里有两个可选字段,影响着日常使用体验,建议一开始就填好。
|
||||
|
||||
### 你的平台用户 ID
|
||||
|
||||
也就是你自己的 Discord 用户 ID,用于:
|
||||
|
||||
- **配对审批** — 当 **私信策略** 为 **配对审批** 时为必填项,`/approve <code>` 是属主命令,runtime 会用这个 ID 校验发起人。
|
||||
- **AI 工具主动推送** — 让 Agent 能主动联系你(提醒、通知),把内部用户引用映射到你的 Discord 账号。
|
||||
- **防自锁** — 自动被 **允许的用户** 信任,给好友收紧 bot 时不会把自己挡在外面。
|
||||
|
||||
获取方式:在 Discord 中打开 **用户设置 → 高级**,启用 **开发者模式**。然后在任意位置右键你自己的用户名,选 **复制用户 ID**。把数字 ID 粘贴到 LobeHub 高级设置的 **你的平台用户 ID** 字段。
|
||||
|
||||
### 默认服务器
|
||||
|
||||
Discord 的 guild ID。当你让 bot 做 "列出频道"、"发送到 #announcements" 这类需要服务器上下文但没指明哪台的事时,AI 工具会默认用这个 server。和访问控制无关 —— 那是 **群组策略** 的活。
|
||||
|
||||
获取方式:在 **开发者模式** 已开启的情况下,在服务器列表中右键服务器名,选 **复制服务器 ID**。粘贴到 LobeHub 高级设置的 **默认服务器** 字段。
|
||||
|
||||
## 接入策略
|
||||
|
||||
LobeHub 通过三层叠加配置控制入站消息,全部位于 **高级设置**,默认都为宽松。
|
||||
|
||||
### 允许的用户 ID(全局)
|
||||
|
||||
**全局**用户级闸门。填入后,**只有**列表里的用户可以与机器人交互 — 私信、群聊 @、子话题,所有入口都生效,不受 DM Policy / Group Policy 模式影响。留空则不做用户级过滤,交给各 scope 自己的策略决定。在 **设置 → 高级** 启用开发者模式后,右键用户 → **复制用户 ID** 即可获取。
|
||||
|
||||
非白名单用户的私信会收到 "你没有权限" 的系统提示。群里被 @ 时也会收到类似提示,但是**发到 Discord 因 @ 自动创建的回复 thread 里**,不会污染父频道。
|
||||
|
||||
### 私信策略
|
||||
|
||||
控制 1:1 私聊。
|
||||
|
||||
- **开放 (Open)(默认)** — 任何与机器人共享服务器的用户都可以私信(若设置了全局白名单则受其约束)。
|
||||
- **白名单 (Allowlist)** — 私信需要发送者在 **允许的用户 ID** 里。和 `Open` 的差别在白名单为空时:`Allowlist` 模式**全部拒绝**,`Open` 模式仍然放任何人私信。
|
||||
- **配对审批 (Pairing)** — 与 `Allowlist` 共享同一份名单,但非名单用户被拒后会收到一次性配对码,由你(属主)通过 `/approve <code>` 审批。审批通过的用户会被自动追加到 **允许的用户 ID**,后续 DM 直通。需先填 **你的平台用户 ID**(runtime 用它校验 `/approve` 发起人),并需要部署 Redis。
|
||||
- **禁用 (Disabled)** — 机器人忽略所有私信,发起方会收到一条提示,引导其在共享频道里 @机器人。
|
||||
|
||||
> Discord 机器人可被任意共享服务器的用户私信,如果你的机器人是私有用途,建议填入 **允许的用户 ID**、把私信策略切到 **配对审批** 让陌生人走自助申请通道,或干脆设为 **禁用**。
|
||||
|
||||
### 群组策略
|
||||
|
||||
控制服务器频道与子话题里的 @提及。
|
||||
|
||||
- **开放 (Open)(默认)** — 在机器人能读取的任何频道里被 @就响应。
|
||||
- **白名单 (Allowlist)** — 只在 **允许的频道 ID** 列出的频道里响应。右键频道 → **复制频道 ID** 即可获取。
|
||||
- **禁用 (Disabled)** — 忽略所有服务器频道流量,只接受私信。
|
||||
|
||||
> 两个策略相互独立。你可以做纯私信机器人(`groupPolicy=disabled`)、纯频道机器人(`dmPolicy=disabled`),也可以两边都用白名单收紧范围。
|
||||
|
||||
跨平台细节见 [渠道概览](/docs/usage/channels/overview#direct-message-policy)。
|
||||
|
||||
## 配置参考
|
||||
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| ----------- | ---- | -------------------- |
|
||||
| **应用程序 ID** | 是 | 您的 Discord 应用程序的 ID |
|
||||
| **机器人令牌** | 是 | 您的 Discord 机器人的认证令牌 |
|
||||
| **公钥** | 是 | 用于验证来自 Discord 的交互请求 |
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| ------------ | ---- | ---------------------------------------------------------- |
|
||||
| **应用程序 ID** | 是 | 您的 Discord 应用程序的 ID |
|
||||
| **机器人令牌** | 是 | 您的 Discord 机器人的认证令牌 |
|
||||
| **公钥** | 是 | 用于验证来自 Discord 的交互请求 |
|
||||
| **允许的用户 ID** | 否 | 逗号或空格分隔的 Discord 用户 ID。全局闸门 — 私信和群聊 @ 都受其约束 |
|
||||
| **私信策略** | 否 | `open`(默认)、`allowlist`、`pairing` 或 `disabled` — 控制谁可以私信机器人 |
|
||||
| **群组策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制机器人在哪些频道响应 |
|
||||
| **允许的频道 ID** | 否 | 逗号或空格分隔的 Discord 频道 ID。仅在群组策略为白名单时使用 |
|
||||
|
||||
## 故障排除
|
||||
|
||||
- **机器人未在服务器中响应:** 确认机器人已被邀请到服务器并拥有正确的权限,同时启用了消息内容意图。
|
||||
- **机器人未在服务器中响应:** 确认机器人已被邀请到服务器并拥有正确的权限,同时启用了消息内容意图。如果 **群组策略** 是 `Disabled` 或 `Allowlist`,确认目标频道在 **允许的频道 ID** 列表里。如果 **允许的用户 ID** 已填,发送者的用户 ID 必须在列表里。
|
||||
- **机器人不回私信:** 打开 **高级设置**,确认 **私信策略** 不是 `Disabled`。如果 **允许的用户 ID** 已填,确认发起方的 Discord 用户 ID 在列表里。
|
||||
- **测试连接失败:** 仔细检查应用程序 ID、机器人令牌和公钥是否正确。
|
||||
|
||||
@@ -174,19 +174,65 @@ By connecting a Feishu channel to your LobeHub agent, team members can interact
|
||||
|
||||
Back in LobeHub's channel settings, click **Test Connection** to verify the credentials. Then find your bot in Feishu by searching its name and send it a message to confirm it responds.
|
||||
|
||||
## Step 7: Set Your Platform Identity (Recommended)
|
||||
|
||||
One optional field under **Advanced Settings** carries a lot of weight in day-to-day use — fill it in once and most surprises go away.
|
||||
|
||||
### Your Platform User ID
|
||||
|
||||
This is your own Feishu `open_id` (the per-app, per-user identifier — **not** the same as your Feishu mobile number or email), used by:
|
||||
|
||||
- **Pairing approval** — required when **DM Policy** is set to **Pairing**, since `/approve <code>` is the owner's command and the runtime checks the sender against this ID.
|
||||
- **AI tools push** — lets the agent reach you proactively (reminders, notifications) by mapping its internal user reference to your Feishu account.
|
||||
- **Anti-lockout** — auto-trusted by **Allowed Users**, so scoping the bot to teammates won't accidentally lock you out.
|
||||
|
||||
To get it: DM the bot once and inspect the inbound event payload — the `open_id` field on the sender is yours. The Feishu Developer Portal also exposes a **User ID** lookup that maps mobile/email to `open_id`. Paste it into **Your Platform User ID** in LobeHub's Advanced Settings.
|
||||
|
||||
> Feishu doesn't expose a single "default server" concept that AI tools can pivot on (the bot operates per-tenant via credentials), so the **Default Server** field is not exposed for Feishu channels.
|
||||
|
||||
## Access Policies
|
||||
|
||||
Two independent policies gate inbound traffic. Both default to **Open**.
|
||||
|
||||
### Allowed User IDs (global)
|
||||
|
||||
A populated **Allowed User IDs** field is a global gate — DMs *and* group `@mentions` are restricted to listed Feishu `open_id` values. Empty means "no user-level filter". Read the `open_id` from the event payload, or copy the **User ID** displayed in the Feishu Developer Portal.
|
||||
|
||||
### DM Policy
|
||||
|
||||
- **Open (default)** — Any tenant member can DM the bot (subject to the global allowlist when set).
|
||||
- **Allowlist** — DMs require the sender to be in **Allowed User IDs**. Differs from `Open` only when the list is empty: `Allowlist` then fails closed (no DMs).
|
||||
- **Pairing** — Same gate as `Allowlist`, but a non-listed sender receives a one-time pairing code instead of a flat rejection. Approve via `/approve <code>` and the applicant is auto-appended to **Allowed User IDs**. Requires **Your Platform User ID** to be set (the runtime checks the `/approve` sender against it) and a configured Redis backend.
|
||||
- **Disabled** — The bot ignores all DMs and only responds to chat-group `@mentions`.
|
||||
|
||||
### Group Policy
|
||||
|
||||
Controls which Feishu chat groups the bot will respond in.
|
||||
|
||||
- **Open (default)** — Respond to `@mentions` in any chat group the bot has been added to.
|
||||
- **Allowlist** — Respond only in chats whose `chat_id` is listed in **Allowed Channel IDs** (read it from the event payload).
|
||||
- **Disabled** — Ignore all group traffic; the bot only responds to DMs.
|
||||
|
||||
See the [Channels overview](/docs/usage/channels/overview#direct-message-policy) for cross-platform details.
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
| Field | Required | Description |
|
||||
| -------------------------- | -------- | --------------------------------------------------------------- |
|
||||
| **App ID** | Yes | Your Feishu app's App ID (`cli_xxx`) |
|
||||
| **App Secret** | Yes | Your Feishu app's App Secret |
|
||||
| **Verification Token** | No | Verifies webhook event source (recommended) |
|
||||
| **Encrypt Key** | No | Decrypts encrypted event payloads |
|
||||
| **Event Subscription URL** | — | Auto-generated after saving; paste into Feishu Developer Portal |
|
||||
| Field | Required | Description |
|
||||
| -------------------------- | -------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| **App ID** | Yes | Your Feishu app's App ID (`cli_xxx`) |
|
||||
| **App Secret** | Yes | Your Feishu app's App Secret |
|
||||
| **Verification Token** | No | Verifies webhook event source (recommended) |
|
||||
| **Encrypt Key** | No | Decrypts encrypted event payloads |
|
||||
| **Event Subscription URL** | — | Auto-generated after saving; paste into Feishu Developer Portal |
|
||||
| **Allowed User IDs** | No | Comma- or whitespace-separated Feishu `open_id` values. Global gate — applies to DMs and group @mentions |
|
||||
| **DM Policy** | No | `open` (default), `allowlist`, `pairing`, or `disabled` — who is allowed to DM the bot |
|
||||
| **Group Policy** | No | `open` (default), `allowlist`, or `disabled` — where the bot responds to @mentions |
|
||||
| **Allowed Channel IDs** | No | Comma- or whitespace-separated Feishu `chat_id` values. Used when Group Policy is Allowlist |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Event Subscription URL verification failed:** Ensure you saved the configuration in LobeHub first, and the URL was copied correctly.
|
||||
- **Bot not responding:** Verify the app is published and approved, the bot capability is enabled, and the `im.message.receive_v1` event is subscribed.
|
||||
- **Bot ignores DMs:** Open **Advanced Settings** in LobeHub and check **DM Policy**. If it is `Disabled`, switch to `Open` or `Allowlist`. If it is `Allowlist`, confirm the sender's `open_id` is listed in **Allowed User IDs**.
|
||||
- **Permission errors:** Confirm all required permissions are added and approved in the Developer Portal.
|
||||
- **Test Connection failed:** Double-check the App ID and App Secret.
|
||||
|
||||
@@ -170,19 +170,65 @@ tags:
|
||||
|
||||
回到 LobeHub 的渠道设置,点击 **测试连接** 以验证凭证。然后在飞书中搜索您的机器人名称并发送消息,确认其是否响应。
|
||||
|
||||
## 第七步:填写你的平台身份(推荐)
|
||||
|
||||
**高级设置**里有一个可选字段影响日常使用体验,建议一开始就填好。
|
||||
|
||||
### 你的平台用户 ID
|
||||
|
||||
也就是你自己的飞书 `open_id`(按应用、按用户隔离的标识符 ——**不是**手机号或邮箱),用于:
|
||||
|
||||
- **配对审批** — 当 **私信策略** 为 **配对审批** 时为必填项,`/approve <code>` 是属主命令,runtime 会用这个 ID 校验发起人。
|
||||
- **AI 工具主动推送** — 让 Agent 能主动联系你(提醒、通知),把内部用户引用映射到你的飞书账号。
|
||||
- **防自锁** — 自动被 **允许的用户** 信任,给同事收紧 bot 时不会把自己挡在外面。
|
||||
|
||||
获取方式:先用任意消息私信 bot 一次,查看入站事件 payload 中发送方的 `open_id` 字段,那就是你的。飞书开发者后台也提供 **User ID 查询** 工具,用手机号 / 邮箱反查 `open_id`。粘贴到 LobeHub 高级设置的 **你的平台用户 ID** 字段。
|
||||
|
||||
> 飞书没有一个 AI 工具能默认指向的 "默认服务器" 概念(bot 通过凭证按租户运行),因此飞书渠道不展示 **默认服务器** 字段。
|
||||
|
||||
## 接入策略
|
||||
|
||||
两个独立的策略控制入站消息,默认都为 **开放**。
|
||||
|
||||
### 允许的用户 ID(全局)
|
||||
|
||||
填入 **允许的用户 ID** 后,**所有**入站消息(私信和群聊 `@提及`)都必须来自列表里的飞书 `open_id`。留空则不做用户级过滤。`open_id` 可从事件 payload 读取,或在飞书开发者后台查看 **User ID**。
|
||||
|
||||
### 私信策略
|
||||
|
||||
- **开放 (Open)(默认)** — 租户内任何成员都可以私信机器人(若设置了全局白名单则受其约束)。
|
||||
- **白名单 (Allowlist)** — 私信需要发送者在 **允许的用户 ID** 里。和 `Open` 的差别在白名单为空时:`Allowlist` 模式**全部拒绝**。
|
||||
- **配对审批 (Pairing)** — 与 `Allowlist` 共享同一份名单,但非名单用户被拒后会收到一次性配对码,由你(属主)通过 `/approve <code>` 审批。审批通过的用户会被自动追加到 **允许的用户 ID**,后续 DM 直通。需先填 **你的平台用户 ID**(runtime 用它校验 `/approve` 发起人),并需要部署 Redis。
|
||||
- **禁用 (Disabled)** — 机器人忽略所有私信,只在群聊里被 `@提及` 时回复。
|
||||
|
||||
### 群组策略
|
||||
|
||||
控制机器人会在哪些飞书群里响应。
|
||||
|
||||
- **开放 (Open)(默认)** — 在机器人加入的任何群里被 `@` 都响应。
|
||||
- **白名单 (Allowlist)** — 只在 **允许的频道 ID** 列出的会话里响应(使用事件 payload 里的 `chat_id`)。
|
||||
- **禁用 (Disabled)** — 忽略所有群聊流量,机器人只接受私信。
|
||||
|
||||
跨平台细节见 [渠道概览](/docs/usage/channels/overview#direct-message-policy)。
|
||||
|
||||
## 配置参考
|
||||
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| ---------------------- | ---- | ----------------------- |
|
||||
| **应用 ID** | 是 | 您的飞书应用的应用 ID(`cli_xxx`) |
|
||||
| **应用密钥** | 是 | 您的飞书应用的应用密钥 |
|
||||
| **Verification Token** | 否 | 验证 webhook 事件来源(推荐) |
|
||||
| **Encrypt Key** | 否 | 解密加密事件负载 |
|
||||
| **事件订阅 URL** | — | 保存后自动生成;粘贴到飞书开发者门户 |
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| ---------------------- | ---- | ---------------------------------------------------------- |
|
||||
| **应用 ID** | 是 | 您的飞书应用的应用 ID(`cli_xxx`) |
|
||||
| **应用密钥** | 是 | 您的飞书应用的应用密钥 |
|
||||
| **Verification Token** | 否 | 验证 webhook 事件来源(推荐) |
|
||||
| **Encrypt Key** | 否 | 解密加密事件负载 |
|
||||
| **事件订阅 URL** | — | 保存后自动生成;粘贴到飞书开发者门户 |
|
||||
| **允许的用户 ID** | 否 | 逗号或空格分隔的飞书 `open_id`。全局闸门 — 私信和群聊 @ 都受其约束 |
|
||||
| **私信策略** | 否 | `open`(默认)、`allowlist`、`pairing` 或 `disabled` — 控制谁可以私信机器人 |
|
||||
| **群组策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制机器人在哪些群中响应 |
|
||||
| **允许的频道 ID** | 否 | 逗号或空格分隔的飞书 `chat_id`。仅在群组策略为白名单时使用 |
|
||||
|
||||
## 故障排除
|
||||
|
||||
- **事件订阅 URL 验证失败:** 确保您已在 LobeHub 中保存配置,并正确复制了 URL。
|
||||
- **机器人未响应:** 验证应用已发布并获得批准,机器人功能已启用,并订阅了 `im.message.receive_v1` 事件。
|
||||
- **机器人不回私信:** 在 LobeHub 的 **高级设置** 检查 **私信策略**。如果是 `Disabled`,改成 `Open` 或 `Allowlist`;如果是 `Allowlist`,确认发起方的 `open_id` 已加入 **允许的用户 ID**。
|
||||
- **权限错误:** 确保所有所需权限已在开发者门户中添加并获得批准。
|
||||
- **测试连接失败:** 仔细检查应用 ID 和应用密钥。
|
||||
|
||||
@@ -165,19 +165,65 @@ By connecting a Lark channel to your LobeHub agent, team members can interact wi
|
||||
|
||||
Back in LobeHub's channel settings, click **Test Connection** to verify the credentials. Then find your bot in Lark by searching its name and send it a message to confirm it responds.
|
||||
|
||||
## Step 7: Set Your Platform Identity (Recommended)
|
||||
|
||||
One optional field under **Advanced Settings** carries a lot of weight in day-to-day use — fill it in once and most surprises go away.
|
||||
|
||||
### Your Platform User ID
|
||||
|
||||
This is your own Lark `open_id` (the per-app, per-user identifier — **not** the same as your Lark mobile number or email), used by:
|
||||
|
||||
- **Pairing approval** — required when **DM Policy** is set to **Pairing**, since `/approve <code>` is the owner's command and the runtime checks the sender against this ID.
|
||||
- **AI tools push** — lets the agent reach you proactively (reminders, notifications) by mapping its internal user reference to your Lark account.
|
||||
- **Anti-lockout** — auto-trusted by **Allowed Users**, so scoping the bot to teammates won't accidentally lock you out.
|
||||
|
||||
To get it: DM the bot once and inspect the inbound event payload — the `open_id` field on the sender is yours. The Lark Developer Portal also exposes a **User ID** lookup that maps mobile/email to `open_id`. Paste it into **Your Platform User ID** in LobeHub's Advanced Settings.
|
||||
|
||||
> Lark doesn't expose a single "default server" concept that AI tools can pivot on (the bot operates per-tenant via credentials), so the **Default Server** field is not exposed for Lark channels.
|
||||
|
||||
## Access Policies
|
||||
|
||||
Two independent policies gate inbound traffic. Both default to **Open**.
|
||||
|
||||
### Allowed User IDs (global)
|
||||
|
||||
A populated **Allowed User IDs** field is a global gate — DMs *and* group `@mentions` are restricted to listed Lark `open_id` values. Empty means "no user-level filter". Read the `open_id` from the event payload, or copy the **User ID** displayed in the Lark Developer Portal.
|
||||
|
||||
### DM Policy
|
||||
|
||||
- **Open (default)** — Any tenant member can DM the bot (subject to the global allowlist when set).
|
||||
- **Allowlist** — DMs require the sender to be in **Allowed User IDs**. Differs from `Open` only when the list is empty: `Allowlist` then fails closed (no DMs).
|
||||
- **Pairing** — Same gate as `Allowlist`, but a non-listed sender receives a one-time pairing code instead of a flat rejection. Approve via `/approve <code>` and the applicant is auto-appended to **Allowed User IDs**. Requires **Your Platform User ID** to be set (the runtime checks the `/approve` sender against it) and a configured Redis backend.
|
||||
- **Disabled** — The bot ignores all DMs and only responds to chat-group `@mentions`.
|
||||
|
||||
### Group Policy
|
||||
|
||||
Controls which Lark chat groups the bot will respond in.
|
||||
|
||||
- **Open (default)** — Respond to `@mentions` in any chat group the bot has been added to.
|
||||
- **Allowlist** — Respond only in chats whose `chat_id` is listed in **Allowed Channel IDs** (read it from the event payload).
|
||||
- **Disabled** — Ignore all group traffic; the bot only responds to DMs.
|
||||
|
||||
See the [Channels overview](/docs/usage/channels/overview#direct-message-policy) for cross-platform details.
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
| Field | Required | Description |
|
||||
| -------------------------- | -------- | ------------------------------------------------------------- |
|
||||
| **App ID** | Yes | Your Lark app's App ID (`cli_xxx`) |
|
||||
| **App Secret** | Yes | Your Lark app's App Secret |
|
||||
| **Verification Token** | No | Verifies webhook event source (recommended) |
|
||||
| **Encrypt Key** | No | Decrypts encrypted event payloads |
|
||||
| **Event Subscription URL** | — | Auto-generated after saving; paste into Lark Developer Portal |
|
||||
| Field | Required | Description |
|
||||
| -------------------------- | -------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| **App ID** | Yes | Your Lark app's App ID (`cli_xxx`) |
|
||||
| **App Secret** | Yes | Your Lark app's App Secret |
|
||||
| **Verification Token** | No | Verifies webhook event source (recommended) |
|
||||
| **Encrypt Key** | No | Decrypts encrypted event payloads |
|
||||
| **Event Subscription URL** | — | Auto-generated after saving; paste into Lark Developer Portal |
|
||||
| **Allowed User IDs** | No | Comma- or whitespace-separated Lark `open_id` values. Global gate — applies to DMs and group @mentions |
|
||||
| **DM Policy** | No | `open` (default), `allowlist`, `pairing`, or `disabled` — who is allowed to DM the bot |
|
||||
| **Group Policy** | No | `open` (default), `allowlist`, or `disabled` — where the bot responds to @mentions |
|
||||
| **Allowed Channel IDs** | No | Comma- or whitespace-separated Lark `chat_id` values. Used when Group Policy is Allowlist |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Event Subscription URL verification failed:** Ensure you saved the configuration in LobeHub first, and the URL was copied correctly.
|
||||
- **Bot not responding:** Verify the app is published and approved, the bot capability is enabled, and the `im.message.receive_v1` event is subscribed.
|
||||
- **Bot ignores DMs:** Open **Advanced Settings** in LobeHub and check **DM Policy**. If it is `Disabled`, switch to `Open` or `Allowlist`. If it is `Allowlist`, confirm the sender's `open_id` is listed in **Allowed User IDs**.
|
||||
- **Permission errors:** Confirm all required permissions are added and approved in the Developer Portal.
|
||||
- **Test Connection failed:** Double-check the App ID and App Secret. Make sure you selected "Lark" (not "飞书") in LobeHub's channel settings.
|
||||
|
||||
@@ -162,19 +162,65 @@ tags:
|
||||
|
||||
回到 LobeHub 的渠道设置,点击 **Test Connection** 以验证凭证。然后在 Lark 中搜索您的机器人名称并发送消息,确认其是否响应。
|
||||
|
||||
## 第七步:填写你的平台身份(推荐)
|
||||
|
||||
**高级设置**里有一个可选字段影响日常使用体验,建议一开始就填好。
|
||||
|
||||
### 你的平台用户 ID
|
||||
|
||||
也就是你自己的 Lark `open_id`(按应用、按用户隔离的标识符 ——**不是**手机号或邮箱),用于:
|
||||
|
||||
- **配对审批** — 当 **私信策略** 为 **配对审批** 时为必填项,`/approve <code>` 是属主命令,runtime 会用这个 ID 校验发起人。
|
||||
- **AI 工具主动推送** — 让 Agent 能主动联系你(提醒、通知),把内部用户引用映射到你的 Lark 账号。
|
||||
- **防自锁** — 自动被 **允许的用户** 信任,给同事收紧 bot 时不会把自己挡在外面。
|
||||
|
||||
获取方式:先用任意消息私信 bot 一次,查看入站事件 payload 中发送方的 `open_id` 字段,那就是你的。Lark 开发者后台也提供 **User ID 查询** 工具,用手机号 / 邮箱反查 `open_id`。粘贴到 LobeHub 高级设置的 **你的平台用户 ID** 字段。
|
||||
|
||||
> Lark 没有一个 AI 工具能默认指向的 "默认服务器" 概念(bot 通过凭证按租户运行),因此 Lark 渠道不展示 **默认服务器** 字段。
|
||||
|
||||
## 接入策略
|
||||
|
||||
两个独立的策略控制入站消息,默认都为 **开放**。
|
||||
|
||||
### 允许的用户 ID(全局)
|
||||
|
||||
填入 **允许的用户 ID** 后,**所有**入站消息(私信和群聊 `@提及`)都必须来自列表里的 Lark `open_id`。留空则不做用户级过滤。`open_id` 可从事件 payload 读取,或在 Lark 开发者后台查看 **User ID**。
|
||||
|
||||
### 私信策略
|
||||
|
||||
- **开放 (Open)(默认)** — 租户内任何成员都可以私信机器人(若设置了全局白名单则受其约束)。
|
||||
- **白名单 (Allowlist)** — 私信需要发送者在 **允许的用户 ID** 里。和 `Open` 的差别在白名单为空时:`Allowlist` 模式**全部拒绝**。
|
||||
- **配对审批 (Pairing)** — 与 `Allowlist` 共享同一份名单,但非名单用户被拒后会收到一次性配对码,由你(属主)通过 `/approve <code>` 审批。审批通过的用户会被自动追加到 **允许的用户 ID**,后续 DM 直通。需先填 **你的平台用户 ID**(runtime 用它校验 `/approve` 发起人),并需要部署 Redis。
|
||||
- **禁用 (Disabled)** — 机器人忽略所有私信,只在群聊里被 `@提及` 时回复。
|
||||
|
||||
### 群组策略
|
||||
|
||||
控制机器人会在哪些 Lark 群里响应。
|
||||
|
||||
- **开放 (Open)(默认)** — 在机器人加入的任何群里被 `@` 都响应。
|
||||
- **白名单 (Allowlist)** — 只在 **允许的频道 ID** 列出的会话里响应(使用事件 payload 里的 `chat_id`)。
|
||||
- **禁用 (Disabled)** — 忽略所有群聊流量,机器人只接受私信。
|
||||
|
||||
跨平台细节见 [渠道概览](/docs/usage/channels/overview#direct-message-policy)。
|
||||
|
||||
## 配置参考
|
||||
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| -------------------------- | ---- | ----------------------------- |
|
||||
| **App ID** | 是 | 您的 Lark 应用的 App ID(`cli_xxx`) |
|
||||
| **App Secret** | 是 | 您的 Lark 应用的 App Secret |
|
||||
| **Verification Token** | 否 | 验证 webhook 事件来源(推荐) |
|
||||
| **Encrypt Key** | 否 | 解密加密事件负载 |
|
||||
| **Event Subscription URL** | — | 保存后自动生成;粘贴到 Lark 开发者门户 |
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| -------------------------- | ---- | ---------------------------------------------------------- |
|
||||
| **App ID** | 是 | 您的 Lark 应用的 App ID(`cli_xxx`) |
|
||||
| **App Secret** | 是 | 您的 Lark 应用的 App Secret |
|
||||
| **Verification Token** | 否 | 验证 webhook 事件来源(推荐) |
|
||||
| **Encrypt Key** | 否 | 解密加密事件负载 |
|
||||
| **Event Subscription URL** | — | 保存后自动生成;粘贴到 Lark 开发者门户 |
|
||||
| **允许的用户 ID** | 否 | 逗号或空格分隔的 Lark `open_id`。全局闸门 — 私信和群聊 @ 都受其约束 |
|
||||
| **私信策略** | 否 | `open`(默认)、`allowlist`、`pairing` 或 `disabled` — 控制谁可以私信机器人 |
|
||||
| **群组策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制机器人在哪些群中响应 |
|
||||
| **允许的频道 ID** | 否 | 逗号或空格分隔的 Lark `chat_id`。仅在群组策略为白名单时使用 |
|
||||
|
||||
## 故障排除
|
||||
|
||||
- **Event Subscription URL 验证失败:** 确保您已在 LobeHub 中保存配置,并正确复制了 URL。
|
||||
- **机器人未响应:** 验证应用已发布并获得批准,机器人功能已启用,并订阅了 `im.message.receive_v1` 事件。
|
||||
- **机器人不回私信:** 在 LobeHub 的 **高级设置** 检查 **私信策略**。如果是 `Disabled`,改成 `Open` 或 `Allowlist`;如果是 `Allowlist`,确认发起方的 `open_id` 已加入 **允许的用户 ID**。
|
||||
- **权限错误:** 确保所有所需权限已在开发者门户中添加并获得批准。
|
||||
- **测试连接失败:** 仔细检查 App ID 和 App Secret。确保您在 LobeHub 的渠道设置中选择了 "Lark"(而不是 "飞书")。
|
||||
|
||||
@@ -21,21 +21,21 @@ tags:
|
||||
|
||||
Channels allow you to connect your LobeHub agents to external messaging platforms. Once connected, users can interact with your AI assistant directly in the chat apps they already use — no need to visit LobeHub.
|
||||
|
||||
> [!NOTE]
|
||||
> \[!NOTE]
|
||||
>
|
||||
> WeChat currently requires an active subscription. If you are using the community edition without a subscription, the WeChat channel option may not appear in the Channels settings yet.
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
| Platform | Description |
|
||||
| ------------------------------------------ | --------------------------------------------------------------- |
|
||||
| [Discord](/docs/usage/channels/discord) | Connect to Discord servers for channel chat and direct messages |
|
||||
| [Slack](/docs/usage/channels/slack) | Connect to Slack for channel and direct message conversations |
|
||||
| [Telegram](/docs/usage/channels/telegram) | Connect to Telegram for private and group conversations |
|
||||
| [QQ](/docs/usage/channels/qq) | Connect to QQ for group chats and direct messages |
|
||||
| Platform | Description |
|
||||
| ------------------------------------------ | --------------------------------------------------------------------------------------------- |
|
||||
| [Discord](/docs/usage/channels/discord) | Connect to Discord servers for channel chat and direct messages |
|
||||
| [Slack](/docs/usage/channels/slack) | Connect to Slack for channel and direct message conversations |
|
||||
| [Telegram](/docs/usage/channels/telegram) | Connect to Telegram for private and group conversations |
|
||||
| [QQ](/docs/usage/channels/qq) | Connect to QQ for group chats and direct messages |
|
||||
| [WeChat (微信)](/docs/usage/channels/wechat) | Connect to WeChat via iLink Bot for private and group chats (requires an active subscription) |
|
||||
| [Feishu (飞书)](/docs/usage/channels/feishu) | Connect to Feishu for team collaboration (Chinese version) |
|
||||
| [Lark](/docs/usage/channels/lark) | Connect to Lark for team collaboration (international version) |
|
||||
| [Feishu (飞书)](/docs/usage/channels/feishu) | Connect to Feishu for team collaboration (Chinese version) |
|
||||
| [Lark](/docs/usage/channels/lark) | Connect to Lark for team collaboration (international version) |
|
||||
|
||||
## How It Works
|
||||
|
||||
@@ -70,3 +70,62 @@ Text messages are supported across all platforms. Some features vary by platform
|
||||
| Group chats | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
|
||||
| Reactions | Yes | Yes | Yes | No | No | Partial | Partial |
|
||||
| Image/file attachments | Yes | Yes | Yes | Yes | No | Yes | Yes |
|
||||
|
||||
## Allowed Users (global)
|
||||
|
||||
The **Allowed Users** list at the top of **Advanced Settings** is a *global* user gate. When populated, only the listed users can interact with the bot — in DMs **and** in group `@mentions` — regardless of DM Policy or Group Policy mode. Leave it empty to disable user-level filtering and let per-scope policies decide on their own.
|
||||
|
||||
Behaviour when populated:
|
||||
|
||||
| Surface | Non-allowlisted sender |
|
||||
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **DM** | Blocked. Sender receives a one-line "you aren't authorized to send direct messages" notice in the same DM. |
|
||||
| **Group / channel @mention** | Blocked. A short "you aren't authorized to interact with this bot" notice is posted in the same thread the @-mention arrived on (on Discord this is the auto-created reply thread, not the parent channel). |
|
||||
|
||||
Add one entry per row. Each row holds a platform user ID (required) and an optional **Note** — a private label that's only ever shown back to you on the settings page. The note is what saves you from having to remember whether `U01ABCXYZ` was Alice or the on-call account six months from now; the runtime ignores it entirely.
|
||||
|
||||
> **Anti-lockout**: if you have **Your Platform User ID** set (the AI-tools field), that ID is implicitly trusted by the global allowlist. Forgetting to add yourself to **Allowed Users** when scoping the bot to friends won't lock you out.
|
||||
|
||||
## Direct Message Policy
|
||||
|
||||
DM Policy only governs DMs — group `@mentions` are gated independently by **Group Policy** below. The user-level filter from the global **Allowed Users** is also applied; per-scope policy stacks on top.
|
||||
|
||||
| Policy | Behavior |
|
||||
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Open** | Any user on the platform can DM the bot (subject to the global allowlist when set). Best for public-facing assistants. |
|
||||
| **Allowlist** | DMs require the sender to be in **Allowed Users**. Distinct from `Open` only when the list is empty: `Allowlist` then **fails closed** (no DMs); `Open` still lets anyone DM. |
|
||||
| **Pairing** | Same gate as `Allowlist`, but a non-listed sender receives a one-time pairing code instead of a flat rejection. The owner approves via `/approve <code>`, which appends the applicant to **Allowed Users** so future DMs flow normally. Requires **Your Platform User ID** and a configured Redis. |
|
||||
| **Disabled** | The bot ignores all DMs entirely. Use this when the bot should only reply in shared channels via `@mention`. |
|
||||
|
||||
## Group Policy
|
||||
|
||||
For the same group-capable platforms, each channel has a **Group Policy** that controls where the bot responds to `@mentions`. This is independent of DM Policy: a `groupPolicy=disabled` bot still accepts DMs (subject to DM Policy), and vice versa.
|
||||
|
||||
| Policy | Behavior |
|
||||
| ------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Open** | Respond to `@mentions` in any group, channel, or thread the bot can read. Default. |
|
||||
| **Allowlist** | Respond only when the channel / group / chat ID is in **Allowed Channels**. Use to scope a bot to specific workspaces. |
|
||||
| **Disabled** | Ignore all non-DM traffic. The bot becomes DM-only. |
|
||||
|
||||
When **Allowlist** is selected, **Allowed Channels** appears as a row editor — one entry per channel / group / chat ID, with an optional note (e.g. `#general`) shown only to you so you can recognise each ID later.
|
||||
|
||||
> **Discord parent channels**: an `@mention` in a Discord channel automatically spawns a reply thread, so the inbound thread ID is *not* what Discord's "Copy Channel ID" gives you. You can paste the **parent channel ID** here — the bot accepts any auto-created reply thread under it. Pasting a specific thread ID instead works too, in which case only that thread is allowed.
|
||||
|
||||
### Per-platform defaults
|
||||
|
||||
Every supported platform defaults to **Open** for both policies so the bot stays reachable out of the box. Tighten per-channel via **Allowlist** or **Disabled** when you want a private bot. WeChat does not expose these settings because the WeChat integration is DM-only by design.
|
||||
|
||||
### Finding a user's platform ID
|
||||
|
||||
- **Discord** — Enable Developer Mode in user settings, right-click the user, and choose **Copy User ID**.
|
||||
- **Slack** — Open the user's profile → click the `⋮` menu → **Copy member ID** (starts with `U`).
|
||||
- **Telegram** — Ask the user to message [@userinfobot](https://t.me/userinfobot), or read `from.id` from the bot's incoming update.
|
||||
- **QQ** — Use the `tiny_id` from the OpenAPI event payload (the public-facing QQ number is not guaranteed to be the platform ID).
|
||||
- **Feishu / Lark** — Use the `open_id` from the event payload, or the **User ID** displayed in the developer portal.
|
||||
|
||||
### Finding a channel / group ID
|
||||
|
||||
- **Discord** — Enable Developer Mode, right-click the channel, and choose **Copy Channel ID**.
|
||||
- **Slack** — Open the channel's About panel and copy the channel ID at the bottom (starts with `C`).
|
||||
- **Telegram** — Forward a message from the group to [@userinfobot](https://t.me/userinfobot), or read `chat.id` from the bot's incoming update (group IDs are negative).
|
||||
- **Feishu / Lark** — Use the `chat_id` from the event payload.
|
||||
|
||||
@@ -20,21 +20,21 @@ tags:
|
||||
|
||||
渠道功能允许您将 LobeHub 代理连接到外部消息平台。一旦连接,用户可以直接在他们已经使用的聊天应用中与您的 AI 助手互动,无需访问 LobeHub。
|
||||
|
||||
> [!NOTE]
|
||||
> \[!NOTE]
|
||||
>
|
||||
> 微信渠道目前需要有效订阅。如果您使用的是没有订阅的社区版,**渠道**设置中可能暂时不会显示微信选项。
|
||||
|
||||
## 支持的平台
|
||||
|
||||
| 平台 | 描述 |
|
||||
| ----------------------------------------- | -------------------------- |
|
||||
| [Discord](/docs/usage/channels/discord) | 连接到 Discord 服务器,用于频道聊天和私信 |
|
||||
| [Slack](/docs/usage/channels/slack) | 连接到 Slack,用于频道和私信对话 |
|
||||
| [Telegram](/docs/usage/channels/telegram) | 连接到 Telegram,用于私人和群组对话 |
|
||||
| [QQ](/docs/usage/channels/qq) | 连接到 QQ,用于群聊和私信 |
|
||||
| 平台 | 描述 |
|
||||
| ----------------------------------------- | ---------------------------------- |
|
||||
| [Discord](/docs/usage/channels/discord) | 连接到 Discord 服务器,用于频道聊天和私信 |
|
||||
| [Slack](/docs/usage/channels/slack) | 连接到 Slack,用于频道和私信对话 |
|
||||
| [Telegram](/docs/usage/channels/telegram) | 连接到 Telegram,用于私人和群组对话 |
|
||||
| [QQ](/docs/usage/channels/qq) | 连接到 QQ,用于群聊和私信 |
|
||||
| [微信](/docs/usage/channels/wechat) | 通过 iLink Bot 连接到微信,用于私聊和群聊(需要有效订阅) |
|
||||
| [飞书](/docs/usage/channels/feishu) | 连接到飞书,用于团队协作(中国版) |
|
||||
| [Lark](/docs/usage/channels/lark) | 连接到 Lark,用于团队协作(国际版) |
|
||||
| [飞书](/docs/usage/channels/feishu) | 连接到飞书,用于团队协作(中国版) |
|
||||
| [Lark](/docs/usage/channels/lark) | 连接到 Lark,用于团队协作(国际版) |
|
||||
|
||||
## 工作原理
|
||||
|
||||
@@ -69,3 +69,62 @@ tags:
|
||||
| 群组聊天 | 是 | 是 | 是 | 是 | 是 | 是 | 是 |
|
||||
| 表情反应 | 是 | 是 | 是 | 否 | 否 | 部分支持 | 部分支持 |
|
||||
| 图片 / 文件附件 | 是 | 是 | 是 | 是 | 否 | 是 | 是 |
|
||||
|
||||
## 允许的用户(全局)
|
||||
|
||||
**高级设置** 里的 **允许的用户 (allowFrom)** 是一道**全局**用户级闸门。填入后,**所有**入站消息(私信和群聊 `@提及` 都算)都必须来自列表里的用户,无论私信策略 / 群组策略选什么都生效。留空则不做用户级过滤,由各 scope 的策略自行决定。
|
||||
|
||||
填入后的行为:
|
||||
|
||||
| 入口 | 不在白名单的发送者 |
|
||||
| --------------- | ------------------------------------------------------------------------------------------ |
|
||||
| **私信** | 被拒绝,对方在私信里收到一条 "您没有私信此机器人的权限" 的系统提示。 |
|
||||
| **群组 / 频道 @提及** | 被拒绝,会在 @ 所在的同一线程里发一条 "您没有与该机器人交互的权限" 的提示(Discord 上这是 @ 时自动创建的 thread,不会污染父频道;其他平台则可见于该群组)。 |
|
||||
|
||||
每行填一个用户:左边是平台用户 ID(必填),右边是可选的 **备注** —— 这个备注只会在设置页展示给你自己,让你日后回看时不用费力辨认 `U01ABCXYZ` 到底是张三还是值班号;运行时完全不读它。
|
||||
|
||||
> **防自我锁出**:如果你已经填了 **你的平台用户 ID**(AI 工具字段),那个 ID 会被全局白名单隐式信任。即便你把 bot 收紧到只给朋友、忘了把自己加进 **允许的用户**,也不会被锁出。
|
||||
|
||||
## 私信策略
|
||||
|
||||
私信策略只影响私信 — 群聊里的 `@提及` 由下面的 **群组策略** 单独管理。全局 **允许的用户** 的用户级过滤也会同时生效;各 scope 的策略叠加在上面。
|
||||
|
||||
| 策略 | 行为 |
|
||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **开放 (Open)** | 平台上的任何用户都可以私信机器人(如设置了全局白名单则受其约束)。适合面向所有人开放的助手。 |
|
||||
| **白名单 (Allowlist)** | 私信需要发送者在 **允许的用户** 里。和 `Open` 的差别在白名单为空时:`Allowlist` 模式下会**全部拒绝**,而 `Open` 模式下任何人都能私信。 |
|
||||
| **配对审批 (Pairing)** | 与 `Allowlist` 共享同一份名单,但非名单用户被拒后会收到一次性配对码,由你(属主)通过 `/approve <code>` 审批。审批通过的用户会被自动追加到 **允许的用户**,后续 DM 直通。需先填 **你的平台用户 ID** 并部署 Redis。 |
|
||||
| **禁用 (Disabled)** | 机器人会忽略所有私信。适合那种 " 只在群里被 `@` 时才回复 " 的场景。 |
|
||||
|
||||
## 群组策略
|
||||
|
||||
同样适用于支持群聊的那些平台,每个渠道都有一个 **群组策略 (Group Policy)**,用来控制机器人在群组、频道、子话题里的响应范围。它跟私信策略相互独立:`groupPolicy=disabled` 的机器人仍会接收私信(受私信策略约束),反之亦然。
|
||||
|
||||
| 策略 | 行为 |
|
||||
| ------------------- | ---------------------------------------- |
|
||||
| **开放 (Open)** | 在机器人能读取的任何群组、频道、子话题里被 `@` 都响应(默认)。 |
|
||||
| **白名单 (Allowlist)** | 只有 **允许的频道** 里列出的频道 / 群组 / 会话才会响应,其他都忽略。 |
|
||||
| **禁用 (Disabled)** | 忽略所有非私信流量,机器人退化为纯私信模式。 |
|
||||
|
||||
选择 **白名单** 时会出现 **允许的频道** 编辑器:每行填一个平台原生的频道 / 群组 / 会话 ID,可附带一个备注(比如 `#general`),备注只展示给你自己,方便日后辨认每个 ID 是哪个频道。
|
||||
|
||||
> **Discord 父频道**:在 Discord 频道里 `@提及` 机器人时,平台会自动开一个 reply thread,入站 thread ID **不等于** Discord「复制频道 ID」给你的那个值。这里直接粘贴**父频道 ID** 就行 —— 它会让父频道下的所有自动 reply thread 都通过白名单。如果你只想放行某个具体 thread,也可以填那个 thread 的 ID,效果就是只放行该 thread。
|
||||
|
||||
### 各平台默认值
|
||||
|
||||
所有支持的平台两个策略默认都为 **开放 (Open)**,开箱即用。需要私有化时按渠道改成 **白名单 (Allowlist)** 或 **禁用 (Disabled)** 即可。微信因本身就是 DM 模式,没有这两个策略选项。
|
||||
|
||||
### 如何获取用户的平台 ID
|
||||
|
||||
- **Discord** — 在用户设置里开启开发者模式,右键用户头像选 **复制用户 ID**。
|
||||
- **Slack** — 打开用户资料 → 点击 `⋮` 菜单 → **复制成员 ID**(以 `U` 开头)。
|
||||
- **Telegram** — 让用户私信 [@userinfobot](https://t.me/userinfobot),或者从机器人收到的 update 里读 `from.id`。
|
||||
- **QQ** — 使用 OpenAPI 事件 payload 里的 `tiny_id`(用户对外可见的 QQ 号不一定就是平台 ID)。
|
||||
- **飞书 / Lark** — 使用事件 payload 里的 `open_id`,或开发者后台显示的 **User ID**。
|
||||
|
||||
### 如何获取频道 / 群组 ID
|
||||
|
||||
- **Discord** — 开启开发者模式,右键频道选 **复制频道 ID**。
|
||||
- **Slack** — 打开频道详情面板,底部能看到频道 ID(以 `C` 开头)。
|
||||
- **Telegram** — 把群里的一条消息转发给 [@userinfobot](https://t.me/userinfobot),或者从机器人收到的 update 里读 `chat.id`(群组是负数)。
|
||||
- **飞书 / Lark** — 使用事件 payload 里的 `chat_id`。
|
||||
|
||||
@@ -132,6 +132,22 @@ LobeHub supports two connection modes for QQ bots:
|
||||
|
||||
Click **Test Connection** in LobeHub's channel settings to verify the integration. Then open QQ, find your bot, and send a message. The bot should respond through your LobeHub agent.
|
||||
|
||||
## Set Your Platform Identity (Recommended)
|
||||
|
||||
One optional field under **Advanced Settings** carries a lot of weight in day-to-day use — fill it in once and most surprises go away.
|
||||
|
||||
### Your Platform User ID
|
||||
|
||||
This is your own QQ `tiny_id` (the platform-level user identifier — **not** the public-facing QQ number, which doesn't always match), used by:
|
||||
|
||||
- **Pairing approval** — required when **DM Policy** is set to **Pairing**, since `/approve <code>` is the owner's command and the runtime checks the sender against this ID.
|
||||
- **AI tools push** — lets the agent reach you proactively (reminders, notifications) by mapping its internal user reference to your QQ account.
|
||||
- **Anti-lockout** — auto-trusted by **Allowed Users**, so scoping the bot to friends won't accidentally lock you out.
|
||||
|
||||
To get it: DM the bot once with any message and check the server logs for the `tiny_id` field on the inbound event payload (or read it from the OpenAPI dashboard if available). Paste the long numeric ID into **Your Platform User ID** in LobeHub's Advanced Settings.
|
||||
|
||||
> QQ doesn't expose a single "default server" concept that AI tools can pivot on, so the **Default Server** field is not exposed for QQ channels.
|
||||
|
||||
## Adding the Bot to Group Chats
|
||||
|
||||
To use the bot in QQ groups:
|
||||
@@ -140,13 +156,42 @@ To use the bot in QQ groups:
|
||||
2. @mention the bot in a message to trigger a response
|
||||
3. The bot will reply in the group conversation
|
||||
|
||||
## Access Policies
|
||||
|
||||
Two independent policies gate inbound traffic. Both default to **Open**.
|
||||
|
||||
### Allowed User IDs (global)
|
||||
|
||||
A populated **Allowed User IDs** field is a global gate — DMs *and* group `@mentions` are restricted to listed QQ `tiny_id` values. Empty means "no user-level filter". Use the platform `tiny_id` from the OpenAPI event payload — the visible QQ number is not always the same as the platform ID.
|
||||
|
||||
### DM Policy
|
||||
|
||||
- **Open (default)** — Any QQ user who shares context with the bot can DM it (subject to the global allowlist when set).
|
||||
- **Allowlist** — DMs require the sender to be in **Allowed User IDs**. Differs from `Open` only when the list is empty: `Allowlist` then fails closed (no DMs).
|
||||
- **Pairing** — Same gate as `Allowlist`, but a non-listed sender receives a one-time pairing code instead of a flat rejection. Approve via `/approve <code>` and the applicant is auto-appended to **Allowed User IDs**. Requires **Your Platform User ID** to be set (the runtime checks the `/approve` sender against it) and a configured Redis backend.
|
||||
- **Disabled** — The bot ignores all DMs and only responds to group `@mentions`.
|
||||
|
||||
### Group Policy
|
||||
|
||||
Controls which QQ groups the bot will respond in.
|
||||
|
||||
- **Open (default)** — Respond to `@mentions` in any group the bot has been added to.
|
||||
- **Allowlist** — Respond only in groups whose ID is listed in **Allowed Channel IDs** (use the platform group ID from the OpenAPI event payload).
|
||||
- **Disabled** — Ignore all group traffic; the bot only responds to DMs.
|
||||
|
||||
See the [Channels overview](/docs/usage/channels/overview#direct-message-policy) for cross-platform details.
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
| Field | Required | Description |
|
||||
| ------------------- | -------- | --------------------------------------------------------------------------------------- |
|
||||
| **Application ID** | Yes | Your bot's App ID from QQ Open Platform |
|
||||
| **App Secret** | Yes | Your bot's App Secret from QQ Open Platform |
|
||||
| **Connection Mode** | No | `websocket` (default) or `webhook`. Choose based on your QQ Open Platform configuration |
|
||||
| Field | Required | Description |
|
||||
| ----------------------- | -------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| **Application ID** | Yes | Your bot's App ID from QQ Open Platform |
|
||||
| **App Secret** | Yes | Your bot's App Secret from QQ Open Platform |
|
||||
| **Connection Mode** | No | `websocket` (default) or `webhook`. Choose based on your QQ Open Platform configuration |
|
||||
| **Allowed User IDs** | No | Comma- or whitespace-separated QQ `tiny_id` values. Global gate — applies to DMs and group @mentions |
|
||||
| **DM Policy** | No | `open` (default), `allowlist`, `pairing`, or `disabled` — who is allowed to DM the bot |
|
||||
| **Group Policy** | No | `open` (default), `allowlist`, or `disabled` — where the bot responds to @mentions |
|
||||
| **Allowed Channel IDs** | No | Comma- or whitespace-separated QQ group IDs. Used when Group Policy is Allowlist |
|
||||
|
||||
## Limitations
|
||||
|
||||
|
||||
@@ -129,6 +129,22 @@ LobeHub ��持两种 QQ 机器人连接模式:
|
||||
|
||||
在 LobeHub 的渠道设置中点击 **测试连接** 以验证集成。然后打开 QQ,找到您的机器人并发送消息。机器人应通过您的 LobeHub 代理进行响应。
|
||||
|
||||
## 填写你的平台身份(推荐)
|
||||
|
||||
**高级设置**里有一个可选字段影响日常使用体验,建议一开始就填好。
|
||||
|
||||
### 你的平台用户 ID
|
||||
|
||||
也就是你自己的 QQ `tiny_id`(平台级用户标识符 ——**不是**对外可见的 QQ 号,两者不一定一致),用于:
|
||||
|
||||
- **配对审批** — 当 **私信策略** 为 **配对审批** 时为必填项,`/approve <code>` 是属主命令,runtime 会用这个 ID 校验发起人。
|
||||
- **AI 工具主动推送** — 让 Agent 能主动联系你(提醒、通知),把内部用户引用映射到你的 QQ 账号。
|
||||
- **防自锁** — 自动被 **允许的用户** 信任,给好友收紧 bot 时不会把自己挡在外面。
|
||||
|
||||
获取方式:先用任意消息私信 bot 一次,然后在 server log 里查看入站事件 payload 中的 `tiny_id` 字段(或在 OpenAPI 控制台读取,如果有)。把那串长数字 ID 粘贴到 LobeHub 高级设置的 **你的平台用户 ID** 字段。
|
||||
|
||||
> QQ 没有一个稳定的 "默认服务器" 概念可让 AI 工具默认指向,因此 QQ 渠道不展示 **默认服务器** 字段。
|
||||
|
||||
## 将机器人添加到群聊
|
||||
|
||||
要在 QQ 群聊中使用机器人:
|
||||
@@ -137,13 +153,42 @@ LobeHub ��持两种 QQ 机器人连接模式:
|
||||
2. 在消息中 @提及机器人以触发响应
|
||||
3. 机器人将在群聊中回复
|
||||
|
||||
## 接入策略
|
||||
|
||||
两个独立的策略控制入站消息,默认都为 **开放**。
|
||||
|
||||
### 允许的用户 ID(全局)
|
||||
|
||||
填入 **允许的用户 ID** 后,**所有**入站消息(私信和群聊 `@提及`)都必须来自列表里的 QQ `tiny_id`。留空则不做用户级过滤。`tiny_id` 来自 OpenAPI 事件 payload —— 用户对外可见的 QQ 号不一定就是平台 ID。
|
||||
|
||||
### 私信策略
|
||||
|
||||
- **开放 (Open)(默认)** — 任何与机器人有上下文交集的 QQ 用户都可以私信(若设置了全局白名单则受其约束)。
|
||||
- **白名单 (Allowlist)** — 私信需要发送者在 **允许的用户 ID** 里。和 `Open` 的差别在白名单为空时:`Allowlist` 模式**全部拒绝**。
|
||||
- **配对审批 (Pairing)** — 与 `Allowlist` 共享同一份名单,但非名单用户被拒后会收到一次性配对码,由你(属主)通过 `/approve <code>` 审批。审批通过的用户会被自动追加到 **允许的用户 ID**,后续 DM 直通。需先填 **你的平台用户 ID**(runtime 用它校验 `/approve` 发起人),并需要部署 Redis。
|
||||
- **禁用 (Disabled)** — 机器人忽略所有私信,只在群聊里被 `@提及` 时回复。
|
||||
|
||||
### 群组策略
|
||||
|
||||
控制机器人会在哪些 QQ 群里响应。
|
||||
|
||||
- **开放 (Open)(默认)** — 在机器人加入的任何群里被 `@` 都响应。
|
||||
- **白名单 (Allowlist)** — 只在 **允许的频道 ID** 列出的群里响应(使用 OpenAPI 事件 payload 里的群 ID)。
|
||||
- **禁用 (Disabled)** — 忽略所有群聊流量,机器人只接受私信。
|
||||
|
||||
跨平台细节见 [渠道概览](/docs/usage/channels/overview#direct-message-policy)。
|
||||
|
||||
## 配置参考
|
||||
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| -------------- | ---- | ----------------------------------------- |
|
||||
| **应用 ID** | 是 | 来自 QQ 开放平台的 App ID |
|
||||
| **App Secret** | 是 | 来自 QQ 开放平台的 App Secret |
|
||||
| **连接模式** | 否 | `websocket`(默认)或 `webhook`,根据 QQ 开放平台配置选择 |
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| -------------- | ---- | ---------------------------------------------------------- |
|
||||
| **应用 ID** | 是 | 来自 QQ 开放平台的 App ID |
|
||||
| **App Secret** | 是 | 来自 QQ 开放平台的 App Secret |
|
||||
| **连接模式** | 否 | `websocket`(默认)或 `webhook`,根据 QQ 开放平台配置选择 |
|
||||
| **允许的用户 ID** | 否 | 逗号或空格分隔的 QQ `tiny_id`。全局闸门 — 私信和群聊 @ 都受其约束 |
|
||||
| **私信策略** | 否 | `open`(默认)、`allowlist`、`pairing` 或 `disabled` — 控制谁可以私信机器人 |
|
||||
| **群组策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制机器人在哪些群中响应 |
|
||||
| **允许的频道 ID** | 否 | 逗号或空格分隔的 QQ 群 ID。仅在群组策略为白名单时使用 |
|
||||
|
||||
## 功能限制
|
||||
|
||||
|
||||
@@ -213,19 +213,69 @@ Use this method if your Slack app already has Event Subscriptions configured wit
|
||||
Also ensure you add the `commands` scope under **OAuth & Permissions** → **Bot Token Scopes**, and enable **Interactivity & Shortcuts** with the same Webhook URL as the Request URL.
|
||||
</Steps>
|
||||
|
||||
## Set Your Platform Identity (Recommended)
|
||||
|
||||
Two optional fields under **Advanced Settings** carry a lot of weight in day-to-day use — fill them in once and most surprises go away.
|
||||
|
||||
### Your Platform User ID
|
||||
|
||||
This is your own Slack member ID, used by:
|
||||
|
||||
- **Pairing approval** — required when **DM Policy** is set to **Pairing**, since `/approve <code>` is the owner's command and the runtime checks the sender against this ID.
|
||||
- **AI tools push** — lets the agent reach you proactively (reminders, notifications) by mapping its internal user reference to your Slack account.
|
||||
- **Anti-lockout** — auto-trusted by **Allowed Users**, so scoping the bot to teammates won't accidentally lock you out.
|
||||
|
||||
To get it: in Slack, click your avatar → **Profile**, then click the `⋮` overflow menu and choose **Copy member ID**. Member IDs start with `U`. Paste it into **Your Platform User ID** in LobeHub's Advanced Settings.
|
||||
|
||||
### Default Server
|
||||
|
||||
The Slack workspace (team) ID the bot's AI tools should default to when you ask it to "list channels", "send to #announcements", or anything else that needs a workspace context without naming one explicitly. Doesn't affect access control — that's **Group Policy**'s job.
|
||||
|
||||
To get it: open Slack in the browser; the URL contains the team ID (`https://app.slack.com/client/T01ABCDEF/...`) — copy the part starting with `T`. Paste it into **Default Server** in LobeHub's Advanced Settings.
|
||||
|
||||
## Access Policies
|
||||
|
||||
Two independent policies gate inbound traffic. Both default to **Open**.
|
||||
|
||||
### Allowed User IDs (global)
|
||||
|
||||
A populated **Allowed User IDs** field is a global gate — DMs *and* channel `@mentions` are restricted to listed Slack member IDs. Empty means "no user-level filter". Open a user's profile → click `⋮` → **Copy member ID** (starts with `U`).
|
||||
|
||||
### DM Policy
|
||||
|
||||
- **Open (default)** — Any workspace member can DM the bot (subject to the global allowlist when set).
|
||||
- **Allowlist** — DMs require the sender to be in **Allowed User IDs**. Differs from `Open` only when the list is empty: `Allowlist` then fails closed (no DMs).
|
||||
- **Pairing** — Same gate as `Allowlist`, but a non-listed sender receives a one-time pairing code instead of a flat rejection. Approve via `/approve <code>` and the applicant is auto-appended to **Allowed User IDs**. Requires **Your Platform User ID** to be set (the runtime checks the `/approve` sender against it) and a configured Redis backend.
|
||||
- **Disabled** — The bot ignores all DMs and only replies to channel `@mentions`.
|
||||
|
||||
### Group Policy
|
||||
|
||||
Controls which Slack channels the bot will respond in.
|
||||
|
||||
- **Open (default)** — Respond to `@mentions` in any channel the bot has been added to.
|
||||
- **Allowlist** — Respond only in channels whose ID is in **Allowed Channel IDs**. Open the channel's About panel to copy the channel ID (starts with `C`).
|
||||
- **Disabled** — Ignore all channel traffic; the bot only responds to DMs.
|
||||
|
||||
See the [Channels overview](/docs/usage/channels/overview#direct-message-policy) for cross-platform details.
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
| Field | Required | Description |
|
||||
| ------------------- | ---------------- | ----------------------------------------------------- |
|
||||
| **Application ID** | Yes | Your Slack app's ID |
|
||||
| **Bot Token** | Yes | Bot User OAuth Token (`xoxb-...`) |
|
||||
| **Signing Secret** | Yes | Used to verify requests from Slack |
|
||||
| **App-Level Token** | Socket Mode only | App-level token (`xapp-...`) for WebSocket connection |
|
||||
| **Connection Mode** | No | `websocket` or `webhook` (default: `webhook`) |
|
||||
| Field | Required | Description |
|
||||
| ----------------------- | ---------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| **Application ID** | Yes | Your Slack app's ID |
|
||||
| **Bot Token** | Yes | Bot User OAuth Token (`xoxb-...`) |
|
||||
| **Signing Secret** | Yes | Used to verify requests from Slack |
|
||||
| **App-Level Token** | Socket Mode only | App-level token (`xapp-...`) for WebSocket connection |
|
||||
| **Connection Mode** | No | `websocket` or `webhook` (default: `webhook`) |
|
||||
| **Allowed User IDs** | No | Comma- or whitespace-separated Slack member IDs. Global gate — applies to DMs and channel @mentions |
|
||||
| **DM Policy** | No | `open` (default), `allowlist`, `pairing`, or `disabled` — who is allowed to DM the bot |
|
||||
| **Group Policy** | No | `open` (default), `allowlist`, or `disabled` — where the bot responds to @mentions |
|
||||
| **Allowed Channel IDs** | No | Comma- or whitespace-separated Slack channel IDs (start with `C`). Used when Group Policy is Allowlist |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **DM shows "Sending messages to this app has been turned off":** In the Slack API Dashboard → **App Home** → **Show Tabs**, make sure **Messages Tab** is enabled and "Allow users to send Slash commands and messages from the messages tab" is checked. This is already enabled if you created the app using the Manifest template.
|
||||
- **Bot ignores DMs even though Slack is configured correctly:** Open **Advanced Settings** in LobeHub and check **DM Policy**. If it is `Disabled`, switch to `Open` or `Allowlist`. If it is `Allowlist`, confirm the sender's Slack member ID is listed in **Allowed User IDs**.
|
||||
- **Bot not responding:** Confirm the bot has been invited to the channel. For Socket Mode, ensure the App-Level Token is correct and Socket Mode is enabled in Slack app settings.
|
||||
- **Test Connection failed:** Double-check the Application ID and Bot Token. Ensure the app is installed to the workspace.
|
||||
- **Webhook verification failed (Webhook mode):** Make sure the Signing Secret matches and the Webhook URL is correct.
|
||||
|
||||
@@ -210,19 +210,69 @@ LobeHub 支持两种 Slack 连接模式:
|
||||
同时确保在 **OAuth & Permissions** → **Bot Token Scopes** 中添加 `commands` 权限,并在 **Interactivity & Shortcuts** 中启用 Interactivity,将 Request URL 设为相同的 Webhook URL。
|
||||
</Steps>
|
||||
|
||||
## 填写你的平台身份(推荐)
|
||||
|
||||
**高级设置**里有两个可选字段,影响着日常使用体验,建议一开始就填好。
|
||||
|
||||
### 你的平台用户 ID
|
||||
|
||||
也就是你自己的 Slack member ID,用于:
|
||||
|
||||
- **配对审批** — 当 **私信策略** 为 **配对审批** 时为必填项,`/approve <code>` 是属主命令,runtime 会用这个 ID 校验发起人。
|
||||
- **AI 工具主动推送** — 让 Agent 能主动联系你(提醒、通知),把内部用户引用映射到你的 Slack 账号。
|
||||
- **防自锁** — 自动被 **允许的用户** 信任,给同事收紧 bot 时不会把自己挡在外面。
|
||||
|
||||
获取方式:在 Slack 中点击你的头像 → **个人资料**,点击 `⋮` 溢出菜单,选 **复制 member ID**。member ID 以 `U` 开头。粘贴到 LobeHub 高级设置的 **你的平台用户 ID** 字段。
|
||||
|
||||
### 默认服务器
|
||||
|
||||
Slack workspace(team)ID。当你让 bot 做 "列出频道"、"发送到 #announcements" 这类需要 workspace 上下文但没指明哪个的事时,AI 工具会默认用这个。和访问控制无关 —— 那是 **群组策略** 的活。
|
||||
|
||||
获取方式:用浏览器打开 Slack,URL 里就有 team ID(`https://app.slack.com/client/T01ABCDEF/...`)—— 复制以 `T` 开头那段。粘贴到 LobeHub 高级设置的 **默认服务器** 字段。
|
||||
|
||||
## 接入策略
|
||||
|
||||
两个独立的策略控制入站消息,默认都为 **开放**。
|
||||
|
||||
### 允许的用户 ID(全局)
|
||||
|
||||
填入 **允许的用户 ID** 后,**所有**入站消息(私信和频道 `@提及`)都必须来自列表里的 Slack 成员 ID。留空则不做用户级过滤。打开用户资料 → 点击 `⋮` → **复制成员 ID**(以 `U` 开头)。
|
||||
|
||||
### 私信策略
|
||||
|
||||
- **开放 (Open)(默认)** — workspace 内任何成员都可以私信机器人(若设置了全局白名单则受其约束)。
|
||||
- **白名单 (Allowlist)** — 私信需要发送者在 **允许的用户 ID** 里。和 `Open` 的差别在白名单为空时:`Allowlist` 模式**全部拒绝**。
|
||||
- **配对审批 (Pairing)** — 与 `Allowlist` 共享同一份名单,但非名单用户被拒后会收到一次性配对码,由你(属主)通过 `/approve <code>` 审批。审批通过的用户会被自动追加到 **允许的用户 ID**,后续 DM 直通。需先填 **你的平台用户 ID**(runtime 用它校验 `/approve` 发起人),并需要部署 Redis。
|
||||
- **禁用 (Disabled)** — 机器人忽略所有私信,只在频道里被 `@提及` 时回复。
|
||||
|
||||
### 群组策略
|
||||
|
||||
控制机器人会在哪些 Slack 频道里响应。
|
||||
|
||||
- **开放 (Open)(默认)** — 在机器人加入的任何频道里被 `@` 都响应。
|
||||
- **白名单 (Allowlist)** — 只在 **允许的频道 ID** 列出的频道里响应。打开频道详情面板,底部能看到频道 ID(以 `C` 开头)。
|
||||
- **禁用 (Disabled)** — 忽略所有频道流量,机器人只接受私信。
|
||||
|
||||
跨平台细节见 [渠道概览](/docs/usage/channels/overview#direct-message-policy)。
|
||||
|
||||
## 配置参考
|
||||
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| -------------- | ------------- | -------------------------------------- |
|
||||
| **应用 ID** | 是 | 您的 Slack 应用 ID |
|
||||
| **Bot Token** | 是 | Bot User OAuth Token(`xoxb-...`) |
|
||||
| **签名密钥** | 是 | 用于验证来自 Slack 的请求 |
|
||||
| **应用级别 Token** | 仅 Socket Mode | 应用级别 Token(`xapp-...`),用于 WebSocket 连接 |
|
||||
| **连接模式** | 否 | `websocket` 或 `webhook`(默认:`webhook`) |
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| -------------- | ------------- | ---------------------------------------------------------- |
|
||||
| **应用 ID** | 是 | 您的 Slack 应用 ID |
|
||||
| **Bot Token** | 是 | Bot User OAuth Token(`xoxb-...`) |
|
||||
| **签名密钥** | 是 | 用于验证来自 Slack 的请求 |
|
||||
| **应用级别 Token** | 仅 Socket Mode | 应用级别 Token(`xapp-...`),用于 WebSocket 连接 |
|
||||
| **连接模式** | 否 | `websocket` 或 `webhook`(默认:`webhook`) |
|
||||
| **允许的用户 ID** | 否 | 逗号或空格分隔的 Slack 成员 ID。全局闸门 — 私信和频道 @ 都受其约束 |
|
||||
| **私信策略** | 否 | `open`(默认)、`allowlist`、`pairing` 或 `disabled` — 控制谁可以私信机器人 |
|
||||
| **群组策略** | 否 | `open`(默认)、`allowlist` 或 `disabled` — 控制机器人在哪些频道中响应 |
|
||||
| **允许的频道 ID** | 否 | 逗号或空格分隔的 Slack 频道 ID(以 `C` 开头)。仅在群组策略为白名单时使用 |
|
||||
|
||||
## 故障排除
|
||||
|
||||
- **私信显示 "Sending messages to this app has been turned off":** 在 Slack API 控制台 → **App Home** → **Show Tabs** 中,确保 **Messages Tab** 已启用,并勾选 "Allow users to send Slash commands and messages from the messages tab"。如果使用 Manifest 模板创建应用则默认已开启。
|
||||
- **Slack 配置正常但机器人不回私信:** 在 LobeHub 的 **高级设置** 里检查 **私信策略**。如果是 `Disabled`,改成 `Open` 或 `Allowlist`;如果是 `Allowlist`,确认发起方的 Slack 成员 ID 已加入 **允许的用户 ID**。
|
||||
- **机器人未响应:** 确认机器人已被邀请到频道。Socket Mode 下请确保应用级别 Token 正确且 Socket Mode 已在 Slack 应用设置中启用。
|
||||
- **测试连接失败:** 仔细检查应用 ID 和 Bot Token 是否正确。确保应用已安装到工作区。
|
||||
- **Webhook 验证失败(Webhook 模式):** 确保签名密钥匹配且 Webhook URL 正确。
|
||||
|
||||
@@ -79,6 +79,22 @@ Click **Test Connection** in LobeHub's channel settings to verify the integratio
|
||||
|
||||

|
||||
|
||||
## Set Your Platform Identity (Recommended)
|
||||
|
||||
One optional field under **Advanced Settings** carries a lot of weight in day-to-day use — fill it in once and most surprises go away.
|
||||
|
||||
### Your Platform User ID
|
||||
|
||||
This is your own Telegram numeric user ID, used by:
|
||||
|
||||
- **Pairing approval** — required when **DM Policy** is set to **Pairing**, since `/approve <code>` is the owner's command and the runtime checks the sender against this ID.
|
||||
- **AI tools push** — lets the agent reach you proactively (reminders, notifications) by mapping its internal user reference to your Telegram account.
|
||||
- **Anti-lockout** — auto-trusted by **Allowed Users**, so scoping the bot to friends won't accidentally lock you out.
|
||||
|
||||
To get it: open Telegram, message [@userinfobot](https://t.me/userinfobot), and it will reply with your numeric user ID. Paste it into **Your Platform User ID** in LobeHub's Advanced Settings.
|
||||
|
||||
> Telegram doesn't have a "default server" concept (each chat is its own surface), so the **Default Server** field is not exposed for Telegram channels.
|
||||
|
||||
## Adding the Bot to Group Chats
|
||||
|
||||
To use the bot in Telegram groups:
|
||||
@@ -93,16 +109,46 @@ To use the bot in Telegram groups:
|
||||
**About Group Privacy Mode:** Telegram bots have privacy mode enabled by default, which means they only receive messages that @mention the bot, reply to the bot, or contain /commands. If you change the privacy mode setting after creating the bot, you **must remove and re-add the bot to the group** for the new setting to take effect in that group.
|
||||
</Callout>
|
||||
|
||||
## Access Policies
|
||||
|
||||
Two independent policies gate inbound traffic. Both default to **Open**.
|
||||
|
||||
### Allowed User IDs (global)
|
||||
|
||||
A populated **Allowed User IDs** field acts as a global gate — DMs *and* group `@mentions` are restricted to listed Telegram numeric user IDs. Empty means "no user-level filter". Grab a user's numeric ID via [@userinfobot](https://t.me/userinfobot), or read the `from.id` field from the bot's incoming update.
|
||||
|
||||
### DM Policy
|
||||
|
||||
- **Open (default)** — Anyone on Telegram can DM the bot (subject to the global allowlist when set).
|
||||
- **Allowlist** — DMs require the sender to be in **Allowed User IDs**. Differs from `Open` only when the list is empty: `Allowlist` then fails closed (no DMs).
|
||||
- **Pairing** — Same gate as `Allowlist`, but a non-listed sender receives a one-time pairing code instead of a flat rejection. Approve via `/approve <code>` and the applicant is auto-appended to **Allowed User IDs**. Requires **Your Platform User ID** to be set (the runtime checks the `/approve` sender against it) and a configured Redis backend.
|
||||
- **Disabled** — The bot ignores all DMs and only responds to group `@mentions`.
|
||||
|
||||
### Group Policy
|
||||
|
||||
Controls which Telegram groups / channels the bot will respond in.
|
||||
|
||||
- **Open (default)** — Respond in any group / channel the bot has been added to.
|
||||
- **Allowlist** — Respond only in chats whose ID is listed in **Allowed Channel IDs**. Forward a message from the chat to [@userinfobot](https://t.me/userinfobot) to grab the chat ID (group IDs are negative).
|
||||
- **Disabled** — Ignore all group traffic; the bot only responds to DMs.
|
||||
|
||||
See the [Channels overview](/docs/usage/channels/overview#direct-message-policy) for cross-platform details.
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
| Field | Required | Description |
|
||||
| ------------------------ | -------- | ---------------------------------------------- |
|
||||
| **Bot Token** | Yes | API token from BotFather |
|
||||
| **Bot User ID** | Auto | Automatically derived from the bot token |
|
||||
| **Webhook Secret Token** | No | Optional secret for verifying webhook requests |
|
||||
| Field | Required | Description |
|
||||
| ------------------------ | -------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| **Bot Token** | Yes | API token from BotFather |
|
||||
| **Bot User ID** | Auto | Automatically derived from the bot token |
|
||||
| **Webhook Secret Token** | No | Optional secret for verifying webhook requests |
|
||||
| **Allowed User IDs** | No | Comma- or whitespace-separated Telegram numeric user IDs. Global gate — applies to DMs and group @mentions |
|
||||
| **DM Policy** | No | `open` (default), `allowlist`, `pairing`, or `disabled` — who is allowed to DM the bot |
|
||||
| **Group Policy** | No | `open` (default), `allowlist`, or `disabled` — where the bot responds in groups |
|
||||
| **Allowed Channel IDs** | No | Comma- or whitespace-separated Telegram chat IDs (group IDs are negative). Used when Group Policy is Allowlist |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Bot not responding:** Verify the bot token is correct and the configuration is saved. Click **Test Connection** to diagnose.
|
||||
- **Bot ignores DMs:** Open **Advanced Settings** and check **DM Policy**. If it is `Disabled`, switch to `Open` or `Allowlist`. If it is `Allowlist`, confirm the sender's Telegram user ID (from [@userinfobot](https://t.me/userinfobot)) is listed in **Allowed User IDs**.
|
||||
- **Webhook registration failed:** Ensure your LobeHub subscription is active. Telegram requires HTTPS endpoints for webhooks, which LobeHub provides automatically.
|
||||
- **Group chat issues:** Make sure the bot has been added to the group and has permission to read messages. Mention the bot with `@username` to trigger a response. If the bot doesn't respond in a group, try removing the bot from the group and re-adding it — Telegram's privacy mode changes require re-joining the group to take effect.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user