Compare commits

..

1 Commits

Author SHA1 Message Date
AmAzing- 32f135a5e5 Fix message edit send behavior 2026-04-23 13:20:20 +08:00
967 changed files with 7179 additions and 56936 deletions
-209
View File
@@ -1,209 +0,0 @@
---
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).
+35 -60
View File
@@ -8,20 +8,16 @@ 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 <generationId> <asyncTaskId> # Wait & download generation result
├── status <generationId> <asyncTaskId> # 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 <genId> <taskId> # Wait & download generation result
├── status <genId> <taskId> # 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>`
@@ -58,7 +54,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 + async task ID for tracking.
Generate images from text prompt. This is an async operation — the command submits the task and returns a generation ID + task ID for tracking.
**Source**: `apps/cli/src/commands/generate/image.ts`
@@ -84,22 +80,17 @@ 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 7ad0eb13-xxxx-xxxx-xxxx-xxxxxxxxxxxx
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
This is the asyncTaskId — use this for status/download
Generation gen_xxx → Task <taskId>
Use "lh generate status <generationId> <asyncTaskId>" to check progress.
Use "lh generate status <generationId> <taskId>" to check progress.
```
**Typical workflow**:
```bash
# 1. Submit generation — note down BOTH IDs from the output
# Generate image, then wait & download
lh gen image "A cute cat"
# 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
lh gen download <generationId> <taskId> -o cat.png
```
---
@@ -111,7 +102,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 |
@@ -131,26 +122,9 @@ lh gen video "A cat playing piano" -m <model> -p <provider> [options]
```
✓ Video generation started
Batch ID: gb_xxx
Generation gen_xxx → Task 7ad0eb13-xxxx-xxxx-xxxx-xxxxxxxxxxxx
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
This is the asyncTaskId — use this for status/download
Generation gen_xxx → Task <taskId>
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
Use "lh generate status <generationId> <taskId>" to check progress.
```
---
@@ -179,18 +153,15 @@ lh gen asr recording.wav [options]
---
## `lh generate download <generationId> <asyncTaskId>`
## `lh generate download <generationId> <taskId>`
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> <asyncTaskId> [-o output.png]
lh gen download gen_xxx 7ad0eb13-xxxx-xxxx-xxxx-xxxxxxxxxxxx -o ~/Desktop/result.mp4 --timeout 600
lh gen download <generationId> <taskId> [-o output.png]
lh gen download gen_xxx task_xxx -o ~/Desktop/result.mp4 --timeout 600
```
| Option | Description | Default |
@@ -204,21 +175,30 @@ lh gen download gen_xxx 7ad0eb13-xxxx-xxxx-xxxx-xxxxxxxxxxxx -o ~/Desktop/result
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 / wrong ID: displays a clear message pointing to the correct ID format
4. On error: displays error message and exits
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> <asyncTaskId>`
## `lh generate status <generationId> <taskId>`
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> <asyncTaskId> [--json]
lh gen status gen_xxx 7ad0eb13-xxxx-xxxx-xxxx-xxxxxxxxxxxx
lh gen status <generationId> <taskId> [--json]
```
| Option | Description |
@@ -255,17 +235,12 @@ 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,51 +1,58 @@
---
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.'
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.'
---
# Review Checklist
# Code Review Guide
## Correctness
## 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
- 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:
@@ -54,3 +61,13 @@ 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."
@@ -1,83 +0,0 @@
---
name: heterogeneous-agent
description: Guide for implementing and debugging LobeHub heterogeneous agent integrations such as Claude Code, Codex, and future external CLI agents. Use when working on adapter event mapping, Electron IPC transport, renderer persistence, tool-call chaining, subagent threads, resume/session handling, or regressions like mixed multi-tool messages, broken step boundaries, stuck tool loading, and orphan tool messages. Triggers on 'heterogeneous agent', 'hetero agent', '异构 agent', 'claude code adapter', 'codex adapter', 'external agent CLI', '孤立 tool 消息', 'raw Codex trace', or adapter/executor bugs.
---
# Heterogeneous Agent Development
Use this skill when the bug or feature lives in the external CLI agent pipeline, not the normal server-side agent runtime.
## Use This Skill For
- Adding or changing a driver under `apps/desktop/src/main/modules/heterogeneousAgent/drivers/`
- Editing an adapter under `packages/heterogeneous-agents/src/adapters/`
- Debugging `heteroAgentRawLine` transport, `window.__HETERO_AGENT_TRACE`, or `executeHeterogeneousAgent`
- Fixing Claude Code stream-json bugs such as duplicate partial/full chunks, broken `message.id` boundaries, missing `tool_result`, TodoWrite state drift, or subagent thread routing
- Fixing Codex JSONL bugs such as mixed multi-tool messages, broken turn boundaries, or missing tool-result mapping
- Fixing step-boundary, tool persistence, subagent thread, or resume bugs in Claude Code / Codex flows
- Reproducing multi-tool mixing, orphan tool messages, or stuck tool-result loading
## Pipeline Map
1. CLI raw stdout / JSONL
2. Electron main spawns the CLI and broadcasts `heteroAgentRawLine`
3. Adapter maps raw provider events into `HeterogeneousAgentEvent`
4. `executeHeterogeneousAgent` persists assistant/tool messages and forwards stream events
5. `createGatewayEventHandler` hydrates the UI
6. Only after this path looks correct should you move on to `agent-tracing` or context-engine debugging
## Read These Files First
- `apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts`
- `apps/desktop/src/main/modules/heterogeneousAgent/drivers/claudeCode.ts`
- `apps/desktop/src/main/modules/heterogeneousAgent/drivers/codex.ts`
- `packages/heterogeneous-agents/src/adapters/claudeCode.ts`
- `packages/heterogeneous-agents/src/adapters/codex.ts`
- `src/store/chat/slices/aiChat/actions/heterogeneousAgentExecutor.ts`
- `src/store/chat/slices/aiChat/actions/__tests__/heterogeneousAgentExecutor.test.ts`
## Default Debug Order
1. Prove whether the raw CLI output is correct before touching UI code.
2. If raw output is correct, compare it with adapter output. In dev, `executeHeterogeneousAgent` exposes `window.__HETERO_AGENT_TRACE`.
3. If adapted events look correct, inspect `persistToolBatch`, `persistToolResult`, step transitions, and subagent routing.
4. Turn the repro into a focused test before fixing.
5. Only after the transport/adapter/executor path looks sound should you debug later-stage message processing.
## Critical Invariants
- One raw tool item must map to one stable `ToolCallPayload.id`.
- A new main-agent step must emit a boundary signal before events are forwarded to the new assistant.
- In Claude Code, multiple assistant events with the same `message.id` are one turn, not multiple turns.
- In Claude Code, `tool_result` lives in `type: 'user'` events, not assistant events.
- In Claude Code partial mode, `message_delta.usage` is authoritative; do not trust echoed usage on every assistant block.
- `persistToolBatch` must pre-register assistant `tools[]` before creating tool messages.
- Every tool message must keep `parentId` equal to the owning assistant and `tool_call_id` equal to the tool id.
- `tool_result` must resolve an existing `toolMsgIdByCallId`.
- Subagent chunks must stay in thread scope and must not be forwarded into the main assistant stream.
- Never clear the global `toolMsgIdByCallId` map at main step boundaries.
## Common Bug Patterns
- Claude Code duplicates text or thinking:
check whether partial deltas and the later full assistant block are both being emitted.
- Claude Code opens too many assistant messages:
check whether the adapter is cutting steps on every assistant event instead of only on `message.id` changes.
- Claude Code tool results never land:
check whether `type: 'user'` `tool_result` blocks are being ignored because the code only inspects assistant events.
- Claude Code TodoWrite cards look stale:
check whether synthesized `pluginState.todos` is being attached at tool-result time.
- Claude Code subagent transcript leaks into the main bubble:
check `parent_tool_use_id` handling and whether subagent chunks are being forwarded to the main gateway handler.
- Multiple Codex tools collapse into one assistant message:
first check whether the adapter emits a usable step boundary such as `newStep` or an equivalent turn-change signal.
- Orphan tool messages:
first check step-transition ordering and whether `persistToolBatch` Phase 1 ran before tool message creation.
- Tool bubble stays loading:
look for `tool_result for unknown toolCallId` and missing `result_msg_id` backfill.
- Subagent tools show up in the main bubble:
check for subagent chunks reaching the main gateway handler.
## References
- For commands, trace capture, invariants, and focused test commands, read [references/debug-workflow.md](./references/debug-workflow.md).
@@ -1,246 +0,0 @@
# Heterogeneous Agent Debug Workflow
## Contents
1. Pipeline map
2. Capture raw CLI traces first
3. Compare raw and adapted events
4. Check step boundaries before persistence
5. Check tool persistence invariants
6. Focused tests
7. Repro-to-fix workflow
## 1. Pipeline Map
```
CLI raw stdout
-> HeterogeneousAgentCtr (Electron main)
-> heteroAgentRawLine broadcast
-> createAdapter(...)
-> executeHeterogeneousAgent(...)
-> persistToolBatch / persistToolResult
-> createGatewayEventHandler(...)
-> UI hydration
```
Start at the leftmost broken layer. Do not jump straight to UI rendering unless raw and adapted events already look correct.
## 2. Capture Raw CLI Traces First
### Codex raw JSONL
Use a read-only prompt and save traces under the repo-local scratch directory `.heerogeneous-tracing/`.
```bash
ts=$(date +%Y%m%d-%H%M%S)
out=".heerogeneous-tracing/codex-${ts}.jsonl"
last=".heerogeneous-tracing/codex-${ts}.last.txt"
cat << 'EOF' | codex exec --json --skip-git-repo-check --sandbox read-only -C "$PWD" -o "$last" - > "$out"
You are being run only to collect a raw Codex JSON event trace.
Do not modify any files.
Use at least 4 separate shell tool invocations, one invocation per command.
Run a short sequence of read-only repo checks and then reply with a one-sentence summary.
EOF
```
What to look for in the JSONL:
- `thread.started`
- `turn.started`
- `item.started` / `item.completed`
- `item.type === 'command_execution'`
- `item.type === 'agent_message'`
- `turn.completed`
If raw Codex already merges tools into one item, the adapter is innocent. If raw Codex emits independent items but UI collapses them, the bug is downstream.
If the repo already contains useful traces under `.heerogeneous-tracing/`, inspect them before reproducing.
### Claude Code raw NDJSON
Mirror the arguments from `apps/desktop/src/main/modules/heterogeneousAgent/drivers/claudeCode.ts`.
- `-p`
- `--input-format stream-json`
- `--output-format stream-json`
- `--verbose`
- `--include-partial-messages`
- `--permission-mode bypassPermissions`
You can capture a local raw trace like this:
```bash
ts=$(date +%Y%m%d-%H%M%S)
out=".heerogeneous-tracing/claude-${ts}.ndjson"
cat << 'EOF' | claude -p \
--input-format stream-json \
--output-format stream-json \
--verbose \
--include-partial-messages \
--permission-mode bypassPermissions \
> "$out"
{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Do a few read-only repo checks, use several tool calls, and then summarize briefly."}]}}
EOF
```
What to look for in Claude Code raw traces:
- `type: 'system', subtype: 'init'`
- `type: 'assistant'` blocks for `thinking`, `tool_use`, and `text`
- `type: 'user'` blocks containing `tool_result`
- `type: 'stream_event'` with `message_start`, `content_block_delta`, and `message_delta`
- `type: 'result'`
- `type: 'rate_limit_event'`
Important Claude Code semantics:
- Each content block often arrives as its own assistant event.
- Multiple assistant events can share the same `message.id`; that is still one turn.
- `message.id` change is the main-step boundary.
- Partial deltas arrive before the later full assistant block.
- `message_delta.usage` is the authoritative per-turn usage.
- Subagent events are tagged with `parent_tool_use_id`.
If the repo already contains useful references, inspect these first:
- `.heerogeneous-tracing/cc-monitor-real-trace.jsonl`
- `.heerogeneous-tracing/cc-stream-chain-reference.md`
If you only need boundary semantics or tool persistence behavior, prefer existing adapter tests under:
- `packages/heterogeneous-agents/src/adapters/claudeCode.test.ts`
- `packages/heterogeneous-agents/src/adapters/claudeCode.e2e.test.ts`
## 3. Compare Raw And Adapted Events
In dev builds, `executeHeterogeneousAgent` stores raw lines plus adapted events on:
- `window.__HETERO_AGENT_TRACE`
Use that trace to compare:
- raw `item.started` / `item.completed`
- adapted `stream_chunk { chunkType: 'tools_calling' }`
- adapted `tool_result`
- adapted `tool_end`
For Codex, the usual mapping is:
- raw `item.started(command_execution)` -> `tools_calling` + `tool_start`
- raw `item.completed(command_execution)` -> `tool_result` + `tool_end`
- raw `item.completed(agent_message)` -> `stream_chunk(text)`
If the raw trace is right but adapted events are wrong, fix the adapter before touching persistence.
## 4. Check Step Boundaries Before Persistence
This is the first thing to verify for "mixed tools in one assistant" bugs.
### Claude Code
Claude Code step boundaries are keyed off assistant `message.id` changes. The adapter should emit:
- `stream_end`
- `stream_start { newStep: true }`
Also verify these Claude-specific invariants:
- the first assistant after init does not open a new step
- repeated assistant events with the same `message.id` do not open a new step
- partial `content_block_delta` text/thinking does not get duplicated by the later full assistant event
- `tool_result` from `type: 'user'` updates the matching tool row
- `parent_tool_use_id` creates thread-scoped subagent chunks instead of main-stream chunks
- TodoWrite `tool_use.input` is converted into synthesized `pluginState.todos` on `tool_result`
Good references:
- `packages/heterogeneous-agents/src/adapters/claudeCode.ts`
- `packages/heterogeneous-agents/src/adapters/claudeCode.test.ts`
### Codex
Codex raw traces usually provide turn-level boundaries through:
- `turn.started`
- `turn.completed`
The executor only cuts a new assistant message when it receives a step-boundary signal it understands. If the adapter emits `stream_start` without `newStep`, multiple Codex tools and text chunks can accumulate under the same assistant longer than intended.
Relevant files:
- `packages/heterogeneous-agents/src/adapters/codex.ts`
- `src/store/chat/slices/aiChat/actions/heterogeneousAgentExecutor.ts`
## 5. Check Tool Persistence Invariants
Read `persistToolBatch` and `persistToolResult` before changing UI code.
### `persistToolBatch`
The expected order is:
1. Pre-register assistant `tools[]`
2. Create `role: 'tool'` messages
3. Backfill `result_msg_id` onto assistant `tools[]`
If tool rows are created before assistant `tools[]` are registered, orphan tool messages are likely.
### `persistToolResult`
`tool_result` must resolve the tool row through `toolMsgIdByCallId`.
Warning signs:
- `tool_result for unknown toolCallId`
- tool rows with empty content forever
- missing `result_msg_id`
For Claude Code, remember that tool results originate from raw `type: 'user'` events.
### Main vs subagent scope
- Main-agent tool state is per-step.
- `toolMsgIdByCallId` is global across main and subagent scopes.
- Subagent chunks must not be forwarded into the main gateway handler.
If subagent events leak to the main handler, the main bubble can inherit the wrong `tools[]` and content.
## 6. Focused Tests
Run the smallest useful test set first.
```bash
bunx vitest run --silent='passed-only' 'packages/heterogeneous-agents/src/adapters/codex.test.ts'
bunx vitest run --silent='passed-only' 'packages/heterogeneous-agents/src/adapters/claudeCode.test.ts'
bunx vitest run --silent='passed-only' 'src/store/chat/slices/aiChat/actions/__tests__/heterogeneousAgentExecutor.test.ts'
```
Especially useful places:
- `packages/heterogeneous-agents/src/adapters/codex.test.ts`
- `packages/heterogeneous-agents/src/adapters/claudeCode.test.ts`
- `src/store/chat/slices/aiChat/actions/__tests__/heterogeneousAgentExecutor.test.ts`
Claude Code-specific assertions worth adding when fixing bugs:
- same `message.id` does not emit `newStep`
- changed `message.id` does emit `stream_end` plus `stream_start { newStep: true }`
- partial text/thinking is emitted once
- `tool_result` from `user` events reaches the right tool row
- subagent chunks carry `subagent.parentToolCallId`
- TodoWrite result synthesizes `pluginState.todos`
When the bug comes from a real trace, distill it into the closest existing test file instead of relying on manual UI-only repros.
## 7. Repro-To-Fix Workflow
1. Capture a raw trace and save it under `.heerogeneous-tracing/`.
2. Confirm whether the bug appears in raw events, adapted events, or persistence.
3. Add or update the narrowest failing test near the broken layer.
4. Fix the smallest layer that can explain the symptom.
5. Re-run focused tests.
6. Only then do an Electron smoke test with the `local-testing` skill if UI confirmation is still needed.
Do not start with a broad Electron repro if a raw trace or adapter test can prove the fault zone faster.
+5 -25
View File
@@ -238,34 +238,13 @@ 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 merged PRs> · <N resolved issues> · <N contributors>
**Since <Previous Version>:** <N commits> · <N merged PRs> · <N resolved issues> · <N contributors>
> <One release thesis sentence: what this release unlocks in practice.>
@@ -317,11 +296,12 @@ If a new contributor appears who is not on this list, treat them as community by
## 👥 Contributors
Huge thanks to **<N contributors>** who shipped **<N merged PRs>** this cycle.
**<N merged PRs>** from **<N contributors>** across **<N commits>**.
@<community-handle> · @<community-handle> · @<team-handle> · @<team-handle>
### Community Contributors
Plus @lobehubbot and renovate[bot] for maintenance.
- @<username> - <notable contribution area>
- @<username> - <notable contribution area>
---
@@ -1,21 +0,0 @@
# 🚀 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,10 +59,7 @@ git push -u origin hotfix/v{version}-{short-hash}
2. **Create PR to main** with a gitmoji prefix title (e.g. `🐛 fix: description`)
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.
3. **After merge**: auto-tag-release detects `hotfix/*` branch → auto patch +1.
### Script
+10 -7
View File
@@ -2,13 +2,14 @@
## Quick Reference by Name
- **@arvinxx**: General/uncategorized issues (default assignee), priority:high issues, tool calling, mcp, database
- **@arvinxx**: Last resort only, mention for priority:high issues, tool calling, mcp, database
- **@canisminor1990**: Design, UI components, editor, markdown rendering
- **@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
- **@tjx666**: Image/video generation, vision, cloud version, documentation, TTS, auth, login/register, database
- **@ONLY-yours**: Performance, streaming, settings, general bugs, web platform, marketplace, agent builder, schedule task
- **@Innei**: Knowledge base, files (KB-related), group chat, Electron, desktop client, build system
- **@nekomeowww**: Memory, backend, deployment, DevOps, database
- **@sudongyuer**: Mobile app (React Native)
- **@sxjeru**: Model providers and configuration
- **@rdmclin2**: Team workspace, IM and bot integration
- **@tcmonster**: Subscription, refund, recharge, business cooperation
@@ -20,7 +21,7 @@ Quick reference for assigning issues based on labels.
| Label | Owner | Notes |
| ---------------- | ------- | -------------------------------------------- |
| All `provider:*` | @tjx666 | Model configuration and provider integration |
| All `provider:*` | @sxjeru | Model configuration and provider integration |
### Platform Labels (platform:\*)
@@ -99,10 +100,11 @@ 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:*`@tjx666
3. **Provider owner** - e.g., `provider:*`@sxjeru
4. **Component owner** - e.g., 💄 Design → @canisminor1990
5. **Infrastructure owner** - e.g., `deployment:*`@nekomeowww
6. **Default assignee** - @arvinxx for general/uncategorized issues
6. **General maintainer** - @ONLY-yours for general bugs/issues
7. **Last resort** - @arvinxx (only if no clear owner)
### Special Cases
@@ -119,7 +121,8 @@ Quick reference for assigning issues based on labels.
**No clear owner:**
- Assign to @arvinxx for general issues
- Assign to @ONLY-yours for general issues
- Only mention @arvinxx if critical and truly unclear
## Comment Templates
-1
View File
@@ -146,5 +146,4 @@ apps/desktop/resources/cli-package.json
# Superpowers plugin brainstorm/spec outputs (local only; do not commit)
.superpowers/
docs/superpowers/
.heerogeneous-tracing
+60 -88
View File
@@ -1,128 +1,100 @@
# LobeHub Development Guidelines
Guidelines for using AI coding agents in this LobeHub repository.
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).
## 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
- **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)
## Project Structure
## Directory Structure
```plaintext
lobehub/
├── apps/
│ ├── desktop/ # Electron desktop app
│ ├── cli/ # LobeHub CLI
│ └── device-gateway/ # Device gateway service
├── 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
│ │ ├── (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
│ ├── app/ # Next.js app router
│ ├── spa/ # SPA entry points (entry.*.tsx) and router config
│ ├── routes/ # SPA page components (roots)
├── features/ # Business components by domain
│ ├── store/ # Zustand stores
│ ├── services/ # Client services
│ ├── server/ # Server services and routers
│ └── ...
├── .agents/skills/ # AI development skills
└── 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`, `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.
## Development Workflow
### 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>`
- 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.
### Package Management
- `pnpm` for dependency management
- `bun` to run npm scripts
- `bunx` for executable npm packages
- Use `pnpm` as the primary package manager
- Use `bun` to run npm scripts
- Use `bunx` to run executable npm packages
### Testing
### Code Style Guidelines
#### TypeScript
- Prefer interfaces over types for object shapes
### Testing Strategy
```bash
# Run specific test (NEVER run `bun run test` - takes ~10 minutes)
bunx vitest run --silent='passed-only' '[file-path]'
# Web tests
bunx vitest run --silent='passed-only' '[file-path-pattern]'
# Database package
cd packages/database && bunx vitest run --silent='passed-only' '[file]'
# Package tests (e.g., database)
cd packages/[package-name] && bunx vitest run --silent='passed-only' '[file-path-pattern]'
```
- 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
**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
### i18n
- 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).
- **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
### Code Review
## SPA Routes and Features
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.
- **`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.
-25
View File
@@ -2,31 +2,6 @@
# 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">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#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>
+123 -1
View File
@@ -1 +1,123 @@
@AGENTS.md
# 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/`.
+1 -1
View File
@@ -1,6 +1,6 @@
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
.\" Manual command details come from the Commander command tree.
.TH LH 1 "" "@lobehub/cli 0.0.9" "User Commands"
.TH LH 1 "" "@lobehub/cli 0.0.8" "User Commands"
.SH NAME
lh \- LobeHub CLI \- manage and connect to LobeHub services
.SH SYNOPSIS
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lobehub/cli",
"version": "0.0.9",
"version": "0.0.8",
"type": "module",
"bin": {
"lh": "./dist/index.js",
+3 -5
View File
@@ -7,14 +7,12 @@ 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(
bufferSeconds = 60,
): Promise<{ credentials: StoredCredentials } | null> {
export async function getValidToken(): Promise<{ credentials: StoredCredentials } | null> {
const credentials = loadCredentials();
if (!credentials) return null;
// Check if token is still valid (with configurable buffer)
if (credentials.expiresAt && Date.now() / 1000 < credentials.expiresAt - bufferSeconds) {
// Check if token is still valid (with 60s buffer)
if (credentials.expiresAt && Date.now() / 1000 < credentials.expiresAt - 60) {
return { credentials };
}
+1 -31
View File
@@ -1,11 +1,6 @@
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',
@@ -88,21 +83,16 @@ 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
@@ -252,33 +242,13 @@ describe('connect command', () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
await clientEventHandlers['auth_failed']?.('invalid token');
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',
+3 -104
View File
@@ -10,7 +10,6 @@ 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';
@@ -285,44 +284,8 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
updateStatus('reconnecting');
});
// 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
}
}
// Handle auth failed
client.on('auth_failed', (reason) => {
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.`,
@@ -345,8 +308,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;
}
@@ -367,7 +330,6 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
// Graceful shutdown
const cleanup = () => {
info('Shutting down...');
cancelRefreshTimer?.();
cleanupAllProcesses();
client.disconnect();
removeStatus();
@@ -412,69 +374,6 @@ 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;
+1 -1
View File
@@ -111,7 +111,7 @@ describe('cron command', () => {
]);
expect(mockTrpcClient.agentCronJob.create.mutate).toHaveBeenCalledWith(
expect.objectContaining({ agentId: 'a1', cronPattern: '* * * * *', name: 'My Job' }),
expect.objectContaining({ agentId: 'a1', name: 'My Job', schedule: '* * * * *' }),
);
});
});
+4 -4
View File
@@ -125,10 +125,10 @@ export function registerCronCommand(program: Command) {
const input: Record<string, any> = {
agentId: options.agentId,
cronPattern: options.schedule,
schedule: options.schedule,
};
if (options.name) input.name = options.name;
if (options.prompt) input.content = options.prompt;
if (options.prompt) input.prompt = 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.cronPattern = options.schedule;
if (options.prompt) data.content = options.prompt;
if (options.schedule) data.schedule = options.schedule;
if (options.prompt) data.prompt = options.prompt;
if (options.maxExecutions) data.maxExecutions = Number.parseInt(options.maxExecutions, 10);
if (options.enable) data.enabled = true;
if (options.disable) data.enabled = false;
+13 -89
View File
@@ -9,61 +9,6 @@ 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')
@@ -78,26 +23,15 @@ export function registerGenerateCommand(program: Command) {
// ── status ──────────────────────────────────────────
generate
.command('status <generationId> <asyncTaskId>')
.command('status <generationId> <taskId>')
.description('Check generation task status')
.option('--json', 'Output raw JSON')
.action(async (generationId: string, asyncTaskId: string, options: { json?: boolean }) => {
.action(async (generationId: string, taskId: string, options: { json?: boolean }) => {
const client = await getTrpcClient();
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;
}
const result = await client.generation.getGenerationStatus.query({
asyncTaskId: taskId,
generationId,
});
if (options.json) {
console.log(JSON.stringify(result, null, 2));
@@ -119,7 +53,7 @@ export function registerGenerateCommand(program: Command) {
// ── download ──────────────────────────────────────────
generate
.command('download <generationId> <asyncTaskId>')
.command('download <generationId> <taskId>')
.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')
@@ -127,7 +61,7 @@ export function registerGenerateCommand(program: Command) {
.action(
async (
generationId: string,
asyncTaskId: string,
taskId: string,
options: { interval?: string; output?: string; timeout?: string },
) => {
const client = await getTrpcClient();
@@ -139,20 +73,10 @@ export function registerGenerateCommand(program: Command) {
// Poll for completion
while (true) {
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;
}
const result = (await client.generation.getGenerationStatus.query({
asyncTaskId: taskId,
generationId,
})) as any;
if (result.status === 'success' && result.generation) {
const gen = result.generation;
@@ -201,7 +125,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} ${asyncTaskId}" to check later.`));
console.log(pc.dim(`Run "lh gen status ${generationId} ${taskId}" to check later.`));
process.exit(1);
}
-1
View File
@@ -188,7 +188,6 @@ export default defineConfig({
],
resolve: {
dedupe: ['react', 'react-dom'],
tsconfigPaths: true,
},
},
});
+2 -2
View File
@@ -74,7 +74,7 @@
"cookie": "^1.1.1",
"cross-env": "^10.1.0",
"diff": "^8.0.4",
"electron": "41.3.0",
"electron": "41.1.0",
"electron-builder": "^26.8.1",
"electron-devtools-installer": "4.0.0",
"electron-is": "^3.0.0",
@@ -107,7 +107,7 @@
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"undici": "^7.16.0",
"uuid": "^14.0.0",
"uuid": "^13.0.0",
"vite": "^8.0.9",
"vitest": "^3.2.4",
"zod": "^3.25.76"
+2 -2
View File
@@ -71,7 +71,6 @@
"macOS.services": "خدمات",
"macOS.unhide": "إظهار الكل",
"tray.open": "فتح {{appName}}",
"tray.openMiniToolbar": "Quick Composer",
"tray.quit": "خروج",
"tray.show": "عرض {{appName}}",
"view.forceReload": "إعادة تحميل قسري",
@@ -87,5 +86,6 @@
"window.minimize": "تصغير",
"window.title": "نافذة",
"window.toggleFullscreen": "تبديل وضع ملء الشاشة",
"window.zoom": "تكبير"
"window.zoom": "تكبير",
"tray.openMiniToolbar": "Quick Composer"
}
@@ -71,7 +71,6 @@
"macOS.services": "Услуги",
"macOS.unhide": "Покажи всичко",
"tray.open": "Отвори {{appName}}",
"tray.openMiniToolbar": "Quick Composer",
"tray.quit": "Изход",
"tray.show": "Покажи {{appName}}",
"view.forceReload": "Принудително презареждане",
@@ -87,5 +86,6 @@
"window.minimize": "Минимизирай",
"window.title": "Прозорец",
"window.toggleFullscreen": "Превключи на цял екран",
"window.zoom": "Мащаб"
"window.zoom": "Мащаб",
"tray.openMiniToolbar": "Quick Composer"
}
@@ -71,7 +71,6 @@
"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",
@@ -87,5 +86,6 @@
"window.minimize": "Minimieren",
"window.title": "Fenster",
"window.toggleFullscreen": "Vollbild umschalten",
"window.zoom": "Zoom"
"window.zoom": "Zoom",
"tray.openMiniToolbar": "Quick Composer"
}
@@ -15,11 +15,6 @@
"fullDiskAccess.openSettings": "Open Settings",
"fullDiskAccess.skip": "Later",
"fullDiskAccess.title": "Full Disk Access Required",
"screenCaptureAccess.cancel": "Later",
"screenCaptureAccess.detail": "Open System Settings, enable Screen Recording for LobeHub, then try Quick Composer again.",
"screenCaptureAccess.message": "Quick Composer needs Screen Recording permission before it can capture screenshots.",
"screenCaptureAccess.openSettings": "Open Settings",
"screenCaptureAccess.title": "Screen Recording Permission Required",
"update.downloadAndInstall": "Download and Install",
"update.downloadComplete": "Download Complete",
"update.downloadCompleteMessage": "Update downloaded. Install now?",
@@ -71,7 +71,6 @@
"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",
@@ -87,5 +86,6 @@
"window.minimize": "Minimizar",
"window.title": "Ventana",
"window.toggleFullscreen": "Alternar pantalla completa",
"window.zoom": "Zoom"
"window.zoom": "Zoom",
"tray.openMiniToolbar": "Quick Composer"
}
@@ -71,7 +71,6 @@
"macOS.services": "خدمات",
"macOS.unhide": "نمایش همه",
"tray.open": "باز کردن {{appName}}",
"tray.openMiniToolbar": "Quick Composer",
"tray.quit": "خروج",
"tray.show": "نمایش {{appName}}",
"view.forceReload": "بارگذاری اجباری",
@@ -87,5 +86,6 @@
"window.minimize": "کوچک کردن",
"window.title": "پنجره",
"window.toggleFullscreen": "تغییر به حالت تمام صفحه",
"window.zoom": "زوم"
"window.zoom": "زوم",
"tray.openMiniToolbar": "Quick Composer"
}
@@ -71,7 +71,6 @@
"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",
@@ -87,5 +86,6 @@
"window.minimize": "Réduire",
"window.title": "Fenêtre",
"window.toggleFullscreen": "Basculer en plein écran",
"window.zoom": "Zoom"
"window.zoom": "Zoom",
"tray.openMiniToolbar": "Quick Composer"
}
@@ -71,7 +71,6 @@
"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",
@@ -87,5 +86,6 @@
"window.minimize": "Minimizza",
"window.title": "Finestra",
"window.toggleFullscreen": "Attiva/disattiva schermo intero",
"window.zoom": "Zoom"
"window.zoom": "Zoom",
"tray.openMiniToolbar": "Quick Composer"
}
@@ -71,7 +71,6 @@
"macOS.services": "サービス",
"macOS.unhide": "すべて表示",
"tray.open": "{{appName}} を開く",
"tray.openMiniToolbar": "Quick Composer",
"tray.quit": "終了",
"tray.show": "{{appName}} を表示",
"view.forceReload": "強制再読み込み",
@@ -87,5 +86,6 @@
"window.minimize": "最小化",
"window.title": "ウィンドウ",
"window.toggleFullscreen": "フルスクリーン切替",
"window.zoom": "ズーム"
"window.zoom": "ズーム",
"tray.openMiniToolbar": "Quick Composer"
}
@@ -71,7 +71,6 @@
"macOS.services": "서비스",
"macOS.unhide": "모두 표시",
"tray.open": "{{appName}} 열기",
"tray.openMiniToolbar": "Quick Composer",
"tray.quit": "종료",
"tray.show": "{{appName}} 표시",
"view.forceReload": "강제 새로 고침",
@@ -87,5 +86,6 @@
"window.minimize": "최소화",
"window.title": "창",
"window.toggleFullscreen": "전체 화면 전환",
"window.zoom": "줌"
"window.zoom": "줌",
"tray.openMiniToolbar": "Quick Composer"
}
@@ -71,7 +71,6 @@
"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",
@@ -87,5 +86,6 @@
"window.minimize": "Minimaliseren",
"window.title": "Venster",
"window.toggleFullscreen": "Schakel volledig scherm in/uit",
"window.zoom": "Inzoomen"
"window.zoom": "Inzoomen",
"tray.openMiniToolbar": "Quick Composer"
}
@@ -71,7 +71,6 @@
"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",
@@ -87,5 +86,6 @@
"window.minimize": "Zminimalizuj",
"window.title": "Okno",
"window.toggleFullscreen": "Przełącz tryb pełnoekranowy",
"window.zoom": "Powiększenie"
"window.zoom": "Powiększenie",
"tray.openMiniToolbar": "Quick Composer"
}
@@ -71,7 +71,6 @@
"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",
@@ -87,5 +86,6 @@
"window.minimize": "Minimizar",
"window.title": "Janela",
"window.toggleFullscreen": "Alternar Tela Cheia",
"window.zoom": "Zoom"
"window.zoom": "Zoom",
"tray.openMiniToolbar": "Quick Composer"
}
@@ -71,7 +71,6 @@
"macOS.services": "Сервисы",
"macOS.unhide": "Показать все",
"tray.open": "Открыть {{appName}}",
"tray.openMiniToolbar": "Quick Composer",
"tray.quit": "Выйти",
"tray.show": "Показать {{appName}}",
"view.forceReload": "Принудительная перезагрузка",
@@ -87,5 +86,6 @@
"window.minimize": "Свернуть",
"window.title": "Окно",
"window.toggleFullscreen": "Переключить полноэкранный режим",
"window.zoom": "Масштаб"
"window.zoom": "Масштаб",
"tray.openMiniToolbar": "Quick Composer"
}
@@ -71,7 +71,6 @@
"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",
@@ -87,5 +86,6 @@
"window.minimize": "Küçült",
"window.title": "Pencere",
"window.toggleFullscreen": "Tam Ekrana Geç",
"window.zoom": "Yakınlaştır"
"window.zoom": "Yakınlaştır",
"tray.openMiniToolbar": "Quick Composer"
}
@@ -71,7 +71,6 @@
"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",
@@ -87,5 +86,6 @@
"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"
"window.zoom": "Thu phóng",
"tray.openMiniToolbar": "Quick Composer"
}
@@ -15,11 +15,6 @@
"fullDiskAccess.openSettings": "打开设置",
"fullDiskAccess.skip": "稍后",
"fullDiskAccess.title": "需要完全磁盘访问权限",
"screenCaptureAccess.cancel": "稍后",
"screenCaptureAccess.detail": "请打开系统设置,为 LobeHub 开启“屏幕录制”权限,然后再次尝试 Quick Composer。",
"screenCaptureAccess.message": "Quick Composer 需要“屏幕录制”权限后才能进行截图。",
"screenCaptureAccess.openSettings": "打开设置",
"screenCaptureAccess.title": "需要屏幕录制权限",
"update.downloadAndInstall": "下载并安装",
"update.downloadComplete": "下载完成",
"update.downloadCompleteMessage": "已下载更新。现在安装吗?",
@@ -71,7 +71,6 @@
"macOS.services": "服務",
"macOS.unhide": "全部顯示",
"tray.open": "打開 {{appName}}",
"tray.openMiniToolbar": "Quick Composer",
"tray.quit": "退出",
"tray.show": "顯示 {{appName}}",
"view.forceReload": "強制重新載入",
@@ -87,5 +86,6 @@
"window.minimize": "最小化",
"window.title": "視窗",
"window.toggleFullscreen": "切換全螢幕",
"window.zoom": "縮放"
"window.zoom": "縮放",
"tray.openMiniToolbar": "Quick Composer"
}
@@ -1,71 +1,52 @@
import type { ChildProcess } from 'node:child_process';
import { spawn } from 'node:child_process';
import { createHash, randomUUID } from 'node:crypto';
import { access, appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import type { Readable, Writable } from 'node:stream';
import type { HeterogeneousAgentSessionError } from '@lobechat/electron-client-ipc';
import {
CLAUDE_CODE_CLI_INSTALL_COMMANDS,
CLAUDE_CODE_CLI_INSTALL_DOCS_URL,
CODEX_CLI_INSTALL_COMMANDS,
CODEX_CLI_INSTALL_DOCS_URL,
HeterogeneousAgentSessionErrorCode,
} from '@lobechat/electron-client-ipc';
import { app as electronApp, BrowserWindow } from 'electron';
import { getHeterogeneousAgentDriver } from '@/modules/heterogeneousAgent';
import { CodexFileChangeTracker } from '@/modules/heterogeneousAgent/codexFileChangeTracker';
import type {
HeterogeneousAgentImageAttachment,
HeterogeneousAgentParsedOutput,
} from '@/modules/heterogeneousAgent/types';
import { buildProxyEnv } from '@/modules/networkProxy/envBuilder';
import { detectHeterogeneousCliCommand } from '@/modules/toolDetectors';
import { createLogger } from '@/utils/logger';
import { ControllerModule, IpcMethod } from './index';
const logger = createLogger('controllers:HeterogeneousAgentCtr');
const CODEX_RESUME_THREAD_NOT_FOUND_PATTERNS = [
/no conversation found/i,
/thread .*not found/i,
/conversation .*not found/i,
/resume.*not found/i,
] as const;
const CLI_AUTH_REQUIRED_PATTERNS = [
/failed to authenticate/i,
/invalid authentication credentials/i,
/authentication[_ ]error/i,
/not authenticated/i,
/\bunauthorized\b/i,
/\b401\b/,
] as const;
const CODEX_RESUME_CWD_MISMATCH_PATTERNS = [
/working directory/i,
/\bcwd\b/i,
/different directory/i,
/directory.*mismatch/i,
] as const;
/** 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:)/;
// ─── CLI presets per agent type ───
// Mirrors @lobechat/heterogeneous-agents/registry but runs in main process
// (can't import from the workspace package in Electron main directly)
interface CLIPreset {
baseArgs: string[];
promptMode: 'positional' | 'stdin';
resumeArgs?: (sessionId: string) => string[];
}
const CLI_PRESETS: Record<string, CLIPreset> = {
'claude-code': {
baseArgs: [
'-p',
'--input-format',
'stream-json',
'--output-format',
'stream-json',
'--verbose',
'--include-partial-messages',
'--permission-mode',
'bypassPermissions',
],
promptMode: 'stdin',
resumeArgs: (sid) => ['--resume', sid],
},
// Future presets:
// 'codex': { baseArgs: [...], promptMode: 'positional' },
// 'kimi-cli': { baseArgs: [...], promptMode: 'positional' },
};
// ─── IPC types ───
@@ -88,9 +69,14 @@ interface StartSessionResult {
sessionId: string;
}
interface ImageAttachment {
id: string;
url: string;
}
interface SendPromptParams {
/** Image attachments to include in the prompt (downloaded from url, cached by id) */
imageList?: HeterogeneousAgentImageAttachment[];
imageList?: ImageAttachment[];
prompt: string;
sessionId: string;
}
@@ -129,24 +115,15 @@ interface AgentSession {
cwd?: string;
env?: Record<string, string>;
process?: ChildProcess;
resumeSessionId?: string;
sessionId: string;
}
type SessionErrorPayload = HeterogeneousAgentSessionError | string;
interface CliTraceSession {
dir: string;
writeQueue: Promise<void>;
}
/**
* External Agent Controller — manages external agent CLI processes via Electron IPC.
*
* Agent-agnostic: delegates spawn-plan construction and stdout framing to a
* per-agent driver so Claude Code, Codex, and future CLIs can differ in
* prompt transport, resume semantics, and raw stream shape without turning
* this controller into a giant `switch`.
* Agent-agnostic: uses CLI presets from a registry to support Claude Code,
* Codex, Kimi CLI, etc. Only handles process lifecycle and raw stdout line
* broadcasting. All event parsing and DB persistence happens on the Renderer side.
*
* Lifecycle: startSession → sendPrompt → (heteroAgentRawLine broadcasts) → stopSession
*/
@@ -155,408 +132,6 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
private sessions = new Map<string, AgentSession>();
private resolveSessionCommand(session: AgentSession): string {
const resolvedCommand = session.command.trim();
if (resolvedCommand) return resolvedCommand;
return session.agentType === 'codex' ? 'codex' : 'claude';
}
private buildCodexCliMissingError(session: AgentSession): HeterogeneousAgentSessionError {
const command = this.resolveSessionCommand(session);
return {
agentType: 'codex',
code: HeterogeneousAgentSessionErrorCode.CliNotFound,
command,
docsUrl: CODEX_CLI_INSTALL_DOCS_URL,
installCommands: CODEX_CLI_INSTALL_COMMANDS,
message: `Codex CLI was not found. Install it and make sure \`${command}\` can be executed.`,
};
}
private buildClaudeCodeCliMissingError(session: AgentSession): HeterogeneousAgentSessionError {
const command = this.resolveSessionCommand(session);
return {
agentType: 'claude-code',
code: HeterogeneousAgentSessionErrorCode.CliNotFound,
command,
docsUrl: CLAUDE_CODE_CLI_INSTALL_DOCS_URL,
installCommands: CLAUDE_CODE_CLI_INSTALL_COMMANDS,
message: `Claude Code CLI was not found. Install it and make sure \`${command}\` can be executed.`,
};
}
private buildCliMissingError(session: AgentSession): HeterogeneousAgentSessionError | undefined {
switch (session.agentType) {
case 'claude-code': {
return this.buildClaudeCodeCliMissingError(session);
}
case 'codex': {
return this.buildCodexCliMissingError(session);
}
default: {
return;
}
}
}
private buildCliAuthRequiredError(
session: AgentSession,
stderr: string,
): HeterogeneousAgentSessionError | undefined {
const command = this.resolveSessionCommand(session);
switch (session.agentType) {
case 'claude-code': {
return {
agentType: 'claude-code',
code: HeterogeneousAgentSessionErrorCode.AuthRequired,
command,
docsUrl: CLAUDE_CODE_CLI_INSTALL_DOCS_URL,
message:
'Claude Code could not authenticate. Sign in again or refresh its credentials, then retry.',
stderr,
};
}
case 'codex': {
return {
agentType: 'codex',
code: HeterogeneousAgentSessionErrorCode.AuthRequired,
command,
docsUrl: CODEX_CLI_INSTALL_DOCS_URL,
message:
'Codex could not authenticate. Sign in again or refresh its credentials, then retry.',
stderr,
};
}
default: {
return;
}
}
}
private getErrorMessage(error: unknown): string | undefined {
return typeof error === 'string'
? error
: error instanceof Error
? error.message
: typeof error === 'object' &&
error &&
'message' in error &&
typeof error.message === 'string'
? error.message
: undefined;
}
private buildCodexResumeError(
code:
| typeof HeterogeneousAgentSessionErrorCode.ResumeCwdMismatch
| typeof HeterogeneousAgentSessionErrorCode.ResumeThreadNotFound,
stderr: string,
session: AgentSession,
): HeterogeneousAgentSessionError {
const message =
code === HeterogeneousAgentSessionErrorCode.ResumeCwdMismatch
? 'The saved Codex thread can only be resumed from its original working directory.'
: 'The saved Codex thread could not be found, so it can no longer be resumed.';
return {
agentType: 'codex',
code,
command: session.command,
message,
resumeSessionId: session.resumeSessionId,
stderr,
workingDirectory: session.cwd,
};
}
private getCodexResumeError(
error: unknown,
session: AgentSession,
): HeterogeneousAgentSessionError | undefined {
if (session.agentType !== 'codex' || !session.resumeSessionId) return;
const message = this.getErrorMessage(error);
if (!message) return;
if (CODEX_RESUME_CWD_MISMATCH_PATTERNS.some((pattern) => pattern.test(message))) {
return this.buildCodexResumeError(
HeterogeneousAgentSessionErrorCode.ResumeCwdMismatch,
message,
session,
);
}
if (CODEX_RESUME_THREAD_NOT_FOUND_PATTERNS.some((pattern) => pattern.test(message))) {
return this.buildCodexResumeError(
HeterogeneousAgentSessionErrorCode.ResumeThreadNotFound,
message,
session,
);
}
}
private getCliAuthRequiredError(
error: unknown,
session: AgentSession,
): HeterogeneousAgentSessionError | undefined {
const message = this.getErrorMessage(error);
if (!message || !CLI_AUTH_REQUIRED_PATTERNS.some((pattern) => pattern.test(message))) return;
return this.buildCliAuthRequiredError(session, message);
}
private getSessionErrorPayload(error: unknown, session: AgentSession): SessionErrorPayload {
if (typeof error === 'object' && error && 'code' in error && error.code === 'ENOENT') {
const cliMissingError = this.buildCliMissingError(session);
if (cliMissingError) return cliMissingError;
}
const resumeError = this.getCodexResumeError(error, session);
if (resumeError) return resumeError;
const authRequiredError = this.getCliAuthRequiredError(error, session);
if (authRequiredError) return authRequiredError;
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> {
const defaultCommand =
session.agentType === 'claude-code'
? 'claude'
: session.agentType === 'codex'
? 'codex'
: undefined;
if (!defaultCommand) return;
const command = this.resolveSessionCommand(session);
const status =
command === defaultCommand
? await this.app.toolDetectorManager?.detect?.(defaultCommand, true)
: await detectHeterogeneousCliCommand(
session.agentType === 'claude-code' ? 'claude-code' : 'codex',
command,
);
const cliMissingError = this.buildCliMissingError(session);
if (!status || status.available || !cliMissingError) return;
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) {
@@ -589,7 +164,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
* Download an image by URL, with local disk cache keyed by id.
*/
private async resolveImage(
image: HeterogeneousAgentImageAttachment,
image: ImageAttachment,
): Promise<{ buffer: Buffer; mimeType: string }> {
const cacheDir = this.fileCacheDir;
const cacheKey = this.getImageCacheKey(image.id);
@@ -626,104 +201,12 @@ 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 = IMAGE_EXTENSIONS_BY_MIME[this.normalizeMimeType(mimeType)];
if (knownByMime) return knownByMime;
try {
const pathname = new URL(image.url).pathname;
const ext = path.extname(pathname).toLowerCase();
if (ext) return ext === '.jpeg' ? '.jpg' : ext;
} catch {
// Fall through to byte sniffing below.
}
return this.guessImageExtensionByBuffer(buffer);
}
/**
* Materialize an image attachment into a stable local file path so CLIs like
* Codex can consume it through `--image <file>`.
*/
private async resolveCliImagePath(image: HeterogeneousAgentImageAttachment): Promise<string> {
const { buffer, mimeType } = await this.resolveImage(image);
const cacheKey = this.getImageCacheKey(image.id);
const ext = this.guessImageExtension(mimeType, image, buffer);
if (!ext) {
throw new Error(`Unsupported image type for ${image.id}`);
}
const filePath = path.join(this.fileCacheDir, `${cacheKey}${ext}`);
try {
await access(filePath);
} catch {
await mkdir(this.fileCacheDir, { recursive: true });
await writeFile(filePath, buffer);
}
return filePath;
}
private async resolveCliImagePaths(
imageList: HeterogeneousAgentImageAttachment[] = [],
): Promise<string[]> {
const results = await Promise.allSettled(
imageList.map((image) => this.resolveCliImagePath(image)),
);
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;
}
/**
* Build a stream-json user message with text + optional image content blocks.
*/
private async buildStreamJsonInput(
prompt: string,
imageList: HeterogeneousAgentImageAttachment[] = [],
imageList: ImageAttachment[] = [],
): Promise<string> {
const content: any[] = [{ text: prompt, type: 'text' }];
@@ -743,10 +226,10 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
}
}
return `${JSON.stringify({
return JSON.stringify({
message: { content, role: 'user' },
type: 'user',
})}\n`;
});
}
// ─── IPC methods ───
@@ -758,7 +241,6 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
async startSession(params: StartSessionParams): Promise<StartSessionResult> {
const sessionId = randomUUID();
const agentType = params.agentType || 'claude-code';
getHeterogeneousAgentDriver(agentType);
this.sessions.set(sessionId, {
// If resuming, pre-set the agent session ID so sendPrompt adds --resume
@@ -769,7 +251,6 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
cwd: params.cwd,
env: params.env,
sessionId,
resumeSessionId: params.resumeSessionId,
});
logger.info('Session created:', { agentType, sessionId });
@@ -787,42 +268,37 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
const session = this.sessions.get(params.sessionId);
if (!session) throw new Error(`Session not found: ${params.sessionId}`);
const preflightError = await this.getSpawnPreflightError(session);
if (preflightError) {
this.broadcast('heteroAgentSessionError', {
error: preflightError,
sessionId: session.sessionId,
});
throw new Error(preflightError.message);
const preset = CLI_PRESETS[session.agentType];
if (!preset) throw new Error(`Unknown agent type: ${session.agentType}`);
const useStdin = preset.promptMode === 'stdin';
// Build stream-json payload up-front so any image download errors
// surface before the process is spawned.
let stdinPayload: string | undefined;
if (useStdin) {
stdinPayload = await this.buildStreamJsonInput(params.prompt, params.imageList ?? []);
}
const driver = getHeterogeneousAgentDriver(session.agentType);
const spawnPlan = await driver.buildSpawnPlan({
args: session.args,
helpers: {
buildClaudeStreamJsonInput: (prompt, imageList) =>
this.buildStreamJsonInput(prompt, imageList),
resolveCliImagePaths: (imageList) => this.resolveCliImagePaths(imageList),
},
imageList: params.imageList ?? [],
prompt: params.prompt,
resumeSessionId: session.agentSessionId,
});
const useStdin = spawnPlan.stdinPayload !== undefined;
const cliArgs = spawnPlan.args;
// Fall back to the user's Desktop so the process never inherits
// the Electron parent's cwd (which is `/` when launched from Finder).
const cwd = session.cwd || electronApp.getPath('desktop');
const traceSession = await this.createCliTraceSession({
cliArgs,
cwd,
imageList: params.imageList ?? [],
session,
stdinPayload: spawnPlan.stdinPayload,
});
return new Promise<void>((resolve, reject) => {
// Build CLI args: base preset + resume + user args
const cliArgs = [
...preset.baseArgs,
...(session.agentSessionId && preset.resumeArgs
? preset.resumeArgs(session.agentSessionId)
: []),
...session.args,
];
if (!useStdin && preset.promptMode === 'positional') {
// Positional mode: append prompt as a CLI arg (legacy / non-CC presets).
cliArgs.push(params.prompt);
}
// 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
@@ -842,116 +318,90 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
stdio: [useStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
});
// 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);
// In stdin mode, write the stream-json message and close stdin.
if (useStdin && stdinPayload && proc.stdin) {
const stdin = proc.stdin as Writable;
stdin.write(spawnPlan.stdinPayload, () => {
stdin.write(stdinPayload + '\n', () => {
stdin.end();
});
}
session.process = proc;
const streamProcessor = driver.createStreamProcessor();
const codexFileChangeTracker =
session.agentType === 'codex' ? new CodexFileChangeTracker() : undefined;
let stdoutBroadcastQueue: Promise<void> = Promise.resolve();
let buffer = '';
const broadcastParsedOutputs = (parsedOutputs: HeterogeneousAgentParsedOutput[]) => {
stdoutBroadcastQueue = stdoutBroadcastQueue
.then(async () => {
for (const parsedOutput of parsedOutputs) {
if (parsedOutput.agentSessionId) {
session.agentSessionId = parsedOutput.agentSessionId;
}
const line = codexFileChangeTracker
? await codexFileChangeTracker.track(parsedOutput.payload)
: parsedOutput.payload;
this.broadcast('heteroAgentRawLine', {
line,
sessionId: session.sessionId,
});
}
})
.catch((error) => {
logger.error('Failed to broadcast parsed agent output:', error);
});
};
// Stream stdout events as raw provider payloads to Renderer.
// Stream stdout lines as raw events 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', () => {
broadcastParsedOutputs(streamProcessor.flush());
buffer += chunk.toString('utf8');
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const parsed = JSON.parse(trimmed);
// Extract agent session ID from init event (for multi-turn)
if (parsed.type === 'system' && parsed.subtype === 'init' && parsed.session_id) {
session.agentSessionId = parsed.session_id;
}
// Broadcast raw parsed JSON — Renderer handles all adaptation
this.broadcast('heteroAgentRawLine', {
line: parsed,
sessionId: session.sessionId,
});
} catch {
// Not valid JSON, skip
}
}
});
// Capture stderr
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,
error: err.message,
sessionId: session.sessionId,
});
reject(new Error(typeof sessionError === 'string' ? sessionError : sessionError.message));
reject(err);
});
proc.on('exit', (code, signal) => {
void stdoutBroadcastQueue.finally(async () => {
void this.writeCliTraceJson(traceSession, 'exit.json', {
code,
finishedAt: new Date().toISOString(),
signal,
logger.info('Agent process exited:', { code, sessionId: session.sessionId, signal });
session.process = undefined;
// If *we* killed it (cancel / stop / before-quit), treat the non-zero
// exit as a clean shutdown — surfacing it as an error would make a
// user-initiated cancel look like an agent failure, and an Electron
// shutdown affecting OTHER running CC sessions would pollute their
// topics with a misleading "Agent exited with code 143" message.
if (session.cancelledByUs) {
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
resolve();
return;
}
if (code === 0) {
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
resolve();
} else {
const stderrOutput = stderrChunks.join('').trim();
const errorMsg = stderrOutput || `Agent exited with code ${code}`;
this.broadcast('heteroAgentSessionError', {
error: errorMsg,
sessionId: session.sessionId,
});
await this.flushCliTrace(traceSession);
logger.info('Agent process exited:', { code, sessionId: session.sessionId, signal });
session.process = undefined;
// If *we* killed it (cancel / stop / before-quit), treat the non-zero
// exit as a clean shutdown — surfacing it as an error would make a
// user-initiated cancel look like an agent failure, and an Electron
// shutdown affecting OTHER running CC sessions would pollute their
// topics with a misleading "Agent exited with code 143" message.
if (session.cancelledByUs) {
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
resolve();
return;
}
if (code === 0) {
this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId });
resolve();
} else {
const stderrOutput = stderrChunks.join('').trim();
const errorMsg = this.getExitErrorMessage(code, session, stderrOutput);
const sessionError = this.getSessionErrorPayload(errorMsg, session);
this.broadcast('heteroAgentSessionError', {
error: sessionError,
sessionId: session.sessionId,
});
reject(
new Error(typeof sessionError === 'string' ? sessionError : sessionError.message),
);
}
});
reject(new Error(errorMsg));
}
});
});
}
@@ -99,9 +99,7 @@ export default class NotificationCtr extends ControllerModule {
}
}
/**
* Show system desktop notification.
* By default notifications only appear when the main window is hidden or unfocused.
* High-priority callers can pass `force` to surface a banner even while focused.
* Show system desktop notification (only when window is hidden)
*/
@IpcMethod()
async showDesktopNotification(
@@ -119,16 +117,12 @@ export default class NotificationCtr extends ControllerModule {
// Check if window is hidden
const isWindowHidden = this.isMainWindowHidden();
if (!params.force && !isWindowHidden) {
if (!isWindowHidden) {
logger.debug('Main window is visible, skipping desktop notification');
return { reason: 'Window is visible', skipped: true, success: true };
}
if (params.requestAttention && isWindowHidden) {
this.requestUserAttention();
}
logger.info('Showing desktop notification:', params.title);
logger.info('Window is hidden, showing desktop notification:', params.title);
const notification = new Notification({
body: params.body,
@@ -155,9 +149,6 @@ 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', () => {
@@ -187,23 +178,6 @@ export default class NotificationCtr extends ControllerModule {
}
}
private requestUserAttention(): void {
try {
const mainWindow = this.app.browserManager.getMainWindow().browserWindow;
if (mainWindow.isDestroyed()) return;
if (electronIs.macOS()) {
app.dock?.bounce?.('informational');
return;
}
mainWindow.flashFrame(true);
} catch (error) {
logger.error('Failed to request user attention:', error);
}
}
/**
* Set the app-level badge count (dock red dot on macOS, Unity counter on Linux,
* overlay icon on Windows). Pass 0 to clear.
@@ -1,18 +1,14 @@
import { execFile } from 'node:child_process';
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import type {
ClaudeAuthStatus,
DetectHeterogeneousAgentCommandParams,
} from '@lobechat/electron-client-ipc';
import type { ClaudeAuthStatus } from '@lobechat/electron-client-ipc';
import type { ToolCategory, ToolStatus } from '@/core/infrastructure/ToolDetectorManager';
import { detectHeterogeneousCliCommand } from '@/modules/toolDetectors';
import { createLogger } from '@/utils/logger';
import { ControllerModule, IpcMethod } from './index';
const execFilePromise = promisify(execFile);
const execPromise = promisify(exec);
const logger = createLogger('controllers:ToolDetectorCtr');
@@ -38,14 +34,6 @@ export default class ToolDetectorCtr extends ControllerModule {
return this.manager.detect(name, force);
}
@IpcMethod()
async detectHeterogeneousAgentCommand(
params: DetectHeterogeneousAgentCommandParams,
): Promise<ToolStatus> {
logger.debug('Detecting heterogeneous agent command:', params);
return detectHeterogeneousCliCommand(params.agentType, params.command);
}
/**
* Detect all registered tools
*/
@@ -137,14 +125,9 @@ export default class ToolDetectorCtr extends ControllerModule {
* Returns null if the CLI is unavailable or the command fails.
*/
@IpcMethod()
async getClaudeAuthStatus(command = 'claude'): Promise<ClaudeAuthStatus | null> {
const resolvedCommand = command.trim() || 'claude';
async getClaudeAuthStatus(): Promise<ClaudeAuthStatus | null> {
try {
const { stdout } = await execFilePromise(resolvedCommand, ['auth', 'status', '--json'], {
timeout: 5000,
windowsHide: true,
});
const { stdout } = await execPromise('claude auth status --json', { timeout: 5000 });
return JSON.parse(stdout.trim()) as ClaudeAuthStatus;
} catch (error) {
logger.debug('Failed to get claude auth status:', error);
@@ -0,0 +1,17 @@
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);
}
}
@@ -4,7 +4,6 @@ import { tmpdir } from 'node:os';
import path from 'node:path';
import { PassThrough } from 'node:stream';
import { HeterogeneousAgentSessionErrorCode } from '@lobechat/electron-client-ipc';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import HeterogeneousAgentCtr from '../HeterogeneousAgentCtr';
@@ -15,7 +14,6 @@ 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() },
@@ -34,36 +32,18 @@ vi.mock('@/utils/logger', () => ({
// Captures the most recent spawn() call so sendPrompt tests can assert on argv.
const spawnCalls: Array<{ args: string[]; command: string; options: any }> = [];
let nextFakeProc: any = null;
const { execFileMock } = vi.hoisted(() => ({
execFileMock: vi.fn(),
vi.mock('node:child_process', () => ({
spawn: (command: string, args: string[], options: any) => {
spawnCalls.push({ args, command, options });
return nextFakeProc;
},
}));
vi.mock('node:child_process', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
execFile: execFileMock,
spawn: (command: string, args: string[], options: any) => {
spawnCalls.push({ args, command, options });
nextFakeProc?.__start?.();
return nextFakeProc;
},
};
});
/**
* Build a fake ChildProcess that immediately exits cleanly. Records every
* stdin write on the returned `writes` array so tests can inspect the payload.
*/
const createFakeProc = ({
exitCode = 0,
stderrLines = [],
stdoutLines = [],
}: {
exitCode?: number;
stderrLines?: string[];
stdoutLines?: string[];
} = {}) => {
const createFakeProc = () => {
const proc = new EventEmitter() as any;
const stdout = new PassThrough();
const stderr = new PassThrough();
@@ -80,29 +60,15 @@ const createFakeProc = ({
};
proc.kill = vi.fn();
proc.killed = false;
let started = false;
proc.__start = () => {
if (started) return;
started = true;
// Exit asynchronously so the Promise returned by sendPrompt resolves cleanly.
setImmediate(() => {
for (const line of stdoutLines) {
stdout.write(line);
}
for (const line of stderrLines) {
stderr.write(line);
}
stdout.end();
stderr.end();
proc.emit('exit', exitCode);
});
};
// Exit asynchronously so the Promise returned by sendPrompt resolves cleanly.
setImmediate(() => {
stdout.end();
stderr.end();
proc.emit('exit', 0);
});
return { proc, writes };
};
const getFlagValues = (args: string[], flag: string) =>
args.flatMap((arg, index) => (arg === flag ? [args[index + 1]] : []));
describe('HeterogeneousAgentCtr', () => {
let appStoragePath: string;
@@ -178,15 +144,10 @@ describe('HeterogeneousAgentCtr', () => {
describe('sendPrompt (claude-code)', () => {
beforeEach(() => {
spawnCalls.length = 0;
execFileMock.mockReset();
});
const runSendPrompt = async (
prompt: string,
sessionOverrides: Record<string, any> = {},
stdoutLines: string[] = [],
) => {
const { proc, writes } = createFakeProc({ stdoutLines });
const runSendPrompt = async (prompt: string, sessionOverrides: Record<string, any> = {}) => {
const { proc, writes } = createFakeProc();
nextFakeProc = proc;
const ctr = new HeterogeneousAgentCtr({
@@ -201,7 +162,7 @@ describe('HeterogeneousAgentCtr', () => {
await ctr.sendPrompt({ prompt, sessionId });
const { args: cliArgs, command, options } = spawnCalls[0];
return { cliArgs, command, ctr, options, sessionId, writes };
return { cliArgs, command, options, writes };
};
it('passes prompt via stdin stream-json — never as a positional arg', async () => {
@@ -260,398 +221,5 @@ describe('HeterogeneousAgentCtr', () => {
expect(options.cwd).toBe(explicitCwd);
});
it('captures the Claude Code session id from stream-json init events', async () => {
const { ctr, sessionId } = await runSendPrompt('hello', {}, [
`${JSON.stringify({ session_id: 'sess_cc_123', subtype: 'init', type: 'system' })}\n`,
]);
await expect(ctr.getSessionInfo({ sessionId })).resolves.toEqual({
agentSessionId: 'sess_cc_123',
});
});
});
describe('sendPrompt (codex)', () => {
beforeEach(() => {
spawnCalls.length = 0;
execFileMock.mockReset();
});
const runSendPrompt = async (
prompt: string,
sessionOverrides: Record<string, any> = {},
stdoutLines: string[] = [],
sendPromptOverrides: Partial<{ imageList: Array<{ id: string; url: string }> }> = {},
) => {
const { proc, writes } = createFakeProc({ stdoutLines });
nextFakeProc = proc;
const ctr = new HeterogeneousAgentCtr({
appStoragePath,
storeManager: { get: vi.fn() },
} as any);
const { sessionId } = await ctr.startSession({
agentType: 'codex',
command: 'codex',
...sessionOverrides,
});
await ctr.sendPrompt({ prompt, sessionId, ...sendPromptOverrides });
const { args: cliArgs, command, options } = spawnCalls[0];
return { cliArgs, command, ctr, options, sessionId, writes };
};
it('fails fast when Codex CLI is unavailable instead of attempting spawn', async () => {
const detect = vi.fn().mockResolvedValue({ available: false });
const ctr = new HeterogeneousAgentCtr({
appStoragePath,
storeManager: { get: vi.fn() },
toolDetectorManager: { detect },
} as any);
const { sessionId } = await ctr.startSession({
agentType: 'codex',
command: 'codex',
});
await expect(ctr.sendPrompt({ prompt: 'hello', sessionId })).rejects.toThrow(
'Codex CLI was not found',
);
expect(detect).toHaveBeenCalledWith('codex', true);
expect(spawnCalls).toHaveLength(0);
});
it('fails fast when Claude Code CLI is unavailable instead of attempting spawn', async () => {
const detect = vi.fn().mockResolvedValue({ available: false });
const ctr = new HeterogeneousAgentCtr({
appStoragePath,
storeManager: { get: vi.fn() },
toolDetectorManager: { detect },
} as any);
const { sessionId } = await ctr.startSession({
agentType: 'claude-code',
command: 'claude',
});
await expect(ctr.sendPrompt({ prompt: 'hello', sessionId })).rejects.toThrow(
'Claude Code CLI was not found',
);
expect(detect).toHaveBeenCalledWith('claude', true);
expect(spawnCalls).toHaveLength(0);
});
it('fails fast when a customized Claude command is unavailable instead of checking the default detector', async () => {
execFileMock.mockImplementation(
(
file: string,
_args: string[],
optionsOrCallback: unknown,
callback?: (error: Error | null, stdout: string, stderr: string) => void,
) => {
const resolvedCallback =
typeof optionsOrCallback === 'function' ? optionsOrCallback : callback;
resolvedCallback?.(
Object.assign(new Error(`${file} not found`), { code: 'ENOENT' }),
'',
'',
);
},
);
const detect = vi.fn().mockResolvedValue({ available: true });
const ctr = new HeterogeneousAgentCtr({
appStoragePath,
storeManager: { get: vi.fn() },
toolDetectorManager: { detect },
} as any);
const { sessionId } = await ctr.startSession({
agentType: 'claude-code',
command: 'claude-alt',
});
await expect(ctr.sendPrompt({ prompt: 'hello', sessionId })).rejects.toThrow(
'Claude Code CLI was not found',
);
expect(detect).not.toHaveBeenCalled();
expect(spawnCalls).toHaveLength(0);
});
it('passes prompt via stdin to codex exec instead of argv', async () => {
const prompt = '--run a shell-like prompt safely';
const { cliArgs, command, writes } = await runSendPrompt(prompt);
expect(command).toBe('codex');
expect(cliArgs).not.toContain(prompt);
expect(cliArgs).toEqual(
expect.arrayContaining(['exec', '--json', '--skip-git-repo-check', '--full-auto']),
);
expect(cliArgs).not.toContain('-');
expect(writes).toEqual([prompt]);
});
it('materializes image attachments into local files and forwards them via --image', async () => {
const imageList = [
{ id: 'image-1', url: 'data:image/png;base64,UE5HX1RFU1Q=' },
{ id: 'image-2', url: 'data:image/jpeg;base64,SlBFR19URVNU' },
];
const { cliArgs, writes } = await runSendPrompt('describe these screenshots', {}, [], {
imageList,
});
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(
imagePaths.every((filePath) =>
filePath.startsWith(path.join(appStoragePath, 'heteroAgent/files')),
),
).toBe(true);
await expect(
Promise.all(imagePaths.map((filePath) => readFile(filePath, 'utf8'))),
).resolves.toEqual(['PNG_TEST', 'JPEG_TEST']);
expect(writes).toEqual(['describe these screenshots']);
});
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 { proc } = createFakeProc();
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({
imageList,
prompt: 'inspect the screenshots',
sessionId,
}),
).rejects.toThrow('Failed to attach image(s) to CLI');
expect(spawnCalls).toHaveLength(0);
});
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 () => {
const { cliArgs } = await runSendPrompt('continue', { resumeSessionId: 'thread_abc' });
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`,
]);
await expect(ctr.getSessionInfo({ sessionId })).resolves.toEqual({
agentSessionId: 'thread_codex_123',
});
});
it('classifies stale Codex resume stderr as a structured resume error', () => {
const ctr = new HeterogeneousAgentCtr({
appStoragePath,
storeManager: { get: vi.fn() },
} as any);
const payload = (ctr as any).getSessionErrorPayload(
'No conversation found for thread thread_stale_123',
{
agentSessionId: 'thread_stale_123',
agentType: 'codex',
args: [],
command: 'codex',
cwd: '/Users/fake/projects/repo',
resumeSessionId: 'thread_stale_123',
sessionId: 'session-1',
},
);
expect(payload).toEqual({
agentType: 'codex',
code: HeterogeneousAgentSessionErrorCode.ResumeThreadNotFound,
command: 'codex',
message: 'The saved Codex thread could not be found, so it can no longer be resumed.',
resumeSessionId: 'thread_stale_123',
stderr: 'No conversation found for thread thread_stale_123',
workingDirectory: '/Users/fake/projects/repo',
});
});
it('classifies CLI authentication failures as auth-required errors', () => {
const ctr = new HeterogeneousAgentCtr({
appStoragePath,
storeManager: { get: vi.fn() },
} as any);
const payload = (ctr as any).getSessionErrorPayload(
'Failed to authenticate. API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"Invalid authentication credentials"}}',
{
agentType: 'claude-code',
args: [],
command: 'claude',
sessionId: 'session-1',
},
);
expect(payload).toEqual({
agentType: 'claude-code',
code: HeterogeneousAgentSessionErrorCode.AuthRequired,
command: 'claude',
docsUrl: 'https://docs.anthropic.com/en/docs/claude-code/setup',
message:
'Claude Code could not authenticate. Sign in again or refresh its credentials, then retry.',
stderr:
'Failed to authenticate. API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"Invalid authentication credentials"}}',
});
});
});
});
@@ -34,9 +34,6 @@ vi.mock('electron', () => {
},
Notification: MockNotification,
app: {
dock: {
bounce: vi.fn(),
},
setAppUserModelId: vi.fn(),
},
};
@@ -51,7 +48,6 @@ vi.mock('electron-is', () => ({
// Mock browserManager
const mockBrowserWindow = {
flashFrame: vi.fn(),
focus: vi.fn(),
isDestroyed: vi.fn(() => false),
isFocused: vi.fn(() => true),
@@ -185,24 +181,6 @@ describe('NotificationCtr', () => {
expect(result).toEqual({ success: true });
});
it('should show notification when force is true even if window is visible and focused', async () => {
const { Notification } = await import('electron');
vi.mocked(Notification.isSupported).mockReturnValue(true);
mockBrowserWindow.isVisible.mockReturnValue(true);
mockBrowserWindow.isFocused.mockReturnValue(true);
mockBrowserWindow.isMinimized.mockReturnValue(false);
const promise = controller.showDesktopNotification({
...params,
force: true,
});
vi.advanceTimersByTime(100);
const result = await promise;
expect(Notification).toHaveBeenCalled();
expect(result).toEqual({ success: true });
});
it('should use low urgency on Linux to prevent GNOME Shell freeze', async () => {
const { linux } = await import('electron-is');
const { Notification } = await import('electron');
@@ -274,40 +252,6 @@ describe('NotificationCtr', () => {
);
});
it('should request window attention when requested and window is hidden', async () => {
const { Notification } = await import('electron');
vi.mocked(Notification.isSupported).mockReturnValue(true);
mockBrowserWindow.isVisible.mockReturnValue(false);
const promise = controller.showDesktopNotification({
...params,
requestAttention: true,
});
vi.advanceTimersByTime(100);
await promise;
expect(mockBrowserWindow.flashFrame).toHaveBeenCalledWith(true);
});
it('should bounce dock on macOS when attention is requested', async () => {
const { app, Notification } = await import('electron');
const { macOS } = await import('electron-is');
vi.mocked(macOS).mockReturnValue(true);
vi.mocked(Notification.isSupported).mockReturnValue(true);
mockBrowserWindow.isVisible.mockReturnValue(false);
const promise = controller.showDesktopNotification({
...params,
requestAttention: true,
});
vi.advanceTimersByTime(100);
await promise;
expect(app.dock.bounce).toHaveBeenCalledWith('informational');
vi.mocked(macOS).mockReturnValue(false);
});
it('should register click handler to show main window', async () => {
const { Notification } = await import('electron');
vi.mocked(Notification.isSupported).mockReturnValue(true);
@@ -0,0 +1,88 @@
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,6 +22,7 @@ import SystemController from './SystemCtr';
import ToolDetectorCtr from './ToolDetectorCtr';
import TrayMenuCtr from './TrayMenuCtr';
import UpdaterCtr from './UpdaterCtr';
import UploadFileCtr from './UploadFileCtr';
export const controllerIpcConstructors = [
HeterogeneousAgentCtr,
@@ -46,6 +47,7 @@ export const controllerIpcConstructors = [
ToolDetectorCtr,
TrayMenuCtr,
UpdaterCtr,
UploadFileCtr,
] as const satisfies readonly IpcServiceConstructor[];
type DesktopControllerIpcConstructors = typeof controllerIpcConstructors;
@@ -16,13 +16,6 @@ const dialog = {
'fullDiskAccess.openSettings': 'Open Settings',
'fullDiskAccess.skip': 'Later',
'fullDiskAccess.title': 'Full Disk Access Required',
'screenCaptureAccess.cancel': 'Later',
'screenCaptureAccess.detail':
'Open System Settings, enable Screen Recording for LobeHub, then try Quick Composer again.',
'screenCaptureAccess.message':
'Quick Composer needs Screen Recording permission before it can capture screenshots.',
'screenCaptureAccess.openSettings': 'Open Settings',
'screenCaptureAccess.title': 'Screen Recording Permission Required',
'update.downloadAndInstall': 'Download and Install',
'update.downloadComplete': 'Download Complete',
'update.downloadCompleteMessage': 'Update downloaded. Install now?',
@@ -1,146 +0,0 @@
import { mkdtemp, rename, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { CodexFileChangeTracker } from './codexFileChangeTracker';
describe('CodexFileChangeTracker', () => {
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(tempDirs.map((dir) => rm(dir, { force: true, recursive: true })));
tempDirs.length = 0;
});
it('enriches completed file_change payloads with per-file and total line stats', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'codex-file-change-tracker-'));
tempDirs.push(dir);
const updatePath = path.join(dir, 'a.txt');
const addPath = path.join(dir, 'b.txt');
await writeFile(updatePath, 'hello\n', 'utf8');
const tracker = new CodexFileChangeTracker();
await tracker.track({
item: {
changes: [
{ kind: 'update', path: updatePath },
{ kind: 'add', path: addPath },
],
id: 'item_1',
type: 'file_change',
},
type: 'item.started',
});
await writeFile(updatePath, 'hello\nappended line\n', 'utf8');
await writeFile(addPath, 'line one\nline two\n', 'utf8');
const enriched = await tracker.track({
item: {
changes: [
{ kind: 'update', path: updatePath },
{ kind: 'add', path: addPath },
],
id: 'item_1',
type: 'file_change',
},
type: 'item.completed',
});
expect(enriched.item).toMatchObject({
changes: [
{
kind: 'update',
linesAdded: 1,
linesDeleted: 0,
path: updatePath,
},
{
kind: 'add',
linesAdded: 2,
linesDeleted: 0,
path: addPath,
},
],
linesAdded: 3,
linesDeleted: 0,
});
});
it('treats rename changes as metadata-only and keeps line stats at zero', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'codex-file-change-tracker-'));
tempDirs.push(dir);
const beforePath = path.join(dir, 'before.txt');
const afterPath = path.join(dir, 'after.txt');
await writeFile(beforePath, 'content\n', 'utf8');
const tracker = new CodexFileChangeTracker();
await tracker.track({
item: {
changes: [{ kind: 'rename', path: afterPath }],
id: 'item_rename',
type: 'file_change',
},
type: 'item.started',
});
await rename(beforePath, afterPath);
const enriched = await tracker.track({
item: {
changes: [{ kind: 'rename', path: afterPath }],
id: 'item_rename',
type: 'file_change',
},
type: 'item.completed',
});
expect(enriched.item).toMatchObject({
changes: [{ kind: 'rename', linesAdded: 0, linesDeleted: 0, path: afterPath }],
linesAdded: 0,
linesDeleted: 0,
});
});
it('counts added lines even when file content begins with repeated plus markers', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'codex-file-change-tracker-'));
tempDirs.push(dir);
const addPath = path.join(dir, 'plus-prefixed.txt');
const tracker = new CodexFileChangeTracker();
await tracker.track({
item: {
changes: [{ kind: 'add', path: addPath }],
id: 'item_plus_prefix',
type: 'file_change',
},
type: 'item.started',
});
await writeFile(addPath, '++leading content\n+++header lookalike\n', 'utf8');
const enriched = await tracker.track({
item: {
changes: [{ kind: 'add', path: addPath }],
id: 'item_plus_prefix',
type: 'file_change',
},
type: 'item.completed',
});
expect(enriched.item).toMatchObject({
changes: [{ kind: 'add', linesAdded: 2, linesDeleted: 0, path: addPath }],
linesAdded: 2,
linesDeleted: 0,
});
});
});
@@ -1,181 +0,0 @@
import { access, readFile } from 'node:fs/promises';
import { createPatch } from 'diff';
interface CodexFileChangeEntry {
kind?: string;
path?: string;
}
interface CodexFileChangeSnapshot {
content?: string;
exists: boolean;
}
interface CodexFileChangeItem {
changes?: CodexFileChangeEntry[];
id?: string;
type?: string;
}
interface CodexFileChangePayload {
item?: CodexFileChangeItem;
type?: string;
}
interface CodexFileChangeLineStats {
linesAdded: number;
linesDeleted: number;
}
interface CodexTrackedFileChangeEntry extends CodexFileChangeEntry, CodexFileChangeLineStats {}
interface CodexTrackedFileChangeItem extends CodexFileChangeItem, CodexFileChangeLineStats {
changes?: CodexTrackedFileChangeEntry[];
}
const isCodexFileChangePayload = (
payload: CodexFileChangePayload,
): payload is Required<CodexFileChangePayload> =>
payload?.item?.type === 'file_change' && !!payload.item.id;
const readTextFileSnapshot = async (filePath: string): Promise<CodexFileChangeSnapshot> => {
try {
await access(filePath);
} catch {
return { exists: false };
}
try {
return {
content: await readFile(filePath, 'utf8'),
exists: true,
};
} catch {
return { exists: true };
}
};
const countPatchLines = (
previousContent: string,
nextContent: string,
): CodexFileChangeLineStats => {
if (previousContent === nextContent) return { linesAdded: 0, linesDeleted: 0 };
const patch = createPatch('codex-file-change', previousContent, nextContent, '', '');
let insideHunk = false;
let linesAdded = 0;
let linesDeleted = 0;
for (const line of patch.split('\n')) {
if (line.startsWith('@@')) {
insideHunk = true;
continue;
}
if (!insideHunk) continue;
if (line.startsWith('+')) {
linesAdded += 1;
continue;
}
if (line.startsWith('-')) {
linesDeleted += 1;
}
}
return { linesAdded, linesDeleted };
};
const computeLineStats = async (
change: CodexFileChangeEntry,
snapshot?: CodexFileChangeSnapshot,
): Promise<CodexFileChangeLineStats> => {
const filePath = change.path;
if (!filePath) return { linesAdded: 0, linesDeleted: 0 };
const kind = change.kind ?? 'update';
if (kind === 'rename') return { linesAdded: 0, linesDeleted: 0 };
const previousContent = snapshot?.content ?? '';
const current = await readTextFileSnapshot(filePath);
const nextContent = current.content ?? '';
if (kind === 'add') {
if (!current.exists) return { linesAdded: 0, linesDeleted: 0 };
return countPatchLines('', nextContent);
}
if (kind === 'delete' || kind === 'remove') {
if (!snapshot?.exists) return { linesAdded: 0, linesDeleted: 0 };
return countPatchLines(previousContent, '');
}
if (!snapshot?.exists && !current.exists) return { linesAdded: 0, linesDeleted: 0 };
return countPatchLines(previousContent, nextContent);
};
export class CodexFileChangeTracker {
private snapshots = new Map<string, Map<string, CodexFileChangeSnapshot>>();
async track<T extends CodexFileChangePayload>(payload: T): Promise<T> {
if (!isCodexFileChangePayload(payload)) return payload;
const itemId = payload.item.id;
const changes = payload.item.changes ?? [];
if (payload.type === 'item.started') {
const snapshots = new Map<string, CodexFileChangeSnapshot>();
await Promise.all(
changes.map(async (change) => {
if (!change.path || snapshots.has(change.path)) return;
snapshots.set(change.path, await readTextFileSnapshot(change.path));
}),
);
this.snapshots.set(itemId, snapshots);
return payload;
}
if (payload.type !== 'item.completed') return payload;
const snapshots = this.snapshots.get(itemId);
this.snapshots.delete(itemId);
if (!snapshots) return payload;
const trackedChanges = await Promise.all(
changes.map(async (change) => {
const stats = await computeLineStats(
change,
change.path ? snapshots.get(change.path) : undefined,
);
return {
...change,
...stats,
} satisfies CodexTrackedFileChangeEntry;
}),
);
const totals = trackedChanges.reduce<CodexFileChangeLineStats>(
(acc, change) => ({
linesAdded: acc.linesAdded + change.linesAdded,
linesDeleted: acc.linesDeleted + change.linesDeleted,
}),
{ linesAdded: 0, linesDeleted: 0 },
);
return {
...payload,
item: {
...payload.item,
...totals,
changes: trackedChanges,
} satisfies CodexTrackedFileChangeItem,
};
}
}
@@ -1,41 +0,0 @@
import { JsonlStreamProcessor } from '../jsonlProcessor';
import type { HeterogeneousAgentBuildPlanParams, HeterogeneousAgentDriver } from '../types';
const CLAUDE_CODE_BASE_ARGS = [
'-p',
'--input-format',
'stream-json',
'--output-format',
'stream-json',
'--verbose',
'--include-partial-messages',
'--permission-mode',
'bypassPermissions',
] as const;
export const claudeCodeDriver: HeterogeneousAgentDriver = {
async buildSpawnPlan({
args,
helpers,
imageList,
prompt,
resumeSessionId,
}: HeterogeneousAgentBuildPlanParams) {
const stdinPayload = await helpers.buildClaudeStreamJsonInput(prompt, imageList);
return {
args: [
...CLAUDE_CODE_BASE_ARGS,
...(resumeSessionId ? ['--resume', resumeSessionId] : []),
...args,
],
stdinPayload,
};
},
createStreamProcessor() {
return new JsonlStreamProcessor({
extractSessionId: (payload) =>
payload?.type === 'system' && payload?.subtype === 'init' ? payload?.session_id : undefined,
});
},
};
@@ -1,50 +0,0 @@
import { JsonlStreamProcessor } from '../jsonlProcessor';
import type { HeterogeneousAgentBuildPlanParams, HeterogeneousAgentDriver } from '../types';
const CODEX_REQUIRED_ARGS = ['--json', '--skip-git-repo-check'] as const;
const CODEX_AUTO_EXECUTION_FLAGS = [
'--full-auto',
'--dangerously-bypass-approvals-and-sandbox',
'--sandbox',
'-s',
] as const;
const hasAnyFlag = (args: string[], flags: readonly string[]) =>
args.some((arg) => flags.includes(arg as (typeof flags)[number]));
const buildCodexOptionArgs = async ({
args,
helpers,
imageList,
}: Pick<HeterogeneousAgentBuildPlanParams, 'args' | 'helpers' | 'imageList'>) => {
const imagePaths = await helpers.resolveCliImagePaths(imageList);
const imageArgs = imagePaths.flatMap((filePath) => ['--image', filePath]);
const autoExecutionArgs = hasAnyFlag(args, CODEX_AUTO_EXECUTION_FLAGS) ? [] : ['--full-auto'];
return [...CODEX_REQUIRED_ARGS, ...autoExecutionArgs, ...args, ...imageArgs];
};
export const codexDriver: HeterogeneousAgentDriver = {
async buildSpawnPlan({
args,
helpers,
imageList,
prompt,
resumeSessionId,
}: HeterogeneousAgentBuildPlanParams) {
const optionArgs = await buildCodexOptionArgs({ args, helpers, imageList });
return {
args: resumeSessionId
? ['exec', 'resume', ...optionArgs, resumeSessionId, '-']
: ['exec', ...optionArgs],
stdinPayload: prompt,
};
},
createStreamProcessor() {
return new JsonlStreamProcessor({
extractSessionId: (payload) =>
payload?.type === 'thread.started' ? payload?.thread_id : undefined,
});
},
};
@@ -1,18 +0,0 @@
import { claudeCodeDriver } from './drivers/claudeCode';
import { codexDriver } from './drivers/codex';
import type { HeterogeneousAgentDriver } from './types';
const heterogeneousAgentDrivers: Record<string, HeterogeneousAgentDriver> = {
'claude-code': claudeCodeDriver,
'codex': codexDriver,
};
export const getHeterogeneousAgentDriver = (agentType: string): HeterogeneousAgentDriver => {
const driver = heterogeneousAgentDrivers[agentType];
if (!driver) {
throw new Error(`Unknown heterogeneous agent type: ${agentType}`);
}
return driver;
};
@@ -1,61 +0,0 @@
import type { HeterogeneousAgentParsedOutput, HeterogeneousAgentStreamProcessor } from './types';
export interface JsonlProcessorOptions {
extractSessionId?: (payload: any) => string | undefined;
}
/**
* Parses stdout as JSONL / NDJSON while tolerating non-JSON noise lines.
* Different CLIs still end up sharing this framing logic even when the
* payload schema differs.
*/
export class JsonlStreamProcessor implements HeterogeneousAgentStreamProcessor {
private buffer = '';
constructor(private readonly options: JsonlProcessorOptions = {}) {}
push(chunk: Buffer | string): HeterogeneousAgentParsedOutput[] {
this.buffer += chunk instanceof Buffer ? chunk.toString('utf8') : chunk;
return this.drainCompleteLines();
}
flush(): HeterogeneousAgentParsedOutput[] {
const trailing = this.buffer.trim();
this.buffer = '';
if (!trailing) return [];
try {
return [this.toParsedOutput(JSON.parse(trailing))];
} catch {
return [];
}
}
private drainCompleteLines(): HeterogeneousAgentParsedOutput[] {
const lines = this.buffer.split('\n');
this.buffer = lines.pop() || '';
const parsed: HeterogeneousAgentParsedOutput[] = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
parsed.push(this.toParsedOutput(JSON.parse(trimmed)));
} catch {
// Ignore non-JSON stdout noise.
}
}
return parsed;
}
private toParsedOutput(payload: any): HeterogeneousAgentParsedOutput {
return {
agentSessionId: this.options.extractSessionId?.(payload),
payload,
};
}
}
@@ -1,42 +0,0 @@
export interface HeterogeneousAgentImageAttachment {
id: string;
url: string;
}
export interface HeterogeneousAgentBuildPlan {
args: string[];
stdinPayload?: string;
}
export interface HeterogeneousAgentBuildPlanHelpers {
buildClaudeStreamJsonInput: (
prompt: string,
imageList: HeterogeneousAgentImageAttachment[],
) => Promise<string>;
resolveCliImagePaths: (imageList: HeterogeneousAgentImageAttachment[]) => Promise<string[]>;
}
export interface HeterogeneousAgentBuildPlanParams {
args: string[];
helpers: HeterogeneousAgentBuildPlanHelpers;
imageList: HeterogeneousAgentImageAttachment[];
prompt: string;
resumeSessionId?: string;
}
export interface HeterogeneousAgentParsedOutput {
agentSessionId?: string;
payload: any;
}
export interface HeterogeneousAgentStreamProcessor {
flush: () => HeterogeneousAgentParsedOutput[];
push: (chunk: Buffer | string) => HeterogeneousAgentParsedOutput[];
}
export interface HeterogeneousAgentDriver {
buildSpawnPlan: (
params: HeterogeneousAgentBuildPlanParams,
) => Promise<HeterogeneousAgentBuildPlan>;
createStreamProcessor: () => HeterogeneousAgentStreamProcessor;
}
@@ -5,14 +5,11 @@ import { ScreenCaptureManager } from './ScreenCaptureManager';
const {
mockBrowserWindow,
MockBrowserWindow,
mockDialogShowMessageBox,
mockScreen,
mockEnumerateWindows,
mockIsMac,
mockCaptureWindow,
mockCaptureRect,
mockGetScreenCaptureStatus,
mockRequestScreenCaptureAccess,
} = vi.hoisted(() => {
const mockBrowserWindow = {
destroy: vi.fn(),
@@ -40,11 +37,8 @@ const {
MockBrowserWindow: vi.fn(() => mockBrowserWindow),
mockCaptureRect: vi.fn(),
mockCaptureWindow: vi.fn(),
mockDialogShowMessageBox: vi.fn(async () => ({ response: 0 })),
mockEnumerateWindows: vi.fn().mockResolvedValue([]),
mockGetScreenCaptureStatus: vi.fn(() => 'granted'),
mockIsMac: { value: true },
mockRequestScreenCaptureAccess: vi.fn(async () => false),
mockScreen: {
getCursorScreenPoint: vi.fn(() => ({ x: 10, y: 10 })),
getDisplayNearestPoint: vi.fn(() => ({
@@ -58,9 +52,6 @@ const {
vi.mock('electron', () => ({
BrowserWindow: MockBrowserWindow,
dialog: {
showMessageBox: mockDialogShowMessageBox,
},
screen: mockScreen,
}));
@@ -83,11 +74,6 @@ vi.mock('@/utils/logger', () => ({
}),
}));
vi.mock('@/utils/permissions', () => ({
getScreenCaptureStatus: mockGetScreenCaptureStatus,
requestScreenCaptureAccess: mockRequestScreenCaptureAccess,
}));
vi.mock('./WindowSourceService', () => ({
enumerateWindows: mockEnumerateWindows,
}));
@@ -98,36 +84,21 @@ vi.mock('./CaptureService', () => ({
}));
describe('ScreenCaptureManager', () => {
const createApp = ({ mainWindowVisible = true }: { mainWindowVisible?: boolean } = {}) => {
const mainWindow = {
browserWindow: {
id: 1,
isVisible: vi.fn(() => mainWindowVisible),
},
};
return {
const createApp = () =>
({
browserManager: {
broadcastToAllWindows: vi.fn(),
broadcastToWindow: vi.fn(),
getMainWindow: vi.fn(() => mainWindow),
showMainWindow: vi.fn(),
},
buildRendererUrl: vi.fn().mockResolvedValue('http://localhost:5173/overlay'),
i18n: {
ns: vi.fn(() => (key: string) => key),
},
} as any;
};
}) as any;
beforeEach(() => {
vi.clearAllMocks();
mockBrowserWindow.isDestroyed.mockReturnValue(false);
mockDialogShowMessageBox.mockResolvedValue({ response: 0 });
mockEnumerateWindows.mockResolvedValue([]);
mockGetScreenCaptureStatus.mockReturnValue('granted');
mockIsMac.value = true;
mockRequestScreenCaptureAccess.mockResolvedValue(false);
});
it('keeps the app in regular mode when showing overlay on macOS', async () => {
@@ -151,56 +122,6 @@ describe('ScreenCaptureManager', () => {
expect(mockBrowserWindow.moveTop).toHaveBeenCalled();
});
it('blocks quick composer and prompts for permission when screen recording is unavailable', async () => {
mockGetScreenCaptureStatus.mockReturnValue('denied');
mockDialogShowMessageBox.mockResolvedValue({ response: 0 });
const app = createApp();
const manager = new ScreenCaptureManager(app);
await manager.startSession();
expect(mockDialogShowMessageBox).toHaveBeenCalledWith(
app.browserManager.getMainWindow().browserWindow,
expect.objectContaining({
message: 'screenCaptureAccess.message',
title: 'screenCaptureAccess.title',
}),
);
expect(mockRequestScreenCaptureAccess).toHaveBeenCalled();
expect(mockEnumerateWindows).not.toHaveBeenCalled();
expect(MockBrowserWindow).not.toHaveBeenCalled();
});
it('does not open settings when permission prompt is dismissed', async () => {
mockGetScreenCaptureStatus.mockReturnValue('denied');
mockDialogShowMessageBox.mockResolvedValue({ response: 1 });
const manager = new ScreenCaptureManager(createApp());
await manager.startSession();
expect(mockRequestScreenCaptureAccess).not.toHaveBeenCalled();
expect(mockEnumerateWindows).not.toHaveBeenCalled();
expect(MockBrowserWindow).not.toHaveBeenCalled();
});
it('shows an app-modal prompt when the main window is hidden', async () => {
mockGetScreenCaptureStatus.mockReturnValue('denied');
const manager = new ScreenCaptureManager(createApp({ mainWindowVisible: false }));
await manager.startSession();
expect(mockDialogShowMessageBox).toHaveBeenCalledWith(
expect.objectContaining({
message: 'screenCaptureAccess.message',
title: 'screenCaptureAccess.title',
}),
);
expect(mockDialogShowMessageBox).not.toHaveBeenCalledWith(
expect.objectContaining({ id: 1 }),
expect.anything(),
);
});
describe('preview handlers', () => {
it('hides overlay via opacity while capturing rect and restores after', async () => {
const app = createApp();
@@ -11,14 +11,13 @@ import type {
ScreenCaptureSession,
ScreenCaptureSubmitParams,
} from '@lobechat/electron-client-ipc';
import { BrowserWindow, dialog, screen } from 'electron';
import { BrowserWindow, screen } from 'electron';
import { BrowsersIdentifiers } from '@/appBrowsers';
import { preloadDir } from '@/const/dir';
import { isMac } from '@/const/env';
import type { App } from '@/core/App';
import { createLogger } from '@/utils/logger';
import { getScreenCaptureStatus, requestScreenCaptureAccess } from '@/utils/permissions';
import { captureRect, captureWindow } from './CaptureService';
import { enumerateWindows } from './WindowSourceService';
@@ -78,10 +77,6 @@ export class ScreenCaptureManager {
}
async startSession(): Promise<void> {
if (!(await this.ensureScreenCaptureAccess())) {
return;
}
if (this.isActive) {
logger.warn('Capture session already active');
this.close();
@@ -272,45 +267,6 @@ export class ScreenCaptureManager {
});
}
private async ensureScreenCaptureAccess(): Promise<boolean> {
if (!isMac) {
return true;
}
const status = getScreenCaptureStatus();
if (status === 'granted') {
return true;
}
const t = this.app.i18n.ns('dialog');
const mainWindow = this.app.browserManager.getMainWindow();
const parentWindow = mainWindow?.browserWindow?.isVisible?.() ? mainWindow.browserWindow : null;
const options = {
buttons: [t('screenCaptureAccess.openSettings'), t('screenCaptureAccess.cancel')],
cancelId: 1,
defaultId: 0,
detail: t('screenCaptureAccess.detail'),
message: t('screenCaptureAccess.message'),
noLink: true,
title: t('screenCaptureAccess.title'),
type: 'warning' as const,
};
const result = parentWindow
? await dialog.showMessageBox(parentWindow, options)
: await dialog.showMessageBox(options);
if (result.response !== 0) {
logger.info(`Screen capture permission prompt dismissed; status=${status}`);
return false;
}
logger.info(`Opening screen capture permission settings; status=${status}`);
await requestScreenCaptureAccess();
return false;
}
private async createOverlayWindow(bounds: Electron.Rectangle): Promise<void> {
const win = new BrowserWindow({
...(isMac ? { type: 'panel' } : {}),
@@ -1,107 +1,58 @@
import { execFile } from 'node:child_process';
import { exec } from 'node:child_process';
import { platform } from 'node:os';
import { promisify } from 'node:util';
import type { IToolDetector, ToolStatus } from '@/core/infrastructure/ToolDetectorManager';
import { createCommandDetector } from '@/core/infrastructure/ToolDetectorManager';
const execFilePromise = promisify(execFile);
type HeterogeneousCliAgentType = 'claude-code' | 'codex';
interface ValidatedDetectorOptions {
description: string;
name: string;
priority: number;
validateFlag?: string;
validateKeywords: string[];
}
const resolveCommandPath = async (command: string): Promise<string | undefined> => {
const trimmedCommand = command.trim();
if (!trimmedCommand) return;
const whichCommand = platform() === 'win32' ? 'where' : 'which';
try {
const { stdout } = await execFilePromise(whichCommand, [trimmedCommand], { timeout: 3000 });
return stdout.trim().split(/\r?\n/)[0] || trimmedCommand;
} catch {
return trimmedCommand;
}
};
const detectValidatedCommand = async (
command: string,
options: Pick<ValidatedDetectorOptions, 'validateFlag' | 'validateKeywords'>,
): Promise<ToolStatus> => {
const trimmedCommand = command.trim();
if (!trimmedCommand) return { available: false };
const { validateFlag = '--version', validateKeywords } = options;
try {
const { stderr, stdout } = await execFilePromise(trimmedCommand, [validateFlag], {
timeout: 5000,
windowsHide: true,
});
const output = `${stdout}\n${stderr}`.trim();
const loweredOutput = output.toLowerCase();
if (!validateKeywords.some((keyword) => loweredOutput.includes(keyword.toLowerCase()))) {
return { available: false };
}
return {
available: true,
path: await resolveCommandPath(trimmedCommand),
version: output.split(/\r?\n/)[0],
};
} catch {
return { available: false };
}
};
const HETEROGENEOUS_CLI_AGENT_OPTIONS = {
'claude-code': {
validateKeywords: ['claude code'],
},
'codex': {
validateKeywords: ['codex'],
},
} as const satisfies Record<
HeterogeneousCliAgentType,
Pick<ValidatedDetectorOptions, 'validateKeywords'>
>;
export const detectHeterogeneousCliCommand = async (
agentType: HeterogeneousCliAgentType,
command: string,
): Promise<ToolStatus> => {
const validator = HETEROGENEOUS_CLI_AGENT_OPTIONS[agentType];
if (!validator) return { available: false };
return detectValidatedCommand(command, validator);
};
const execPromise = promisify(exec);
/**
* Detector that resolves a command path via which/where, then validates
* the binary by matching `--version` (or `--help`) output against a keyword
* to avoid collisions with unrelated executables of the same name.
*/
const createValidatedDetector = (
options: ValidatedDetectorOptions & {
candidates: string[];
},
): IToolDetector => {
const { candidates, description, name, priority, ...validation } = options;
const createValidatedDetector = (options: {
candidates: string[];
description: string;
name: string;
priority: number;
validateFlag?: string;
validateKeywords: string[];
}): IToolDetector => {
const {
name,
description,
priority,
candidates,
validateFlag = '--version',
validateKeywords,
} = options;
return {
description,
async detect(): Promise<ToolStatus> {
const whichCmd = platform() === 'win32' ? 'where' : 'which';
for (const cmd of candidates) {
const status = await detectValidatedCommand(cmd, validation);
if (status.available) return status;
try {
const { stdout: pathOut } = await execPromise(`${whichCmd} ${cmd}`, { timeout: 3000 });
const toolPath = pathOut.trim().split('\n')[0];
if (!toolPath) continue;
const { stdout: out } = await execPromise(`${cmd} ${validateFlag}`, { timeout: 5000 });
const output = out.trim();
const lowered = output.toLowerCase();
if (!validateKeywords.some((kw) => lowered.includes(kw.toLowerCase()))) continue;
return {
available: true,
path: toolPath,
version: output.split('\n')[0],
};
} catch {
continue;
}
}
return { available: false };
@@ -6,7 +6,7 @@
*/
export { browserAutomationDetectors } from './agentBrowserDetectors';
export { cliAgentDetectors, detectHeterogeneousCliCommand } from './cliAgentDetectors';
export { cliAgentDetectors } from './cliAgentDetectors';
export { astSearchDetectors, contentSearchDetectors } from './contentSearchDetectors';
export { fileSearchDetectors } from './fileSearchDetectors';
export { runtimeEnvironmentDetectors } from './runtimeEnvironmentDetectors';
+1 -1
View File
@@ -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 -1
View File
@@ -252,7 +252,7 @@ const ChatPanel = memo<ChatPanelProps>(
}, [theme]);
useLayoutEffect(() => {
if (!hidden && textareaRef.current) {
if (selected && !hidden && textareaRef.current) {
textareaRef.current.focus();
}
}, [hidden, selected]);
@@ -7,7 +7,7 @@ import type { MouseEvent as ReactMouseEvent } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import ChatPanel, { type ChatPanelSelection, type ChatPanelSubmitPayload } from './ChatPanel';
import { OVERLAY_COPY, OVERLAY_LAYOUT, OVERLAY_SHORTCUTS } from './constants';
import { OVERLAY_COPY, OVERLAY_LAYOUT } from './constants';
import * as styles from './overlay.css.ts';
import { resolveCommittedSelectionRect, shouldHideChatPanel } from './overlaySelectionState';
import { useDragSelection } from './useDragSelection';
@@ -375,7 +375,6 @@ const ScreenCaptureOverlay = memo(() => {
});
const showHover = hoveredWindow && !hasSelections && !isDragging && !committedSelectionRect;
const showDrag = isDragging && dragRect;
const showHint = !hasSelections && !isDragging && !pendingSelectionRect;
return (
<div
@@ -447,22 +446,6 @@ const ScreenCaptureOverlay = memo(() => {
onRemoveSelection={removeSelection}
onSubmit={handleSubmit}
/>
{showHint && (
<div className={styles.hintPill} role="note">
<span className={styles.hintPillKey}>{OVERLAY_COPY.hintHoverTrigger}</span>
<span className={styles.hintPillDivider}></span>
<span className={styles.hintPillLabel}>{OVERLAY_COPY.hintSelectWindow}</span>
<span className={styles.hintPillGroupDivider} />
<span className={styles.hintPillKey}>{OVERLAY_COPY.hintDragTrigger}</span>
<span className={styles.hintPillDivider}></span>
<span className={styles.hintPillLabel}>{OVERLAY_COPY.hintDragRegion}</span>
<span className={styles.hintPillGroupDivider} />
<span className={styles.hintPillKey}>{OVERLAY_SHORTCUTS.close}</span>
<span className={styles.hintPillDivider}></span>
<span className={styles.hintPillLabel}>{OVERLAY_COPY.hintExit}</span>
</div>
)}
</div>
);
});
-5
View File
@@ -4,11 +4,6 @@ export const OVERLAY_COPY = {
clearSelectionLabel: 'Clear selection',
closeLabel: 'Close',
customRegionLabel: 'Custom region',
hintDragRegion: 'Capture a region',
hintDragTrigger: 'Drag',
hintExit: 'Exit',
hintHoverTrigger: 'Hover',
hintSelectWindow: 'Select active window',
idlePlaceholder: 'Select a window or drag a region to start asking…',
latestSelectionLabel: 'Latest',
modelSelectLabel: 'Model',
+2 -45
View File
@@ -15,7 +15,8 @@ const overlayTheme = {
tagText: 'rgba(248, 250, 252, 0.96)',
},
font: {
system: 'system-ui, sans-serif',
system:
"'SF Pro Display', 'SF Pro Text', 'Segoe UI Variable Text', 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, sans-serif",
},
radius: {
highlight: '14px',
@@ -159,47 +160,3 @@ export const selection = style([
boxShadow: vars.shadow.selection,
},
]);
export const hintPill = style([
windowTag,
{
bottom: 24,
left: 24,
minHeight: 28,
padding: '4px 14px',
position: 'fixed',
userSelect: 'none',
zIndex: 20,
},
]);
export const hintPillKey = style({
color: vars.color.tagText,
flex: 'none',
fontSize: 12,
fontWeight: 650,
letterSpacing: '0.01em',
});
export const hintPillDivider = style({
color: vars.color.tagDivider,
flex: 'none',
fontSize: 10,
});
export const hintPillLabel = style({
color: vars.color.tagMuted,
flex: 'none',
fontSize: 12,
fontWeight: 500,
whiteSpace: 'nowrap',
});
export const hintPillGroupDivider = style({
background: vars.color.tagDivider,
flex: 'none',
height: 12,
margin: '0 4px',
opacity: 0.4,
width: 1,
});
@@ -1,6 +1,9 @@
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', () => {
+2 -1
View File
@@ -15,7 +15,8 @@ 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,7 +3,9 @@ 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,
-5
View File
@@ -1,9 +1,4 @@
[
{
"children": {},
"date": "2026-04-20",
"version": "2.1.52"
},
{
"children": {
"fixes": ["fix minify cli.", "recent delete."]
+4 -3
View File
@@ -52,7 +52,6 @@
"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",
@@ -469,5 +468,7 @@
"https://github.com/user-attachments/assets/facdc83c-e789-4649-8060-7f7a10a1b1dd": "/blog/assets05b20e40c03ced0ec8707fed2e8e0f25.webp",
"https://github.com/user-attachments/assets/fcdfb9c5-819a-488f-b28d-0857fe861219": "/blog/assets8477415ecec1f37e38ab38ff1217d0a7.webp",
"https://github.com/user-attachments/assets/fd60ab55-ead2-4930-ad00-fdf77662f5a0": "/blog/assets276a4e8748e9bd300b30dcd9d0e24980.webp",
"https://file.rene.wang/clipboard-1777343750668-9b3dcb0dfff86.png": "/blog/assetsfa267a02f20bc5ba6f1273bcf27b7c9f.webp"
}
"https://file.rene.wang/clipboard-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"
}
+1 -4
View File
@@ -1,9 +1,6 @@
---
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 -4
View File
@@ -1,9 +1,6 @@
---
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,8 +1,6 @@
---
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 -4
View File
@@ -1,9 +1,6 @@
---
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 -1
View File
@@ -1,5 +1,5 @@
---
title: 语音会话:与你的助理自然对话
title: '语音会话:与你的助理自然对话'
description: LobeHub 现已支持文字转语音(TTS)和语音转文字(STT),实现自然的语音交互。与助理对话并听到清晰、个性化的语音回复。
tags:
- TTS
+1 -4
View File
@@ -1,9 +1,6 @@
---
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
+2 -4
View File
@@ -1,8 +1,6 @@
---
title: 文生图:在对话中直接创作视觉内容
description: >-
LobeHub 现已支持文本到图片生成。在对话中直接调用 DALL-E 3、MidJourney 或
Pollinations,无需离开聊天界面即可将想法转化为图像。
title: '文生图:在对话中直接创作视觉内容'
description: LobeHub 现已支持文本到图片生成。在对话中直接调用 DALL-E 3、MidJourney 或 Pollinations,无需离开聊天界面即可将想法转化为图像。
tags:
- Text to Image
- 文生图
@@ -1,6 +1,7 @@
---
title: 灵活适配的认证体系:Clerk 与 Next-Auth 双方案支持
description: LobeHub 现已支持 Clerk 和 Next-Auth 两种认证方案,让团队可以根据部署模式和安全需求选择最适合的身份验证方式。
description: >-
LobeHub 现已支持 Clerk 和 Next-Auth 两种认证方案,让团队可以根据部署模式和安全需求选择最适合的身份验证方式。
tags:
- 用户管理
- 身份验证
+2 -1
View File
@@ -1,6 +1,7 @@
---
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.6GPT-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 -1
View File
@@ -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,6 +1,8 @@
---
title: 支持导出对话为 Markdown 或 OpenAI JSON 格式
description: LobeHub v1.28.0 新增 Markdown 与 OpenAI 格式 JSON 导出,方便将对话转为文档、 调试数据或训练语料。
description: >-
LobeHub v1.28.0 新增 Markdown 与 OpenAI 格式 JSON 导出,方便将对话转为文档、
调试数据或训练语料。
tags:
- 文本格式导出
- Markdown 导出
@@ -1,6 +1,8 @@
---
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 -3
View File
@@ -1,8 +1,6 @@
---
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
+2 -4
View File
@@ -1,8 +1,6 @@
---
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
+2 -4
View File
@@ -1,8 +1,6 @@
---
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 -1
View File
@@ -1,5 +1,5 @@
---
title: 快捷键自定义、数据导出与服务商扩展
title: "快捷键自定义、数据导出与服务商扩展"
description: LobeHub 新增快捷键自定义、数据导出功能,并扩展服务商支持,让日常使用更顺手、数据更可迁移。
tags:
- LobeHub
+2 -4
View File
@@ -1,8 +1,6 @@
---
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:
- 桌面端
+2 -4
View File
@@ -1,8 +1,6 @@
---
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 -1
View File
@@ -1,5 +1,5 @@
---
title: 提示词变量与 Claude 4 推理模型支持
title: "提示词变量与 Claude 4 推理模型支持"
description: LobeHub 引入提示词变量实现模板复用,并完整支持 Claude 4 推理模型及网页搜索集成。
tags:
- 提示词变量
+1 -2
View File
@@ -1,8 +1,7 @@
---
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,8 +1,7 @@
---
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: "图像生成、桌面端与认证更新 \U0001F3A8"
title: 图像生成、桌面端与认证更新 🎨
description: 通过多个服务商生成 AI 图像,用更多身份系统完成接入,并在桌面端享受更顺畅的工作流。
tags:
- 图像生成

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