mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-19 13:54:10 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee14d6c481 | |||
| 84a36c0cc3 | |||
| 55eb1509d2 | |||
| 4c5dbbece7 | |||
| 5a08e92603 |
@@ -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).
|
||||
@@ -23,7 +23,7 @@ LobeChat agents can answer inside external chat platforms. Inbound messages flow
|
||||
|
||||
`supportsMarkdown=false` ⇒ outbound markdown is stripped to plain text via `stripMarkdown` and the AI is told not to use markdown. `supportsMessageEdit=false` ⇒ no progress edits — only the final reply is sent.
|
||||
|
||||
**Multi-mode connection** — Slack/Feishu/Lark/QQ ship as websocket but support `webhook` per-provider via `settings.connectionMode`. The runtime always merges schema defaults into stored settings before resolving the mode (`resolveBotProviderConfig` / `resolveConnectionMode` in `platforms/utils.ts`), so the schema's `field.default` is the source of truth — set it correctly when adding a new multi-mode platform.
|
||||
**Multi-mode connection** — Slack/Feishu/Lark/QQ shipped as websocket but support `webhook` per-provider via `settings.connectionMode`. Legacy rows without that field stay on `webhook` (see `LEGACY_WEBHOOK_PLATFORMS` in `platforms/utils.ts`) — **never add new platforms to that list**.
|
||||
|
||||
## Inbound Flow (one webhook → reply)
|
||||
|
||||
|
||||
@@ -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,7 +5,7 @@ description: Internationalization guide using react-i18next. Use when adding tra
|
||||
|
||||
# LobeHub Internationalization Guide
|
||||
|
||||
- Default language: English (en-US)
|
||||
- Default language: Chinese (zh-CN)
|
||||
- Framework: react-i18next
|
||||
- **Only edit files in `src/locales/default/`** - Never edit JSON files in `locales/`
|
||||
- Run `pnpm i18n` to generate translations (or manually translate zh-CN/en-US for dev preview)
|
||||
|
||||
@@ -30,63 +30,6 @@ This is NON-NEGOTIABLE. Skipping Linear comments is a workflow violation.
|
||||
|
||||
When creating issues with `mcp__linear-server__create_issue`, **MUST add the `claude code` label**.
|
||||
|
||||
## Creating Sub-issue Trees
|
||||
|
||||
When breaking a parent issue into a tree of sub-issues (e.g., task decomposition for LOBE-xxx), follow these rules — they work around real limitations of the Linear MCP tools.
|
||||
|
||||
### 1. ALWAYS prefix titles with an ordering index
|
||||
|
||||
The Linear Sub-issues panel displays children by `sortOrder`, which **defaults to newest-first** (most recently created appears on top). Neither parallel nor serial creation will produce the intended top-to-bottom reading order, and the MCP `save_issue` tool does **not expose a `sortOrder` parameter** — you cannot set order at create time.
|
||||
|
||||
**Workaround**: encode execution order in the title itself:
|
||||
|
||||
```plaintext
|
||||
[1] [db] add schema fields
|
||||
[2] [db] new table + repository
|
||||
[3] [service] business logic layer
|
||||
[4] [api] REST endpoints
|
||||
[4.1] [sdk] client SDK wrapper
|
||||
[4.1.1] [app] consumer integration
|
||||
[4.1.2] [app] UI surface
|
||||
[4.2] [ui] dashboard page
|
||||
```
|
||||
|
||||
Even when the panel shuffles, the reader can mentally reconstruct the dependency graph at a glance. Dotted numbering `[n.m.k]` should mirror the parent-child nesting so the index and the tree agree.
|
||||
|
||||
### 2. Nest sub-issues by logical parent-child, not flat under the root
|
||||
|
||||
Linear supports **unlimited sub-issue depth**. A flat list of 8+ siblings under one root is hard to scan. Group by main-subordinate logic:
|
||||
|
||||
- Core service → its SDK → SDK consumers
|
||||
- Don't create a sibling when a child is more accurate
|
||||
|
||||
Use `parentId: "LOBE-xxxx"` at creation (or `save_issue` to move). Moving an issue's parent does not disturb its `blockedBy` relations.
|
||||
|
||||
### 3. Sub-issue creation order is dictated by `blockedBy`
|
||||
|
||||
`blockedBy` requires the blocker to exist first (you need its LOBE-id). So:
|
||||
|
||||
1. **Topologically sort** the DAG — leaves (no deps) first, roots last
|
||||
2. Create issues with zero deps in the first wave
|
||||
3. Create dependent issues only after collecting the blocker IDs from prior responses
|
||||
4. `blockedBy` is **append-only**; passing it again does not overwrite — safe to re-run
|
||||
|
||||
### 4. Don't waste rounds trying to parallelize
|
||||
|
||||
MCP tool calls in a single message look parallel but execute sequentially on the server, and you still need blocker IDs from earlier responses. Just issue calls in dependency order; optimizing for parallelism gains nothing here.
|
||||
|
||||
### 5. Keep each sub-issue description self-contained
|
||||
|
||||
Each sub-issue should state:
|
||||
|
||||
- Goal (1–2 lines)
|
||||
- Key files to touch
|
||||
- Concrete changes / acceptance criteria
|
||||
- Dependencies (link to blocker issues by `LOBE-xxxx`)
|
||||
- Validation steps
|
||||
|
||||
The implementer may open only the sub-issue, not the parent — don't rely on context that lives only in the parent description.
|
||||
|
||||
## Completion Comment Format
|
||||
|
||||
Every completed issue MUST have a comment summarizing work done:
|
||||
|
||||
@@ -1,27 +1,18 @@
|
||||
---
|
||||
name: version-release
|
||||
description: "Version release workflow. Use when the user mentions 'release', 'hotfix', 'version upgrade', 'weekly release', or '发版'/'发布'/'小班车'. This skill is for release process and GitHub Release notes (not docs/changelog page writing)."
|
||||
description: "Version release workflow. Use when the user mentions 'release', 'hotfix', 'version upgrade', 'weekly release', or '发版'/'发布'/'小班车'. Provides guides for Minor Release and Patch Release workflows."
|
||||
---
|
||||
|
||||
# Version Release Workflow
|
||||
|
||||
## Scope Boundary (Important)
|
||||
|
||||
This skill is only for:
|
||||
|
||||
1. Release branch / PR workflow
|
||||
2. CI trigger constraints (`auto-tag-release.yml`)
|
||||
3. GitHub Release note writing
|
||||
|
||||
This skill is **not** for writing `docs/changelog/*.mdx`.\
|
||||
If the user asks for website changelog pages, load `../docs-changelog/SKILL.md`.
|
||||
|
||||
## Mandatory Companion Skill
|
||||
|
||||
For every `/version-release` execution, you MUST load and apply:
|
||||
|
||||
- `../microcopy/SKILL.md`
|
||||
|
||||
Changelog style guidance is now fully embedded in this skill. Keep release facts unchanged, and only improve structure, readability, and tone.
|
||||
|
||||
## Overview
|
||||
|
||||
The primary development branch is **canary**. All day-to-day development happens on canary. When releasing, canary is merged into main. After merge, `auto-tag-release.yml` automatically handles tagging, version bumping, creating a GitHub Release, and syncing back to the canary branch.
|
||||
@@ -35,7 +26,7 @@ Only two release types are used in practice (major releases are extremely rare a
|
||||
|
||||
## Minor Release Workflow
|
||||
|
||||
Used to publish a new minor version (e.g. `v2.2.0`), roughly every 4 weeks.
|
||||
Used to publish a new minor version (e.g. v2.2.0), roughly every 4 weeks.
|
||||
|
||||
### Steps
|
||||
|
||||
@@ -48,7 +39,7 @@ git checkout -b release/v{version}
|
||||
git push -u origin release/v{version}
|
||||
```
|
||||
|
||||
2. **Determine the version number** — Read the current version from `package.json` and compute the next minor version (e.g. 2.1.x -> 2.2.0)
|
||||
2. **Determine the version number** — Read the current version from `package.json` and compute the next minor version (e.g. 2.1.x → 2.2.0)
|
||||
|
||||
3. **Create a PR to main**
|
||||
|
||||
@@ -60,10 +51,9 @@ gh pr create \
|
||||
--body "## 📦 Release v{version} ..."
|
||||
```
|
||||
|
||||
> \[!IMPORTANT]
|
||||
> The PR title must strictly match the `🚀 release: v{x.y.z}` format. CI uses a regex on this title to determine the exact version number.
|
||||
> \[!IMPORTANT]: The PR title must strictly match the `🚀 release: v{x.y.z}` format. CI uses a regex on this title to determine the exact version number.
|
||||
|
||||
4. **Automatic trigger after merge**: `auto-tag-release` detects the title format and uses the version number from the title to complete the release.
|
||||
4. **Automatic trigger after merge**: auto-tag-release detects the title format and uses the version number from the title to complete the release.
|
||||
|
||||
### Scripts
|
||||
|
||||
@@ -78,7 +68,7 @@ Version number is automatically bumped by patch +1. There are 4 common scenarios
|
||||
|
||||
| Scenario | Source Branch | Branch Naming | Description |
|
||||
| ------------------- | ------------- | ----------------------------- | ------------------------------------------------ |
|
||||
| Weekly Release | canary | `release/weekly-{YYYYMMDD}` | Weekly release train, canary -> main |
|
||||
| Weekly Release | canary | `release/weekly-{YYYYMMDD}` | Weekly release train, canary → main |
|
||||
| Bug Hotfix | main | `hotfix/v{version}-{hash}` | Emergency bug fix |
|
||||
| New Model Launch | canary | Community PR merged directly | New model launch, triggered by PR title prefix |
|
||||
| DB Schema Migration | main | `release/db-migration-{name}` | Database migration, requires dedicated changelog |
|
||||
@@ -91,19 +81,19 @@ All scenarios auto-bump patch +1. Patch PR titles do not need a version number.
|
||||
bun run hotfix:branch # Hotfix scenario
|
||||
```
|
||||
|
||||
## Auto-Release Trigger Rules (`auto-tag-release.yml`)
|
||||
## Auto-Release Trigger Rules (auto-tag-release.yml)
|
||||
|
||||
After a PR is merged into main, CI determines whether to release based on the following priority:
|
||||
|
||||
### 1. Minor Release (Exact Version)
|
||||
|
||||
PR title matches `🚀 release: v{x.y.z}` -> uses the version number from the title.
|
||||
PR title matches `🚀 release: v{x.y.z}` → uses the version number from the title.
|
||||
|
||||
### 2. Patch Release (Auto patch +1)
|
||||
|
||||
Triggered by the following priority:
|
||||
|
||||
- **Branch name match**: `hotfix/*` or `release/*` -> triggers directly (skips title detection)
|
||||
- **Branch name match**: `hotfix/*` or `release/*` → triggers directly (skips title detection)
|
||||
- **Title prefix match**: PRs with the following title prefixes will trigger:
|
||||
- `style` / `💄 style`
|
||||
- `feat` / `✨ feat`
|
||||
@@ -114,225 +104,241 @@ Triggered by the following priority:
|
||||
|
||||
### 3. No Trigger
|
||||
|
||||
PRs that don't match any conditions above (e.g. `docs`, `chore`, `ci`, `test`) will not trigger a release when merged into main.
|
||||
PRs that don't match any of the above conditions (e.g. `docs`, `chore`, `ci`, `test` prefixes) will not trigger a release when merged into main.
|
||||
|
||||
## Post-Release Automated Actions
|
||||
|
||||
1. **Bump `package.json`** — commits `🔖 chore(release): release version v{x.y.z} [skip ci]`
|
||||
1. **Bump package.json** — commits `🔖 chore(release): release version v{x.y.z} [skip ci]`
|
||||
2. **Create annotated tag** — `v{x.y.z}`
|
||||
3. **Create GitHub Release**
|
||||
4. **Dispatch `sync-main-to-canary`** — syncs main back to canary
|
||||
4. **Dispatch sync-main-to-canary** — syncs main back to the canary branch
|
||||
|
||||
## Agent Action Guide
|
||||
## Claude Action Guide
|
||||
|
||||
When the user requests a release:
|
||||
|
||||
### Minor Release
|
||||
|
||||
1. Read `package.json` to get the current version and compute the next minor version
|
||||
2. Create a `release/v{version}` branch from canary
|
||||
3. Push and create a PR — **title must be `🚀 release: v{version}`**
|
||||
4. Inform the user that merging the PR will automatically trigger the release
|
||||
|
||||
### Precheck
|
||||
|
||||
Before creating the release branch, verify the source branch:
|
||||
|
||||
- **Weekly Release** (`release/weekly-*`): must branch from `canary`
|
||||
- **All other release/hotfix branches**: must branch from `main`; run `git merge-base --is-ancestor main <branch> && echo OK`
|
||||
- If the branch is based on the wrong source, recreate from the correct base
|
||||
|
||||
### Minor Release
|
||||
|
||||
1. Read `package.json` to get the current version and compute the next minor version
|
||||
2. Create a `release/v{version}` branch from canary
|
||||
3. Push and create PR — **title must be `🚀 release: v{version}`**
|
||||
4. Inform the user that merge will auto-trigger release
|
||||
- **All other release/hotfix branches**: must branch from `main` — run `git merge-base --is-ancestor main <branch> && echo OK` to confirm
|
||||
- If the branch is based on the wrong source, delete and recreate from the correct base
|
||||
|
||||
### Patch Release
|
||||
|
||||
Choose workflow by scenario (see `reference/patch-release-scenarios.md`):
|
||||
Choose the appropriate workflow based on the scenario (see `reference/patch-release-scenarios.md`):
|
||||
|
||||
- **Weekly Release**: create `release/weekly-{YYYYMMDD}` from canary; use `git log main..canary` for release note inputs; title like `🚀 release: 20260222`
|
||||
- **Bug Hotfix**: create `hotfix/` from main; use gitmoji prefix title (e.g. `🐛 fix: ...`)
|
||||
- **New Model Launch**: community PRs trigger automatically via title prefix (`feat` / `style`)
|
||||
- **DB Migration**: create `release/db-migration-{name}` from main; cherry-pick migration commits; include dedicated migration notes
|
||||
- **Weekly Release**: Create a `release/weekly-{YYYYMMDD}` branch from canary, scan `git log main..canary` to write the changelog, title like `🚀 release: 20260222`
|
||||
- **Bug Hotfix**: Create a `hotfix/` branch from main, use a gitmoji prefix title (e.g. `🐛 fix: ...`)
|
||||
- **New Model Launch**: Community PRs trigger automatically via title prefix (`feat` / `style`), no extra steps needed
|
||||
- **DB Migration**: Create a `release/db-migration-{name}` branch from main, cherry-pick migration commits, write a dedicated migration changelog
|
||||
|
||||
### Hard Rules
|
||||
### Important Notes
|
||||
|
||||
- **Do NOT** manually modify `package.json` version
|
||||
- **Do NOT** manually create tags
|
||||
- Minor PR title format is strict
|
||||
- Patch PRs do not need explicit version number
|
||||
- Keep release facts accurate; do not invent metrics or availability statements
|
||||
- **Do NOT manually modify the version in package.json** — CI will auto-bump it
|
||||
- **Do NOT manually create tags** — CI will create them automatically
|
||||
- The Minor Release PR title format is a hard requirement — incorrect format will not use the specified version number
|
||||
- Patch PRs do not need a version number — CI auto-bumps patch +1
|
||||
- All release PRs must include a user-facing changelog
|
||||
|
||||
## GitHub Release Changelog Standard (Long-Form Style)
|
||||
## Changelog Writing Guidelines
|
||||
|
||||
Use this section for writing **GitHub Release notes** (or release PR body when the PR body is intended to become release notes).\
|
||||
Do not use this as `docs/changelog` page guidance.
|
||||
All release PR bodies (both Minor and Patch) must include a user-facing changelog. Scan changes via `git log main..canary --oneline` or `git diff main...canary --stat`, then write following the format below.
|
||||
|
||||
### Positioning
|
||||
### Format Reference
|
||||
|
||||
This release-note style is:
|
||||
- Weekly Release: See `reference/changelog-example/weekly-release.md`
|
||||
- DB Migration: See `reference/changelog-example/db-migration.md`
|
||||
|
||||
1. **Data-backed at the top** (date, range, key metrics)
|
||||
2. **Narrative first, then structured detail**
|
||||
3. **Deep but scannable** (clear sectioning + compact bullets)
|
||||
4. **Contributor-forward** (credits are part of the release story)
|
||||
### Mandatory Inputs Before Writing
|
||||
|
||||
### Required Inputs Before Writing
|
||||
1. Release diff context (`git log main..canary` and/or `git diff main...canary --stat`)
|
||||
2. Existing release template constraints (title, credits, trigger rules)
|
||||
3. `../microcopy/SKILL.md` terminology constraints
|
||||
|
||||
Collect these inputs first:
|
||||
### Output Constraints (Hard Rules)
|
||||
|
||||
1. Compare range (`<prev_tag>...<current_tag>`)
|
||||
2. Release metrics (commits, merged PRs, resolved issues, contributors, optional files/insertions/deletions)
|
||||
3. High-impact changes by domain (core loop, platform/gateway, UX, tooling, security, reliability)
|
||||
4. Contributor list (with standout contributions if known)
|
||||
5. Known risks / migrations / rollout notes (if any)
|
||||
1. Keep all factual claims accurate to merged changes.
|
||||
2. Do not invent numbers, scope, timelines, or availability tiers.
|
||||
3. Keep release title and trigger-sensitive format unchanged.
|
||||
4. Keep `Credits` section intact (format required by project conventions).
|
||||
5. Prefer fewer headings and more natural narrative paragraphs.
|
||||
6. EN/ZH versions must cover the same facts in the same order.
|
||||
7. Prefer storytelling over feature enumeration.
|
||||
8. Avoid `Key Updates` sections that are only bullet dumps unless explicitly requested.
|
||||
|
||||
If metrics cannot be reliably computed, omit unknown numbers instead of guessing.
|
||||
### Editorial Voice (Notion/Linear-Inspired)
|
||||
|
||||
### Canonical Structure
|
||||
Target a changelog voice that is calm, confident, and human:
|
||||
|
||||
Follow this section order unless the user asks otherwise:
|
||||
- Start from user reality, not internal implementation.
|
||||
- Explain why this change matters before listing mechanics.
|
||||
- Keep tone practical and grounded, but allow a little product warmth.
|
||||
- Favor concrete workflow examples over abstract claims.
|
||||
- Write like an update from a thoughtful product team, not a marketing launch page.
|
||||
|
||||
1. `# 🚀 LobeHub v<x.y.z> (<YYYYMMDD>)`
|
||||
2. Metadata lines:
|
||||
- `Release Date`
|
||||
- `Since <Previous Version>` metrics
|
||||
3. One quoted release thesis (single paragraph, 1-2 lines)
|
||||
4. `## ✨ Highlights` (6-12 bullets for major releases; 3-8 for weekly)
|
||||
5. Domain blocks with optional `###` subsections:
|
||||
- `## 🏗️ Core Agent & Architecture` (or equivalent product core)
|
||||
- `## 📱 Platforms / Integrations`
|
||||
- `## 🖥️ CLI & User Experience`
|
||||
- `## 🔧 Tooling`
|
||||
- `## 🔒 Security & Reliability`
|
||||
- `## 📚 Documentation` (optional if meaningful)
|
||||
6. `## 👥 Contributors`
|
||||
7. `**Full Changelog**: <prev>...<current>`
|
||||
### Writing Model (3-Pass Rewrite)
|
||||
|
||||
Use `---` separators between major blocks for long releases.
|
||||
#### Pass 1: Remove AI Vocabulary and Filler
|
||||
|
||||
### Writing Rules (Hard)
|
||||
- Replace inflated words with simple alternatives.
|
||||
- Remove transition padding like "furthermore", "notably", "it is worth noting that".
|
||||
- Cut generic importance inflation ("pivotal", "testament", "game-changer").
|
||||
- Prefer direct verbs like `run`, `customize`, `manage`, `capture`, `improve`, `fix`.
|
||||
|
||||
1. **No fabricated metrics**: all numbers must be traceable.
|
||||
2. **No vague headline bullets**: each bullet must include capability + impact.
|
||||
3. **No internal-only framing**: phrase from user/operator perspective.
|
||||
4. **Security must be explicit** when security-sensitive fixes are present.
|
||||
5. **PR/issue linkage**: use `(#1234)` when IDs are available.
|
||||
6. **Terminology consistency**: same feature/provider name across sections.
|
||||
7. **Do not bury migration or breaking changes**: elevate to dedicated section or callout.
|
||||
#### Pass 2: Break AI Sentence Patterns
|
||||
|
||||
### Style Rules (Long-Form)
|
||||
Avoid these structures:
|
||||
|
||||
1. Start with an "everyday use" framing, not implementation internals.
|
||||
2. Mix narrative sentence + evidence bullets.
|
||||
3. Keep bullets compact but informative:
|
||||
- Good: `**Fast Mode (`/fast`)** — Priority routing for OpenAI and Anthropic, reducing latency on supported models. (#6875, #6960)`
|
||||
4. Use bold only for capability names, not for whole sentences.
|
||||
5. Keep heading depth <= 3 levels.
|
||||
- Parallel negation: "Not X, but Y"
|
||||
- Tricolon overload: "A, B, and C" used repeatedly
|
||||
- Rhetorical Q + answer: "What does this mean? It means..."
|
||||
- Dramatic reveal openers: "Here's the thing", "The result?"
|
||||
- Mirror symmetry in consecutive lines
|
||||
- Overuse of em dashes
|
||||
- Every paragraph ending in tidy "lesson learned" phrasing
|
||||
|
||||
### Release Size Heuristics
|
||||
#### Pass 3: Add Human Product Texture
|
||||
|
||||
- **Minor / major milestone release**
|
||||
- Include full structure with multiple domain blocks.
|
||||
- `Highlights` usually 8-12 bullets.
|
||||
- **Weekly patch release**
|
||||
- Keep full skeleton but reduce subsection count.
|
||||
- `Highlights` usually 4-8 bullets.
|
||||
- **DB migration release**
|
||||
- Keep concise.
|
||||
- Must include `Migration overview`, operator impact, and rollback/backup note.
|
||||
- Lead with user-visible outcome, then explain mechanism.
|
||||
- Mix sentence lengths naturally.
|
||||
- Prefer straightforward phrasing over polished-but-empty language.
|
||||
- Keep confidence, but avoid launch-ad hype.
|
||||
- Write like a product team update, not a marketing page.
|
||||
|
||||
### Contributor Ordering
|
||||
### Recommended Structure Blueprint
|
||||
|
||||
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.
|
||||
Use this shape unless the user asks otherwise:
|
||||
|
||||
**LobeHub team roster** — anyone in this list is a team member; anyone not in this list is a community contributor:
|
||||
1. `# 🚀 release: ...`
|
||||
2. One opening paragraph (2-4 sentences) that explains overall user impact.
|
||||
3. 2-4 narrative capability blocks (short headings optional):
|
||||
- each block = user value + key capability
|
||||
4. `Improvements and fixes` / `体验优化与修复` with concise bullets
|
||||
5. `Credits` with required mention format
|
||||
|
||||
- @arvinxx
|
||||
- @Innei
|
||||
- @tjx666 (commit author name: YuTengjing)
|
||||
- @LiJian
|
||||
- @Neko
|
||||
- @Rdmclin2
|
||||
- @AmAzing129
|
||||
- @sudongyuer
|
||||
- @rivertwilight
|
||||
- @CanisMinor
|
||||
### Length and Reading Density (Important)
|
||||
|
||||
> **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.
|
||||
Avoid overly short release notes when the diff is substantial.
|
||||
|
||||
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.
|
||||
- Weekly release PR body:
|
||||
- Usually target 350-700 English words (or equivalent Chinese length)
|
||||
- Keep 2-4 narrative sections, each with at least one real paragraph
|
||||
- Minor release PR body:
|
||||
- Usually target 500-1000 English words (or equivalent Chinese length)
|
||||
- Allow richer context and more concrete usage scenarios
|
||||
- DB migration release PR body:
|
||||
- Keep concise, but still include context + impact + operator notes
|
||||
- If there are many commits, increase narrative depth before adding more bullets.
|
||||
- If there are few commits, stay concise and do not pad content.
|
||||
|
||||
### GitHub Release Changelog Template
|
||||
### Storytelling Contract (Major Capabilities)
|
||||
|
||||
For each major capability, write in this order:
|
||||
|
||||
1. Prior context/problem (briefly)
|
||||
2. What changed in this release
|
||||
3. Practical impact on user workflow
|
||||
|
||||
Do not collapse major capability sections into one-line bullets.
|
||||
|
||||
### Section Anatomy (Preferred)
|
||||
|
||||
Each major section should follow this internal rhythm:
|
||||
|
||||
1. Lead sentence: what changed and who benefits.
|
||||
2. Context sentence: what was painful, slow, or fragmented before.
|
||||
3. Mechanism paragraph: how the new behavior works in practice.
|
||||
4. Optional utility list (`Use X to:`) for actionable workflows.
|
||||
5. Optional availability closer when plan/platform constraints matter.
|
||||
|
||||
This pattern increases readability and makes changelogs more enjoyable to read without sacrificing precision.
|
||||
|
||||
### Section and Heading Heuristics
|
||||
|
||||
- Keep heading count low (typically 3-5).
|
||||
- Weekly release PR body target:
|
||||
- 1 opening paragraph
|
||||
- 2-4 major narrative sections
|
||||
- 1 improvements/fixes section
|
||||
- 1 credits section
|
||||
- Never produce heading-per-bullet layout.
|
||||
- If a section has 4+ bullets, convert into 2-3 short narrative paragraphs when possible.
|
||||
|
||||
### Linear-Style Block Pattern
|
||||
|
||||
Use this pattern when writing major sections:
|
||||
|
||||
```md
|
||||
# 🚀 LobeHub v<x.y.z> (<YYYYMMDD>)
|
||||
## <Capability name>
|
||||
|
||||
**Release Date:** <Month DD, YYYY>
|
||||
**Since <Previous Version>:** <N merged PRs> · <N resolved issues> · <N contributors>
|
||||
<One sentence: what users can do now and why it matters.>
|
||||
|
||||
> <One release thesis sentence: what this release unlocks in practice.>
|
||||
<One short paragraph: how this works in practice, in plain language.>
|
||||
|
||||
---
|
||||
<Optional list for workflows>
|
||||
Use <feature> to:
|
||||
- <practical action 1>
|
||||
- <practical action 2>
|
||||
- <practical action 3>
|
||||
|
||||
## ✨ Highlights
|
||||
|
||||
- **<Capability A>** — <What changed and why it matters>. (#1234)
|
||||
- **<Capability B>** — <What changed and why it matters>. (#2345)
|
||||
- **<Capability C>** — <What changed and why it matters>. (#3456)
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Core Product & Architecture
|
||||
|
||||
### <Subdomain>
|
||||
|
||||
- <Concrete change + impact>. (#...)
|
||||
- <Concrete change + impact>. (#...)
|
||||
|
||||
---
|
||||
|
||||
## 📱 Platforms / Integrations
|
||||
|
||||
- <Platform update + impact>. (#...)
|
||||
- <Compatibility/reliability fix + impact>. (#...)
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ CLI & User Experience
|
||||
|
||||
- <User-facing workflow improvement>. (#...)
|
||||
- <Quality-of-life fix>. (#...)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Tooling
|
||||
|
||||
- <Tool/runtime improvement>. (#...)
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security & Reliability
|
||||
|
||||
- **Security:** <hardening or vulnerability fix>. (#...)
|
||||
- **Reliability:** <stability/performance behavior improvement>. (#...)
|
||||
|
||||
---
|
||||
|
||||
## 👥 Contributors
|
||||
|
||||
Huge thanks to **<N contributors>** who shipped **<N merged PRs>** this cycle.
|
||||
|
||||
@<community-handle> · @<community-handle> · @<team-handle> · @<team-handle>
|
||||
|
||||
Plus @lobehubbot and renovate[bot] for maintenance.
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: <previous_tag>...<current_tag>
|
||||
<Optional availability sentence>
|
||||
```
|
||||
|
||||
### Notion-Style Readability Moves
|
||||
|
||||
Apply these moves when appropriate:
|
||||
|
||||
- Use one clear "scene" sentence to ground context (for example, what a team is doing when the feature helps).
|
||||
- Alternate paragraph lengths: one compact paragraph followed by a denser explanatory one.
|
||||
- Prefer specific nouns (`triage inbox`, `topic switch`, `mobile session`) over broad terms like "experience" or "workflow improvements".
|
||||
- Keep transitions natural (`Previously`, `Now`, `In practice`, `This means`) and avoid ornate writing.
|
||||
- End key sections with a practical takeaway sentence, not a slogan.
|
||||
|
||||
### Anti-Pattern Red Flags (Rewrite Required)
|
||||
|
||||
- "Key Updates" followed by only bullets and no narrative context
|
||||
- One bullet per feature with no prior context or user impact
|
||||
- Repeated template like "Feature X: did Y"
|
||||
- Heading-per-feature with no explanatory paragraph
|
||||
- Mechanical transitions with no causal flow
|
||||
|
||||
### EN/ZH Synchronization Rules
|
||||
|
||||
- Keep section order aligned.
|
||||
- Keep facts and scope aligned.
|
||||
- Localize naturally; avoid literal sentence mirroring.
|
||||
- If one language uses bullets for a section, the other should match style intent.
|
||||
|
||||
### Writing Tips
|
||||
|
||||
- **User-facing**: Describe changes that users can perceive, not internal implementation details
|
||||
- **Clear categories**: Group by features, models/providers, desktop, stability/fixes, etc.
|
||||
- **Highlight key items**: Use `**bold**` for important feature names
|
||||
- **Credit contributors**: Collect all committers via `git log` and list alphabetically
|
||||
- **Flexible categories**: Choose categories based on actual changes — no need to force-fit all categories
|
||||
- **Terminology enforcement**: Ensure wording follows `microcopy` skill terminology and tone constraints
|
||||
- **Linear narrative enforcement**: Follow capability -> explanation -> optional "Use X to" list
|
||||
- **Storytelling enforcement**: For major updates, write in "before -> now -> impact" order
|
||||
- **Depth enforcement**: If the diff is non-trivial, prefer complete paragraphs over compressed bullet-only summaries
|
||||
- **Pleasure-to-read enforcement**: Include concrete examples and practical scenarios so readers can imagine using the capability
|
||||
|
||||
### Quick Checklist
|
||||
|
||||
- [ ] Uses top metadata and a clear release thesis
|
||||
- [ ] Includes `Highlights` plus domain-grouped sections
|
||||
- [ ] Every major bullet states both change and user/operator impact
|
||||
- [ ] Security and reliability updates are explicitly surfaced (when present)
|
||||
- [ ] Contributor credits and compare range are included
|
||||
- [ ] All numbers and claims are verifiable
|
||||
- [ ] First paragraph explains user-visible release outcome
|
||||
- [ ] Heading count is minimal and meaningful
|
||||
- [ ] Major capabilities are short narrative paragraphs, not only bullets
|
||||
- [ ] Includes "before -> now -> impact" for major sections
|
||||
- [ ] No obvious AI patterns (parallel negation, rhetorical Q/A, dramatic reveal)
|
||||
- [ ] Vocabulary is plain, direct, and product-credible
|
||||
- [ ] Improvements/fixes remain concise and scannable
|
||||
- [ ] Credits format is preserved exactly
|
||||
- [ ] EN/ZH versions align in facts and order
|
||||
|
||||
@@ -1,60 +1,31 @@
|
||||
# 🚀 LobeHub v2.1.50 (20260416)
|
||||
# DB Schema Migration Changelog Example
|
||||
|
||||
**Release Date:** April 20, 2026\
|
||||
**Migration Scope:** Agent benchmark data model bootstrap (5 new tables, 2 new indexes)
|
||||
|
||||
> This release introduces a schema foundation for benchmark execution and reporting, so agent evaluation data is stored as a complete lifecycle instead of fragmented records.
|
||||
A changelog reference for database migration release PR bodies.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Highlights
|
||||
This release includes a **database schema migration** for Agent Evaluation Benchmark. We are adding **5 new tables** so benchmark setup, runs, and run-topic records can be stored in a complete and queryable structure.
|
||||
|
||||
- **Benchmark Lifecycle Schema** — Added a relational model that tracks benchmark setup, runs, per-topic execution, and record outputs end-to-end.
|
||||
- **Queryability Upgrade** — Added indexes for run status and benchmark-topic joins, improving operational queries in dashboard and debugging workflows.
|
||||
- **Safer Operator Rollout** — Migration is startup-driven and backward-compatible with existing non-benchmark chat workflows.
|
||||
## Migration overview
|
||||
|
||||
---
|
||||
Previously, benchmark-related data lacked a full lifecycle model, which made it harder to track evaluation flow from dataset to run results. This migration introduces the missing relational layer so benchmark configuration, execution, and analysis records stay connected.
|
||||
|
||||
## 🗄️ Migration Overview
|
||||
In practical terms, this reduces ambiguity for downstream features and gives operators a cleaner foundation for troubleshooting and reporting.
|
||||
|
||||
Added tables:
|
||||
|
||||
- `agent_eval_benchmarks`
|
||||
- `agent_eval_datasets`
|
||||
- `agent_eval_records`
|
||||
- `agent_eval_runs`
|
||||
- `agent_eval_run_topics`
|
||||
- `agent_eval_records`
|
||||
|
||||
Added indexes:
|
||||
## Notes for self-hosted users
|
||||
|
||||
- `idx_agent_eval_runs_status_created_at`
|
||||
- `idx_agent_eval_run_topics_run_id_topic_id`
|
||||
- Migration runs automatically during app startup.
|
||||
- No manual SQL action is required in standard deployments.
|
||||
- As with any schema release, we still recommend database backup and rollout during a low-traffic window.
|
||||
|
||||
These additions close a previous gap where benchmark data existed in partial forms but lacked a stable relational backbone for auditing and historical analysis.
|
||||
The migration owner: @{pr-author} — responsible for this database schema change, reach out for any migration-related issues.
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Operator Notes
|
||||
|
||||
- Migration runs automatically on application startup.
|
||||
- No manual SQL is required in standard deployment paths.
|
||||
- Schedule rollout in a low-traffic window and take a backup snapshot before deployment.
|
||||
- If migration fails, do not retry repeatedly; inspect migration logs and lock state first.
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Reliability & Risk
|
||||
|
||||
- Existing chat/session paths are unaffected unless benchmark features are enabled.
|
||||
- Migration is additive (new tables/indexes only), minimizing downgrade risk to existing entities.
|
||||
- Rollback should follow your standard DB restore or migration rollback policy if your environment requires strict reversibility.
|
||||
|
||||
---
|
||||
|
||||
## 👥 Owner
|
||||
|
||||
Migration owner: @{pr-author}
|
||||
|
||||
The migration owner is responsible for rollout follow-up and incident handling for this schema change.
|
||||
|
||||
> **Note for Claude**: Replace `{pr-author}` with the actual PR author. Retrieve via `gh pr view <number> --json author --jq '.author.login'` or from commit metadata. Do not hardcode a username.
|
||||
> **Note for Claude**: Replace `{pr-author}` with the actual PR author. Retrieve via `gh pr view <number> --json author --jq '.author.login'` or `git log` commit author. Do NOT hardcode a username.
|
||||
|
||||
@@ -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.
|
||||
@@ -1,80 +1,51 @@
|
||||
# 🚀 LobeHub v2.1.50 (20260420)
|
||||
# Patch Release (Weekly) Changelog Example
|
||||
|
||||
**Release Date:** April 20, 2026\
|
||||
**Since v2026.04.13:** 96 commits · 58 merged PRs · 31 resolved issues · 17 contributors
|
||||
|
||||
> This weekly release focuses on reducing friction in everyday agent work: faster model routing, smoother gateway behavior, stronger task continuity, and clearer operator diagnostics when something goes wrong.
|
||||
A real-world changelog reference for weekly patch release PR bodies.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Highlights
|
||||
This weekly release includes **82 commits**. The throughline is simple: less friction when moving from idea to execution. Across agent workflows, model coverage, and desktop polish, this release removes several small blockers that used to interrupt momentum.
|
||||
|
||||
- **Gateway Session Recovery** — Agent sessions now recover more reliably after short network interruptions, so long-running tasks continue with less manual retry. (#10121, #10133)
|
||||
- **Fast Model Routing** — Expanded low-latency routing for priority model tiers, reducing wait time in high-frequency generation workflows. (#10102, #10117)
|
||||
- **Agent Task Workspace** — Running tasks now remain isolated from main chat state, which keeps primary conversations cleaner while background work progresses. (#10088)
|
||||
- **Provider Coverage Update** — Added support for new model variants across OpenAI-compatible and regional providers, improving fallback options in production. (#10094, #10109)
|
||||
- **Desktop Attachment Flow** — File and screenshot attachment behavior is more predictable in desktop sessions, especially for mixed text + media prompts. (#10073)
|
||||
- **Security Hardening Pass** — Closed multiple input validation gaps in webhook and file-path handling paths. (#10141, #10152)
|
||||
The result is not one headline feature, but a noticeably smoother week-to-week experience. Teams can evaluate agents with clearer structure, ship richer media flows, and spend less time debugging provider and platform edge cases.
|
||||
|
||||
---
|
||||
## Agent workflows and media generation
|
||||
|
||||
## 🏗️ Core Agent & Architecture
|
||||
Previously, some agent evaluation and media generation flows still felt fragmented: setup was manual, discoverability was uneven, and switching between topics could interrupt context. This release adds **Agent Benchmark** support and lands the **video generation** path end-to-end, from entry point to generation feedback.
|
||||
|
||||
### Agent loop and context handling
|
||||
In practice, this means users can discover and run these workflows with fewer detours. Sidebar "new" indicators improve visibility, skeleton loading makes topic switches feel less abrupt, and memory-related controls now behave more predictably under real workload pressure.
|
||||
|
||||
- Improved context compaction thresholds to reduce mid-task exits under tight token budgets. (#10079)
|
||||
- Added better diagnostics for tool-call truncation and recovery behavior during streamed responses. (#10106)
|
||||
- Refined delegate task activity propagation to improve parent-child task status consistency. (#10098)
|
||||
We also expanded memory controls with effort and tool-permission configuration, and improved timeout calculation for memory analysis tasks so longer runs fail less often in production-like usage.
|
||||
|
||||
### Provider and model behavior
|
||||
## Models and provider coverage
|
||||
|
||||
- Unified provider-side timeout handling in fallback chains to reduce false failure classification. (#10097)
|
||||
- Updated reasoning-model defaults and response normalization for better cross-provider consistency. (#10109)
|
||||
Provider diversity matters most when teams can adopt new models without rewriting glue code every sprint. This release adds **Straico** and updates support for Claude Sonnet 4.6, Gemini 3.1 Pro Preview, Qwen3.5, Grok Imagine (`grok-imagine-image`), and MiniMax 2.5.
|
||||
|
||||
---
|
||||
Use these updates to:
|
||||
|
||||
## 📱 Gateway & Platform Integrations
|
||||
- route requests to newly available providers
|
||||
- test newer model families without custom patching
|
||||
- keep model parameters and related i18n copy aligned across providers
|
||||
|
||||
- Gateway now drains in-flight events more safely before restart, reducing duplicate notification bursts. (#10125)
|
||||
- Discord and Slack adapters received retry/backoff tuning for unstable webhook windows. (#10091, #10119)
|
||||
- WeCom callback-mode message state persistence now uses safer atomic updates. (#10114)
|
||||
This keeps model exploration practical: faster evaluation loops, fewer adaptation surprises, and cleaner cross-provider behavior.
|
||||
|
||||
---
|
||||
## Desktop and platform polish
|
||||
|
||||
## 🖥️ CLI & User Experience
|
||||
Desktop receives a set of quality-of-life upgrades that reduce "death by a thousand cuts" moments. We integrated `electron-liquid-glass` for macOS Tahoe and improved DMG background assets and packaging flow for more consistent release output.
|
||||
|
||||
- Improved slash command discoverability in CLI and gateway contexts with clearer hint messages. (#10086)
|
||||
- `/model` switching feedback now returns clearer success/failure states in cross-platform chats. (#10108)
|
||||
- Setup flow now warns earlier about missing provider credentials in first-run scenarios. (#10115)
|
||||
The desktop editor now supports image upload from the file picker, which shortens everyday authoring steps and removes one more reason to switch tools mid-task.
|
||||
|
||||
---
|
||||
## Improvements and fixes
|
||||
|
||||
## 🔧 Tooling
|
||||
- Fixed multiple video pipeline issues across precharge refund handling, webhook token verification, pricing parameter usage, asset cleanup, and type safety.
|
||||
- Fixed path traversal risk in `sanitizeFileName` and added corresponding unit tests.
|
||||
- Fixed MCP media URL generation when `APP_URL` was duplicated in output paths.
|
||||
- Fixed Qwen3 embedding failures caused by batch-size limits.
|
||||
- Fixed several UI interaction issues, including mobile header agent selector/topic count, ChatInput scrolling behavior, and tooltip stacking context.
|
||||
- Fixed missing `@napi-rs/canvas` native bindings in Docker standalone builds.
|
||||
- Improved GitHub Copilot authentication retry behavior and response error handling in edge cases.
|
||||
|
||||
- MCP registration flow now validates duplicate tool names before activation, reducing runtime conflicts. (#10093)
|
||||
- Browser tooling improved stale-session cleanup to prevent orphaned local resources. (#10112)
|
||||
## Credits
|
||||
|
||||
---
|
||||
Huge thanks to these contributors (alphabetical):
|
||||
|
||||
## 🔒 Security & Reliability
|
||||
|
||||
- **Security:** Hardened path sanitization for uploaded assets and webhook callback validation. (#10141, #10152)
|
||||
- **Reliability:** Reduced empty-response retry storms by refining retry-classification conditions. (#10130)
|
||||
- **Reliability:** Improved timeout defaults for long-running background processes in constrained environments. (#10122)
|
||||
|
||||
---
|
||||
|
||||
## 👥 Contributors
|
||||
|
||||
**58 merged PRs** from **17 contributors** across **96 commits**.
|
||||
|
||||
### Community Contributors
|
||||
|
||||
- @alice-example - Gateway recovery and retry improvements
|
||||
- @bob-example - Provider fallback normalization
|
||||
- @charlie-example - Desktop media attachment flow
|
||||
- @dora-example - Webhook validation hardening
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: v2026.04.13...v2026.04.20
|
||||
@AmAzing129 @Coooolfan @Innei @ONLY-yours @Zhouguanyang @arvinxx @eaten-cake @hezhijie0327 @nekomeowww @rdmclin2 @rivertwilight @sxjeru @tjx666
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -136,11 +136,6 @@ OPENAI_API_KEY=sk-xxxxxxxxx
|
||||
|
||||
# MOONSHOT_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# ## Kimi Code Plan ####
|
||||
|
||||
# KIMICODINGPLAN_PROXY_URL=https://api.kimi.com/coding
|
||||
# KIMICODINGPLAN_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# ## Minimax AI ####
|
||||
|
||||
# MINIMAX_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: Release ModelBank
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
id-token: write
|
||||
|
||||
on:
|
||||
@@ -41,12 +41,15 @@ jobs:
|
||||
|
||||
publish:
|
||||
name: Publish ModelBank
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -60,70 +63,27 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Bump patch version
|
||||
id: version
|
||||
run: |
|
||||
npm version patch --no-git-tag-version --prefix packages/model-bank
|
||||
echo "version=$(node -p 'require(\"./packages/model-bank/package.json\").version')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build package
|
||||
run: pnpm --filter model-bank build
|
||||
|
||||
- name: Prepare publish package
|
||||
id: version
|
||||
run: |
|
||||
BASE_VERSION=$(node -p "require('./packages/model-bank/package.json').version.split('.').slice(0, 2).join('.')")
|
||||
MODEL_BANK_VERSION="${BASE_VERSION}.$(date -u +%Y%m%d%H%M%S)"
|
||||
export MODEL_BANK_VERSION
|
||||
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
|
||||
const packagePath = 'packages/model-bank/package.json';
|
||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||
const toDistExport = (sourcePath) => sourcePath.replace('./src/', './dist/').replace(/\.ts$/, '.mjs');
|
||||
|
||||
packageJson.version = process.env.MODEL_BANK_VERSION;
|
||||
packageJson.type = 'module';
|
||||
packageJson.main = './dist/index.mjs';
|
||||
packageJson.types = './dist/index.d.mts';
|
||||
packageJson.files = ['dist'];
|
||||
packageJson.repository = {
|
||||
type: 'git',
|
||||
url: 'https://github.com/lobehub/lobehub',
|
||||
directory: 'packages/model-bank',
|
||||
};
|
||||
packageJson.exports = Object.fromEntries(
|
||||
Object.entries(packageJson.exports).map(([key, value]) => {
|
||||
if (typeof value !== 'string') return [key, value];
|
||||
|
||||
const distPath = toDistExport(value);
|
||||
|
||||
return [
|
||||
key,
|
||||
{
|
||||
types: distPath.replace(/\.mjs$/, '.d.mts'),
|
||||
import: distPath,
|
||||
default: distPath,
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
|
||||
delete packageJson.private;
|
||||
delete packageJson.devDependencies;
|
||||
delete packageJson.scripts;
|
||||
|
||||
if (packageJson.dependencies) {
|
||||
delete packageJson.dependencies['@lobechat/business-const'];
|
||||
|
||||
if (Object.keys(packageJson.dependencies).length === 0) {
|
||||
delete packageJson.dependencies;
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
NODE
|
||||
|
||||
echo "version=${MODEL_BANK_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "Prepared model-bank@${MODEL_BANK_VERSION}"
|
||||
|
||||
- name: Publish to npm
|
||||
run: npm publish --provenance --access public
|
||||
run: npm publish --provenance
|
||||
working-directory: packages/model-bank
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Commit version bump
|
||||
env:
|
||||
MODEL_BANK_VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
git config user.name "lobehubbot"
|
||||
git config user.email "i@lobehub.com"
|
||||
git add packages/model-bank/package.json
|
||||
git commit -m "🔖 chore(model-bank): release v${MODEL_BANK_VERSION}"
|
||||
git push
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@lobehub/cli",
|
||||
"version": "0.0.9",
|
||||
"version": "0.0.8",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"lh": "./dist/index.js",
|
||||
|
||||
@@ -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,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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: '* * * * *' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ export function registerTaskCommand(program: Command) {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const input: Record<string, any> = {};
|
||||
if (options.status) input.statuses = [options.status];
|
||||
if (options.status) input.status = options.status;
|
||||
if (options.root) input.parentTaskId = null;
|
||||
if (options.parent) input.parentTaskId = options.parent;
|
||||
if (options.agent) input.assigneeAgentId = options.agent;
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
## 专题文档
|
||||
|
||||
- [桌面端全屏 Overlay 截图方案设计与集成说明](./WindowOverlayCapture.md)
|
||||
|
||||
## 核心框架组件目录架构
|
||||
|
||||
### 主进程核心组件
|
||||
|
||||
@@ -1,502 +0,0 @@
|
||||
# 桌面端全屏 Overlay 截图方案设计与集成说明
|
||||
|
||||
| 字段 | 内容 |
|
||||
| ------------ | ----------------------------------------------------- |
|
||||
| 状态 | 已完成技术预研与 demo 验证 |
|
||||
| 最后更新 | 2026-04-14 |
|
||||
| 适用范围 | Electron 桌面端全屏遮罩、窗口高亮、点击截窗、区域截图 |
|
||||
| 当前验证载体 | `tmp/electron-window-overlay-demo` |
|
||||
| 目标读者 | 后续将该能力接入 LobeHub Desktop 主业务的开发者 |
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
本文档用于沉淀以下内容:
|
||||
|
||||
| 目标 | 说明 |
|
||||
| -------------------- | ------------------------------------------------------------- |
|
||||
| 记录方案演进 | 保存从纯 Electron、native、自研、开源库到最终 demo 的决策过程 |
|
||||
| 固化关键技术结论 | 明确哪些能力 Electron 可做,哪些能力必须借助额外库 |
|
||||
| 提供业务接入蓝图 | 指出应修改的真实仓库文件、模块边界、IPC 设计与 UI 接入点 |
|
||||
| 降低后续重复调研成本 | 使后续实现可以直接沿用本文档,不必重新验证底层假设 |
|
||||
|
||||
## 2. 需求回顾
|
||||
|
||||
| 需求项 | 结论 |
|
||||
| ----------------------------------- | --------------------------------------------------- |
|
||||
| 新增一个 “全屏” 入口 | 需要,但本质上是一个覆盖整块屏幕的透明 overlay 窗口 |
|
||||
| 覆盖用户整个 screen | 需要,且在 macOS 上要覆盖菜单栏与 Dock 所在区域 |
|
||||
| 获取系统窗口几何信息 | 需要,至少需要 `appName + bounds + windowId` |
|
||||
| 在 overlay 上高亮窗口边框并显示 Tag | 需要 |
|
||||
| 点击高亮窗口即截图该窗口 | 需要 |
|
||||
| 拖拽任意区域截图 | 需要 |
|
||||
| 输出先写入剪贴板 | 需要,作为 MVP |
|
||||
| 避免自研 native addon | 明确要求避免 |
|
||||
| 跨平台预留 | 需要,至少不能被 macOS-only 自研方案锁死 |
|
||||
|
||||
## 3. 关键术语澄清
|
||||
|
||||
### 3.1 “压住 macOS 菜单栏与 Dock” 的准确含义
|
||||
|
||||
这里的含义不是 “调用系统 fullscreen API”,而是:
|
||||
|
||||
| 项目 | 含义 |
|
||||
| -------- | ------------------------------------------------------------ |
|
||||
| 覆盖范围 | 窗口尺寸必须基于 `display.bounds`,而不是 `display.workArea` |
|
||||
| Z 轴层级 | 窗口需要位于普通应用窗口之上,并且进入菜单栏所在区域 |
|
||||
| 视觉效果 | 用户看到的是整块屏幕都被半透明遮罩覆盖 |
|
||||
|
||||
必须区分以下两件事:
|
||||
|
||||
| 易混概念 | 实际含义 |
|
||||
| ----------------------------------- | ---------------------------------------------------- |
|
||||
| `app.dock.hide()` | 仅隐藏应用在 Dock 中的图标,不会隐藏系统 Dock 栏本身 |
|
||||
| `BrowserWindow.setFullScreen(true)` | 更接近原生全屏行为,未必适合作为截图 overlay |
|
||||
|
||||
## 4. 预研结论总览
|
||||
|
||||
### 4.1 方案对比
|
||||
|
||||
| 方案 | 能否覆盖菜单栏 / Dock | 能否拿到系统窗口 bounds | 能否按窗口截图 | 跨平台性 | 结论 |
|
||||
| ------------------------------------- | --------------------: | ----------------------: | -----------------: | -------: | -------------------------- |
|
||||
| 纯 Electron `desktopCapturer` | 是 | 否 | 部分可做,但不精确 | 高 | 不足以满足需求 |
|
||||
| 自研 native addon | 是 | 是 | 是 | 中 | 能做,但被明确拒绝 |
|
||||
| 参考 Claude.app 的 native quick entry | 是 | 是 | 是 | 低到中 | 可借鉴思路,不适合直接照搬 |
|
||||
| `node-screenshots` 单库 | 是 | 是 | 是 | 中到高 | 核心方案成立 |
|
||||
| `node-screenshots + get-windows` | 是 | 是 | 是 | 中到高 | 当前最终方案 |
|
||||
|
||||
### 4.2 最终选型
|
||||
|
||||
| 能力 | 最终实现 |
|
||||
| --------------------- | -------------------------- |
|
||||
| 全屏 overlay 窗口 | Electron `BrowserWindow` |
|
||||
| 系统窗口枚举 | `node-screenshots` |
|
||||
| 指定窗口截图 | `node-screenshots` |
|
||||
| 隐藏 / 伪关闭窗口过滤 | `get-windows` 作为白名单 |
|
||||
| 区域截图 | Electron `desktopCapturer` |
|
||||
| 输出介质 | `clipboard.writeImage()` |
|
||||
|
||||
## 5. 对 Claude.app 的观察结论
|
||||
|
||||
本轮曾直接检查过本机解包后的 Claude.app 产物,结论如下:
|
||||
|
||||
| 观察对象 | 结论 |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------------ |
|
||||
| `quick_window` | 不是全屏 overlay;它是小尺寸 `panel` 弹窗 |
|
||||
| `nativeQuickEntry` | Claude.app 存在原生 quick entry 能力,说明其真实覆盖式入口并不完全依赖纯 Electron |
|
||||
| `cu-glow` | 这是最接近本需求的 Electron overlay 实现:使用 `display.bounds`、透明窗、`screen-saver` 置顶层级 |
|
||||
|
||||
据此可以得出两个重要判断:
|
||||
|
||||
| 判断 | 含义 |
|
||||
| -------------------------------------------- | ---- |
|
||||
| Electron 可以做 “整屏遮罩” | 成立 |
|
||||
| Claude 的 “整屏入口” 并不等于 `quick_window` | 成立 |
|
||||
|
||||
## 6. 当前 demo 的最终方案
|
||||
|
||||
### 6.1 架构图
|
||||
|
||||
```text
|
||||
┌──────────────────────────────┐
|
||||
│ Tray / Menu / Future Action │
|
||||
└──────────────┬───────────────┘
|
||||
│ startOverlaySession
|
||||
▼
|
||||
┌────────────────────────────────────────────┐
|
||||
│ Main Process │
|
||||
│ │
|
||||
│ 1. 选定当前光标所在 display │
|
||||
│ 2. 枚举窗口:node-screenshots │
|
||||
│ 3. 过滤隐藏窗口:get-windows 白名单 │
|
||||
│ 4. 创建整屏 overlay BrowserWindow │
|
||||
└──────────────┬─────────────────────────────┘
|
||||
│ preload / IPC
|
||||
▼
|
||||
┌────────────────────────────────────────────┐
|
||||
│ Overlay Renderer │
|
||||
│ │
|
||||
│ 1. 渲染窗口高亮框与左上角 tag │
|
||||
│ 2. 点击窗口 => captureWindow(windowId) │
|
||||
│ 3. 拖拽区域 => captureRect(rect) │
|
||||
└──────────────┬─────────────────────────────┘
|
||||
│ IPC
|
||||
▼
|
||||
┌────────────────────────────────────────────┐
|
||||
│ Main Process Capture Path │
|
||||
│ │
|
||||
│ Window: node-screenshots.captureImage() │
|
||||
│ Region: desktopCapturer + crop │
|
||||
│ Output: clipboard.writeImage() │
|
||||
└────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.2 demo 文件职责
|
||||
|
||||
| 文件 | 作用 |
|
||||
| -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- |
|
||||
| [`tmp/electron-window-overlay-demo/main.mjs`](../../tmp/electron-window-overlay-demo/main.mjs) | 主进程入口;创建 overlay,枚举窗口,执行截图 |
|
||||
| [`tmp/electron-window-overlay-demo/preload.cjs`](../../tmp/electron-window-overlay-demo/preload.cjs) | 为 overlay renderer 暴露 IPC bridge |
|
||||
| [`tmp/electron-window-overlay-demo/renderer/index.html`](../../tmp/electron-window-overlay-demo/renderer/index.html) | overlay 渲染宿主页 |
|
||||
| [`tmp/electron-window-overlay-demo/renderer/app.js`](../../tmp/electron-window-overlay-demo/renderer/app.js) | 窗口高亮、点击截窗、拖拽截区交互 |
|
||||
| [`tmp/electron-window-overlay-demo/renderer/styles.css`](../../tmp/electron-window-overlay-demo/renderer/styles.css) | 遮罩视觉样式 |
|
||||
| [`tmp/electron-window-overlay-demo/README.md`](../../tmp/electron-window-overlay-demo/README.md) | demo 的运行说明 |
|
||||
|
||||
## 7. 全屏 overlay 的关键实现参数
|
||||
|
||||
### 7.1 必要窗口参数
|
||||
|
||||
| 参数 / 调用 | 用途 | 必要性 |
|
||||
| ----------------------------------- | ---------------------------------- | ------ |
|
||||
| `x/y/width/height = display.bounds` | 覆盖整块屏幕,包括菜单栏区域 | 必需 |
|
||||
| `transparent: true` | 允许渲染半透明遮罩 | 必需 |
|
||||
| `frame: false` | 去除系统边框 | 必需 |
|
||||
| `skipTaskbar: true` | 避免出现在任务栏 / Dock 窗口列表中 | 建议 |
|
||||
| `hasShadow: false` | 避免覆盖层产生自身投影 | 建议 |
|
||||
| `focusable: true` | 允许接收鼠标交互 | 必需 |
|
||||
| `fullscreenable: false` | 避免进入原生 fullscreen 流程 | 建议 |
|
||||
| `enableLargerThanScreen: true` | 提升跨平台稳健性 | 建议 |
|
||||
| `type: 'panel'`(macOS) | 更接近工具层窗口行为 | 建议 |
|
||||
|
||||
### 7.2 必要层级调用
|
||||
|
||||
| 调用 | 作用 |
|
||||
| ---------------------------------------------------------------- | --------------------------------- |
|
||||
| `setAlwaysOnTop(true, 'screen-saver')` | 让窗口位于更高层级 |
|
||||
| `setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })` | 避免 Space / 全屏窗口场景下不可见 |
|
||||
| `setHiddenInMissionControl(true)` | 降低该窗口对系统窗口管理的干扰 |
|
||||
|
||||
### 7.3 重要结论
|
||||
|
||||
| 结论 | 说明 |
|
||||
| ------------------------- | ------------------------------------------- |
|
||||
| `display.workArea` 不可用 | 它会排除菜单栏 / Dock 区域 |
|
||||
| `display.bounds` 必须使用 | 只有它能覆盖整个 display |
|
||||
| `screen-saver` 层级有效 | 这是当前 macOS 上最接近需求的 Electron 方案 |
|
||||
|
||||
## 8. 系统窗口枚举与过滤策略
|
||||
|
||||
### 8.1 为什么不能只用 Electron
|
||||
|
||||
| Electron 能力 | 缺口 |
|
||||
| --------------------------------------------------- | --------------------------------------------------------- |
|
||||
| `desktopCapturer.getSources({ types: ['window'] })` | 能列出可捕获源,但没有稳定的窗口 bounds 用于 overlay 画框 |
|
||||
| `DesktopCapturerSource.thumbnail` | 可截图缩略图,但不适合 “按原窗口精确高亮 + 点击即截” |
|
||||
|
||||
因此,纯 Electron 不足以完成 “系统窗口高亮 + 点击截窗”。
|
||||
|
||||
### 8.2 `node-screenshots` 的职责
|
||||
|
||||
| API | 用途 |
|
||||
| --------------------------------- | -------------- |
|
||||
| `Window.all()` | 枚举系统窗口 |
|
||||
| `window.id()` | 稳定识别窗口 |
|
||||
| `window.appName()` | 获取应用名 |
|
||||
| `window.title()` | 获取标题 |
|
||||
| `window.x()/y()/width()/height()` | 获取几何信息 |
|
||||
| `window.captureImage()` | 截取该窗口图像 |
|
||||
|
||||
### 8.3 `get-windows` 的职责
|
||||
|
||||
`get-windows` 在当前方案中不负责截图,而只负责 “第二层白名单过滤”。
|
||||
|
||||
| 问题 | 处理方式 |
|
||||
| ------------------------------------------ | ------------------------------------------------------------- |
|
||||
| 某些应用逻辑上已隐藏,但底层枚举仍可能残留 | 只保留同时出现在 `get-windows` 与 `node-screenshots` 中的窗口 |
|
||||
| Electron 自身的假关闭 /hide 行为 | 该白名单对这类情况更稳 |
|
||||
|
||||
### 8.4 当前过滤规则
|
||||
|
||||
| 规则 | 目的 |
|
||||
| ------------------------------------------------ | ---------------------------- |
|
||||
| `isMinimized() === false` | 排除最小化窗口 |
|
||||
| 最小尺寸阈值:`80x60` | 排除菜单栏控件、过小悬浮面板 |
|
||||
| 排除 `Dock` / `Window Server` / `Control Centre` | 排除系统 UI |
|
||||
| 排除 demo 自身窗口 | 避免 overlay 自我高亮 |
|
||||
| 必须与目标 display 相交 | 只画当前屏幕可见窗口 |
|
||||
| 必须出现在 `get-windows` 白名单中 | 排除隐藏 / 伪关闭残留窗口 |
|
||||
|
||||
## 9. 截图路径设计
|
||||
|
||||
### 9.1 点击窗口截图
|
||||
|
||||
```text
|
||||
点击高亮框
|
||||
└───> renderer 发送 windowId
|
||||
└───> main 查找对应 node-screenshots Window
|
||||
└───> overlay.hide()
|
||||
└───> captureImage()
|
||||
└───> PNG Buffer
|
||||
└───> nativeImage
|
||||
└───> clipboard.writeImage()
|
||||
```
|
||||
|
||||
### 9.2 拖拽区域截图
|
||||
|
||||
```text
|
||||
拖拽区域
|
||||
└───> renderer 发送全局 rect
|
||||
└───> main 隐藏 overlay
|
||||
└───> desktopCapturer 获取目标 display 图像
|
||||
└───> 按 scaleFactor 计算 cropRect
|
||||
└───> clipboard.writeImage()
|
||||
```
|
||||
|
||||
### 9.3 为什么两条路径采用不同技术
|
||||
|
||||
| 路径 | 技术 | 原因 |
|
||||
| ---------- | ------------------ | --------------------------------- |
|
||||
| 按窗口截图 | `node-screenshots` | 它天然理解 “窗口” 这一对象 |
|
||||
| 按区域截图 | `desktopCapturer` | 区域本质上是 display 上的矩形裁剪 |
|
||||
|
||||
## 10. 权限与平台边界
|
||||
|
||||
### 10.1 macOS 权限
|
||||
|
||||
| 权限 | 是否需要 | 用途 |
|
||||
| ---------------- | ---------------- | ----------------------------------------------------- |
|
||||
| Screen Recording | 需要 | 窗口截图、区域截图 |
|
||||
| Accessibility | 当前方案不强依赖 | `get-windows` 已使用 `accessibilityPermission: false` |
|
||||
|
||||
### 10.2 当前已知平台边界
|
||||
|
||||
| 平台 / 场景 | 状态 | 说明 |
|
||||
| ------------- | -------- | --------------------------------------------------------------------- |
|
||||
| macOS | 已验证 | 当前主要验证平台 |
|
||||
| Windows | 理论可行 | `node-screenshots` / `get-windows` 均支持,但尚未在本仓库内做实机验证 |
|
||||
| Linux X11 | 理论可行 | 需要单独验证打包与权限 |
|
||||
| Linux Wayland | 风险较高 | 上游库虽宣称支持,但必须做专项验证 |
|
||||
|
||||
### 10.3 特殊窗口风险
|
||||
|
||||
| 风险类型 | 当前处理 |
|
||||
| ---------------------- | -------------------------------------------------------------- |
|
||||
| 菜单栏状态窗 / 面板 | 通过尺寸阈值与排除名单降低噪音 |
|
||||
| 系统 UI | 通过应用名黑名单排除 |
|
||||
| 某些应用截图结果为黑图 | 已观察到个别状态面板存在此现象,应在业务层继续限制候选窗口类别 |
|
||||
|
||||
## 11. 已完成验证
|
||||
|
||||
| 验证项 | 结果 | 产物 |
|
||||
| ----------------------------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| overlay 覆盖整屏 | 通过 | [`tmp/electron-window-overlay-demo/.cache/window-overlay-visual.png`](../../tmp/electron-window-overlay-demo/.cache/window-overlay-visual.png) |
|
||||
| `node-screenshots` 直接截图普通窗口 | 通过 | [`tmp/electron-window-overlay-demo/.cache/cursor-direct.png`](../../tmp/electron-window-overlay-demo/.cache/cursor-direct.png) |
|
||||
| 点击高亮窗口后写入剪贴板 | 通过 | [`tmp/electron-window-overlay-demo/.cache/window-capture-probe-final.png`](../../tmp/electron-window-overlay-demo/.cache/window-capture-probe-final.png) |
|
||||
| 拖拽区域截图 | 通过 | [`tmp/electron-window-overlay-demo/.cache/self-test-clipboard-final.png`](../../tmp/electron-window-overlay-demo/.cache/self-test-clipboard-final.png) |
|
||||
|
||||
## 12. 推荐的业务接入方式
|
||||
|
||||
### 12.1 总体建议
|
||||
|
||||
| 维度 | 建议 |
|
||||
| -------------------- | ---------------------------------------------------------------------------------- |
|
||||
| overlay 窗口生命周期 | 不建议直接挂进现有 `BrowserManager` 的常规窗口体系 |
|
||||
| 原因 | overlay 是瞬态、全屏、平台特化、不可持久化的工具窗口,与主业务窗口生命周期明显不同 |
|
||||
| 推荐做法 | 新增独立主进程模块管理 overlay;渲染内容仍建议走现有 SPA 路由体系 |
|
||||
|
||||
### 12.2 为什么不直接复用 `BrowserManager`
|
||||
|
||||
| 观察 | 影响 |
|
||||
| ----------------------------------------- | ------------------------------- |
|
||||
| `Browser` 默认承担普通业务窗口职责 | overlay 并非普通业务窗口 |
|
||||
| `WindowStateManager` 倾向保存窗口状态 | overlay 不应持久化位置与大小 |
|
||||
| `BrowserManager` 以 “可复用业务窗口” 建模 | overlay 更接近 “一次性工具会话” |
|
||||
|
||||
因此,更合理的做法是:
|
||||
|
||||
```text
|
||||
┌────────────────────────────┐
|
||||
│ BrowserManager │ 负责常规业务窗口
|
||||
└────────────────────────────┘
|
||||
|
||||
┌────────────────────────────┐
|
||||
│ CaptureOverlayManager │ 负责全屏截图 overlay 会话
|
||||
└────────────────────────────┘
|
||||
```
|
||||
|
||||
## 13. 建议的生产代码落点
|
||||
|
||||
### 13.1 主进程
|
||||
|
||||
| 建议文件 | 作用 |
|
||||
| ---------------------------------------------------------------------- | ------------------------------------------------------------------ |
|
||||
| `apps/desktop/src/main/modules/screenCapture/CaptureOverlayManager.ts` | 创建 / 销毁 overlay 窗口;管理一次截图会话 |
|
||||
| `apps/desktop/src/main/modules/screenCapture/WindowSourceService.ts` | 封装 `node-screenshots + get-windows` 的窗口枚举与过滤 |
|
||||
| `apps/desktop/src/main/modules/screenCapture/CaptureService.ts` | 封装窗口截图、区域截图、剪贴板输出 |
|
||||
| `apps/desktop/src/main/modules/screenCapture/permission.ts` | 封装 macOS 屏幕录制权限检查 |
|
||||
| `apps/desktop/src/main/controllers/ScreenCaptureCtr.ts` | 对 renderer 暴露 `start / captureRect / captureWindow / close` IPC |
|
||||
| `apps/desktop/src/main/controllers/registry.ts` | 注册 `ScreenCaptureCtr` |
|
||||
|
||||
### 13.2 IPC 类型
|
||||
|
||||
| 建议文件 | 作用 |
|
||||
| --------------------------------------------------------- | ------------------------------------------------- |
|
||||
| `packages/electron-client-ipc/src/types/screenCapture.ts` | 定义 overlay 会话、窗口元数据、截图参数与返回结果 |
|
||||
| `packages/electron-client-ipc/src/types/index.ts` | 导出新类型 |
|
||||
|
||||
建议定义的核心类型:
|
||||
|
||||
| 类型名 | 用途 |
|
||||
| -------------------------- | --------------------------------------------------- |
|
||||
| `ScreenCaptureDisplayInfo` | display id / bounds / scaleFactor |
|
||||
| `ScreenCaptureWindowInfo` | `windowId/appName/title/bounds/overlayBounds/order` |
|
||||
| `ScreenCaptureSession` | `display + windows` |
|
||||
| `CaptureRectParams` | 全局屏幕坐标的矩形 |
|
||||
| `ScreenCaptureStartResult` | 权限状态、会话状态、错误信息 |
|
||||
| `ScreenCaptureOutput` | `clipboard`、后续可扩展 `file`、`attachment` |
|
||||
|
||||
### 13.3 Preload 与 renderer service
|
||||
|
||||
| 建议文件 | 作用 |
|
||||
| ----------------------------------------- | -------------------------------------------------- |
|
||||
| `apps/desktop/src/preload/electronApi.ts` | 通常无需特殊改造;沿用统一 `invoke` 即可 |
|
||||
| `src/services/electron/screenCapture.ts` | 前端统一调用 `ensureElectronIpc().screenCapture.*` |
|
||||
|
||||
### 13.4 Renderer 路由
|
||||
|
||||
生产环境存在两种可选实现:
|
||||
|
||||
| 方案 | 优点 | 缺点 | 建议 |
|
||||
| ------------------ | -------------------------------- | -------------------------------- | ---------------- |
|
||||
| 独立静态 HTML 页面 | 轻量、与业务隔离、最接近 demo | 与现有 React/i18n / 业务状态脱节 | 仅适合 spike |
|
||||
| 独立桌面 SPA 路由 | 可复用现有构建、i18n、业务事件链 | 需要维护 desktop router 双配置 | **推荐生产使用** |
|
||||
|
||||
若采用 SPA 路由,建议新增:
|
||||
|
||||
| 建议文件 | 作用 |
|
||||
| ------------------------------------------------------- | ------------------------------------ |
|
||||
| `src/routes/(desktop)/screen-capture-overlay/index.tsx` | overlay 页面入口;仅负责挂载 UI 组件 |
|
||||
| `src/features/DesktopScreenCaptureOverlay/*` | 业务组件、hooks、样式 |
|
||||
| `src/spa/router/desktopRouter.config.tsx` | 动态路由配置 |
|
||||
| `src/spa/router/desktopRouter.config.desktop.tsx` | 同步路由配置 |
|
||||
|
||||
必须注意:
|
||||
|
||||
| 规则 | 说明 |
|
||||
| -------------------------------- | ------------------------------------ |
|
||||
| 两份 desktop router 必须同时更新 | 否则 Electron 本地构建可能出现空白页 |
|
||||
| overlay route 应保持极薄 | 不在 route 文件中堆叠业务逻辑 |
|
||||
|
||||
## 14. 托盘入口的真实接入点
|
||||
|
||||
若要从托盘启动 overlay,会涉及以下文件:
|
||||
|
||||
| 文件 | 作用 |
|
||||
| ----------------------------------------------- | -------------------- |
|
||||
| `apps/desktop/src/main/menus/impls/macOS.ts` | macOS 托盘菜单模板 |
|
||||
| `apps/desktop/src/main/menus/impls/windows.ts` | Windows 托盘菜单模板 |
|
||||
| `apps/desktop/src/main/menus/impls/linux.ts` | Linux 托盘菜单模板 |
|
||||
| `apps/desktop/src/main/locales/default/menu.ts` | 托盘菜单文案 |
|
||||
|
||||
推荐新增文案键:
|
||||
|
||||
| Key | 语义 |
|
||||
| -------------------------- | ------------------------ |
|
||||
| `tray.captureScreen` | 启动截图 overlay |
|
||||
| `tray.captureScreenWindow` | 启动窗口截图模式(可选) |
|
||||
|
||||
## 15. 业务接入分阶段计划
|
||||
|
||||
### 阶段一:桌面主进程能力落地
|
||||
|
||||
| 步骤 | 目标 |
|
||||
| ---- | ---------------------------------------------------------------------------------- |
|
||||
| 1 | 将 `node-screenshots`、`get-windows` 加入 `apps/desktop/package.json#dependencies` |
|
||||
| 2 | 新建 `screenCapture` 主进程模块与 controller |
|
||||
| 3 | 跑通托盘菜单触发 overlay |
|
||||
| 4 | 继续以剪贴板为唯一输出 |
|
||||
|
||||
### 阶段二:接回现有业务 UI
|
||||
|
||||
| 步骤 | 目标 |
|
||||
| ---- | -------------------------------------------------- |
|
||||
| 1 | 新增桌面专用 overlay route /feature |
|
||||
| 2 | 将截图结果从 “仅写剪贴板” 升级为 “回传 attachment” |
|
||||
| 3 | 支持从 chat 输入区触发 |
|
||||
| 4 | 支持截图后自动插入当前会话 |
|
||||
|
||||
### 阶段三:体验完善
|
||||
|
||||
| 步骤 | 目标 |
|
||||
| ---- | ------------------------------------ |
|
||||
| 1 | 多 display 支持 |
|
||||
| 2 | Hover 高亮 / 文案优化 |
|
||||
| 3 | 保存文件、编辑器标注、OCR 等增强能力 |
|
||||
| 4 | 平台差异补齐(尤其 Windows / Linux) |
|
||||
|
||||
## 16. 依赖落点与版本建议
|
||||
|
||||
### 16.1 应加入的位置
|
||||
|
||||
| 文件 | 说明 |
|
||||
| --------------------------- | --------------------------------- |
|
||||
| `apps/desktop/package.json` | Electron 桌面运行时的真实依赖落点 |
|
||||
|
||||
### 16.2 建议依赖
|
||||
|
||||
| 包名 | 用途 | 当前 demo 使用版本 |
|
||||
| ------------------ | --------------------------- | ------------------ |
|
||||
| `node-screenshots` | 枚举窗口 + 窗口截图 | `^0.2.8` |
|
||||
| `get-windows` | 白名单过滤隐藏 / 伪关闭窗口 | `^9.3.0` |
|
||||
|
||||
说明:
|
||||
|
||||
| 项目 | 结论 |
|
||||
| ---------------------------- | ---- |
|
||||
| 这不是 “纯 Electron” 方案 | 成立 |
|
||||
| 这也不是 “自研 native addon” | 成立 |
|
||||
| 当前依赖的是开源原生库 | 成立 |
|
||||
|
||||
## 17. 测试建议
|
||||
|
||||
建议避免写 “窗口列表快照” 这类低信号测试,优先做行为测试。
|
||||
|
||||
| 测试层级 | 建议内容 |
|
||||
| -------------- | ---------------------------------------------------------- |
|
||||
| 单元测试 | 过滤逻辑:尺寸阈值、系统应用排除、自身窗口排除、白名单交集 |
|
||||
| 主进程集成测试 | 权限失败、overlay 会话生命周期、错误分支 |
|
||||
| 手工验证 | 菜单栏覆盖、点击截窗、拖拽截区、隐藏窗口过滤 |
|
||||
|
||||
建议手工验证清单:
|
||||
|
||||
| 检查项 | 期望 |
|
||||
| ------------------------ | ------------------------ |
|
||||
| 当前活动屏幕启动 overlay | 只覆盖当前目标 display |
|
||||
| 已隐藏的 Electron 子窗口 | 不再出现边框 |
|
||||
| 点击普通应用窗口 | 剪贴板中得到该窗口图像 |
|
||||
| 拖拽区域截图 | 剪贴板中得到对应裁剪区域 |
|
||||
| 取消操作 | `Esc` 可关闭 overlay |
|
||||
|
||||
## 18. 当前已确认的非目标
|
||||
|
||||
| 非目标 | 说明 |
|
||||
| ----------------------------------- | ----------------------------------------------------------------------- |
|
||||
| 当前阶段支持全平台一致体验 | 尚未完成 |
|
||||
| 当前阶段支持窗口标题绝对准确 | `get-windows` 在无额外权限时标题可为空;当前主要依赖 `node-screenshots` |
|
||||
| 当前阶段支持多 display 同时 overlay | 尚未实现 |
|
||||
| 当前阶段支持标注编辑器 | 未实现 |
|
||||
|
||||
## 19. 后续实现时的推荐决策
|
||||
|
||||
| 决策点 | 推荐 |
|
||||
| ----------------------------------------------- | ------------------------ |
|
||||
| overlay 窗口是否复用 `BrowserManager` | 不推荐 |
|
||||
| renderer 是否走 SPA route | 推荐 |
|
||||
| 主进程是否继续保留 “剪贴板优先” 输出 | 推荐,先保持最小可用闭环 |
|
||||
| 是否继续保留 `desktopCapturer` 作为区域截图路径 | 推荐 |
|
||||
| 是否用 `get-windows` 继续做白名单过滤 | 推荐 |
|
||||
|
||||
## 20. 实施摘要
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ 已验证的技术事实 │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ 1. Electron 可以创建覆盖整块 display 的窗体 │
|
||||
│ 2. 纯 Electron 无法独立完成系统窗口高亮 │
|
||||
│ 3. node-screenshots 可完成窗口枚举与截窗 │
|
||||
│ 4. get-windows 可帮助过滤隐藏 / 残留窗口 │
|
||||
│ 5. 最终可形成“点击窗口即截图 + 拖拽截区”闭环 │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
本文档可视为后续将该能力正式接入 `apps/desktop` 主业务的实施基线。
|
||||
@@ -255,7 +255,6 @@ const config = {
|
||||
generateUpdatesFilesForAllChannels: true,
|
||||
linux: {
|
||||
category: 'Utility',
|
||||
icon: 'build/icon.png',
|
||||
maintainer: 'electronjs.org',
|
||||
target: ['AppImage', 'snap', 'deb', 'rpm', 'tar.gz'],
|
||||
},
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin';
|
||||
import dotenv from 'dotenv';
|
||||
import { defineConfig } from 'electron-vite';
|
||||
import type { PluginOption, ViteDevServer } from 'vite';
|
||||
@@ -53,11 +52,6 @@ function electronDesktopHtmlPlugin(): PluginOption {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
if (pathname === '/overlay' || pathname === '/overlay.html') {
|
||||
req.url = '/apps/desktop/overlay.html';
|
||||
next();
|
||||
return;
|
||||
}
|
||||
if (pathname === '/popup.html') {
|
||||
req.url = '/apps/desktop/popup.html';
|
||||
next();
|
||||
@@ -98,8 +92,6 @@ const updateChannel = process.env.UPDATE_CHANNEL;
|
||||
const desktopPackageJson = JSON.parse(
|
||||
readFileSync(path.resolve(__dirname, 'package.json'), 'utf8'),
|
||||
) as { version: string };
|
||||
const electronRuntimeExternals = ['electron'];
|
||||
const mainProcessRuntimeExternals = [...electronRuntimeExternals, 'node-mac-permissions'];
|
||||
|
||||
console.info(`[electron-vite.config.ts] Detected UPDATE_CHANNEL: ${updateChannel}`);
|
||||
|
||||
@@ -108,15 +100,10 @@ export default defineConfig({
|
||||
build: {
|
||||
minify: !isDev,
|
||||
outDir: 'dist/main',
|
||||
rolldownOptions: {
|
||||
rollupOptions: {
|
||||
// Native modules must be externalized to work correctly.
|
||||
// bufferutil and utf-8-validate are optional peer deps of ws that may not be installed.
|
||||
external: [
|
||||
...mainProcessRuntimeExternals,
|
||||
...getExternalDependencies(),
|
||||
'bufferutil',
|
||||
'utf-8-validate',
|
||||
],
|
||||
external: [...getExternalDependencies(), 'bufferutil', 'utf-8-validate'],
|
||||
output: {
|
||||
// Prevent debug package from being bundled into index.js to avoid side-effect pollution
|
||||
manualChunks(id) {
|
||||
@@ -150,9 +137,6 @@ export default defineConfig({
|
||||
build: {
|
||||
minify: !isDev,
|
||||
outDir: 'dist/preload',
|
||||
rolldownOptions: {
|
||||
external: electronRuntimeExternals,
|
||||
},
|
||||
sourcemap: isDev ? 'inline' : false,
|
||||
},
|
||||
resolve: {
|
||||
@@ -166,10 +150,9 @@ export default defineConfig({
|
||||
root: ROOT_DIR,
|
||||
build: {
|
||||
outDir: path.resolve(__dirname, 'dist/renderer'),
|
||||
rolldownOptions: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: path.resolve(__dirname, 'index.html'),
|
||||
overlay: path.resolve(__dirname, 'overlay.html'),
|
||||
popup: path.resolve(__dirname, 'popup.html'),
|
||||
},
|
||||
output: sharedRollupOutput,
|
||||
@@ -183,12 +166,10 @@ export default defineConfig({
|
||||
plugins: [
|
||||
forceAbsoluteBasePlugin(),
|
||||
electronDesktopHtmlPlugin(),
|
||||
vanillaExtractPlugin(),
|
||||
...(sharedRendererPlugins({ platform: 'desktop' }) as PluginOption[]),
|
||||
],
|
||||
resolve: {
|
||||
dedupe: ['react', 'react-dom'],
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -36,8 +36,7 @@ export const nativeModules = [
|
||||
// macOS-only native modules
|
||||
...(isDarwin ? ['node-mac-permissions'] : []),
|
||||
'@napi-rs/canvas',
|
||||
'get-windows',
|
||||
'node-screenshots',
|
||||
// Add more native modules here as needed
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body, #root { width: 100%; height: 100%; overflow: hidden; background: transparent; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/apps/desktop/src/overlay/entry.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -42,10 +42,7 @@
|
||||
"update-server": "sh scripts/update-test/run-test.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lobehub/fluent-emoji": "^4.1.0",
|
||||
"@napi-rs/canvas": "^0.1.70",
|
||||
"get-windows": "^9.3.0",
|
||||
"node-screenshots": "^0.2.8"
|
||||
"@napi-rs/canvas": "^0.1.70"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
||||
@@ -67,21 +64,19 @@
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/set-cookie-parser": "^2.4.10",
|
||||
"@typescript/native-preview": "7.0.0-dev.20251210.1",
|
||||
"@vanilla-extract/css": "^1.17.4",
|
||||
"@vanilla-extract/vite-plugin": "^5.1.0",
|
||||
"async-retry": "^1.3.3",
|
||||
"consola": "^3.4.2",
|
||||
"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",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-store": "^8.2.0",
|
||||
"electron-updater": "^6.6.2",
|
||||
"electron-vite": "6.0.0-beta.1",
|
||||
"electron-vite": "^5.0.0",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"es-toolkit": "^1.43.0",
|
||||
"eslint": "10.0.0",
|
||||
@@ -101,14 +96,13 @@
|
||||
"resolve": "^1.22.11",
|
||||
"semver": "^7.7.3",
|
||||
"set-cookie-parser": "^2.7.2",
|
||||
"strip-ansi": "6.0.1",
|
||||
"stylelint": "^15.11.0",
|
||||
"superjson": "^2.2.6",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"undici": "^7.16.0",
|
||||
"uuid": "^14.0.0",
|
||||
"vite": "^8.0.9",
|
||||
"uuid": "^13.0.0",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^3.2.4",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
packages:
|
||||
- '../cli'
|
||||
- '../../packages/agent-gateway-client'
|
||||
- '../../packages/const'
|
||||
- '../../packages/electron-server-ipc'
|
||||
- '../../packages/electron-client-ipc'
|
||||
|
||||
@@ -71,7 +71,6 @@
|
||||
"macOS.services": "خدمات",
|
||||
"macOS.unhide": "إظهار الكل",
|
||||
"tray.open": "فتح {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "خروج",
|
||||
"tray.show": "عرض {{appName}}",
|
||||
"view.forceReload": "إعادة تحميل قسري",
|
||||
|
||||
@@ -71,7 +71,6 @@
|
||||
"macOS.services": "Услуги",
|
||||
"macOS.unhide": "Покажи всичко",
|
||||
"tray.open": "Отвори {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Изход",
|
||||
"tray.show": "Покажи {{appName}}",
|
||||
"view.forceReload": "Принудително презареждане",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,8 +71,6 @@
|
||||
"macOS.services": "Services",
|
||||
"macOS.unhide": "Show All",
|
||||
"tray.open": "Open {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quickChat": "Quick Chat",
|
||||
"tray.quit": "Quit",
|
||||
"tray.show": "Show {{appName}}",
|
||||
"view.forceReload": "Force Reload",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -71,7 +71,6 @@
|
||||
"macOS.services": "خدمات",
|
||||
"macOS.unhide": "نمایش همه",
|
||||
"tray.open": "باز کردن {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "خروج",
|
||||
"tray.show": "نمایش {{appName}}",
|
||||
"view.forceReload": "بارگذاری اجباری",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -71,7 +71,6 @@
|
||||
"macOS.services": "サービス",
|
||||
"macOS.unhide": "すべて表示",
|
||||
"tray.open": "{{appName}} を開く",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "終了",
|
||||
"tray.show": "{{appName}} を表示",
|
||||
"view.forceReload": "強制再読み込み",
|
||||
|
||||
@@ -71,7 +71,6 @@
|
||||
"macOS.services": "서비스",
|
||||
"macOS.unhide": "모두 표시",
|
||||
"tray.open": "{{appName}} 열기",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "종료",
|
||||
"tray.show": "{{appName}} 표시",
|
||||
"view.forceReload": "강제 새로 고침",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -71,7 +71,6 @@
|
||||
"macOS.services": "Сервисы",
|
||||
"macOS.unhide": "Показать все",
|
||||
"tray.open": "Открыть {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "Выйти",
|
||||
"tray.show": "Показать {{appName}}",
|
||||
"view.forceReload": "Принудительная перезагрузка",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "已下载更新。现在安装吗?",
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
"file.newAgent": "新建助手",
|
||||
"file.newAgentGroup": "新建助手组",
|
||||
"file.newPage": "新建页面",
|
||||
"file.newTab": "新建标签页",
|
||||
"file.newTopic": "新建话题",
|
||||
"file.preferences": "设置…",
|
||||
"file.quit": "退出",
|
||||
@@ -72,8 +71,6 @@
|
||||
"macOS.services": "服务",
|
||||
"macOS.unhide": "全部显示",
|
||||
"tray.open": "打开 {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quickChat": "快捷聊天",
|
||||
"tray.quit": "退出",
|
||||
"tray.show": "显示 {{appName}}",
|
||||
"view.forceReload": "强制重新加载",
|
||||
|
||||
@@ -71,7 +71,6 @@
|
||||
"macOS.services": "服務",
|
||||
"macOS.unhide": "全部顯示",
|
||||
"tray.open": "打開 {{appName}}",
|
||||
"tray.openMiniToolbar": "Quick Composer",
|
||||
"tray.quit": "退出",
|
||||
"tray.show": "顯示 {{appName}}",
|
||||
"view.forceReload": "強制重新載入",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 807 B |
Binary file not shown.
|
After Width: | Height: | Size: 738 B |
Binary file not shown.
|
Before Width: | Height: | Size: 393 B |
Binary file not shown.
|
Before Width: | Height: | Size: 704 B |
@@ -1,51 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generate the macOS tray template icon set (black + alpha).
|
||||
*
|
||||
* Template images must contain only black pixels and an alpha channel;
|
||||
* macOS then recolors them automatically based on the menu bar theme.
|
||||
*
|
||||
* Renders two files in apps/desktop/resources:
|
||||
* - trayTemplate.png (@1x, 18x18)
|
||||
* - trayTemplate@2x.png (@2x, 36x36)
|
||||
*
|
||||
* Run: bun run apps/desktop/scripts/generate-tray-template.mjs
|
||||
*/
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import sharp from 'sharp';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const outDir = path.resolve(__dirname, '..', 'resources');
|
||||
|
||||
// Silhouette derived from the LobeHub logo. Eyes and mouth are cut as
|
||||
// transparent holes via fill-rule=evenodd so they remain visible when
|
||||
// macOS tints the entire shape in a single color.
|
||||
const svg = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320">
|
||||
<path fill="#000" d="M172.997 19.016c-14.027 0-19.5-11.5-41-11-23.394 0-34 13-45.5 23-1.958 1.702-11.5 7-16 9-19.683 8.748-34.5 21.5-34.5 40.5 0 20.711 17.461 37.5 39 37.5 3.536 0 6.963-.453 10.22-1.301 8.7 10.539 22.179 16.658 37.28 17.301 23.5 1 31-15.25 44.5-8.5 9.259 4.629 13.83 8.5 28.5 8.5 17.108 0 25.057-5.233 30-11 9-10.5 22.879-4 31.5-4 18.778 0 34-14.551 34-32.5 0-17.95-15.222-32.5-34-32.5-5.15 0-14.856 1.27-17-7-3.5-13.5-20.148-29-44-29-9.318 0-17.691 1-23 1z"/>
|
||||
<path fill="#000" fill-rule="evenodd" d="M294 172.519c0 75.655-59.442 128.5-134 128.5-74.558 0-134-53.845-134-129.5 0-22.5 5-32.141 31.5-35.671 47.5-6.329 72.542-3.829 102.5-3.829 29.959 0 72.556-1.27 102.5 3.829 24.5 4.171 30 8.671 31.5 36.671zM101 221.012c15.464 0 28-12.536 28-28s-12.536-28-28-28-28 12.536-28 28 12.536 28 28 28zM219 221.012c15.464 0 28-12.536 28-28s-12.536-28-28-28-28 12.536-28 28 12.536 28 28 28zM159.75 242.51c-28.25 0-35.75 3.5-35.75 3.5s3.5 27 35.75 27 35.75-27 35.75-27-7.5-3.5-35.75-3.5z"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
async function render(size, outFile) {
|
||||
const buf = Buffer.from(svg);
|
||||
await sharp(buf, { density: Math.max(72, size * 12) })
|
||||
.resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||
.png()
|
||||
.toFile(outFile);
|
||||
console.log(`wrote ${path.relative(process.cwd(), outFile)} (${size}x${size})`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await mkdir(outDir, { recursive: true });
|
||||
await render(18, path.join(outDir, 'trayTemplate.png'));
|
||||
await render(36, path.join(outDir, 'trayTemplate@2x.png'));
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,29 +1,29 @@
|
||||
import path from 'node:path';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { app } from 'electron';
|
||||
|
||||
export const mainDir = path.join(__dirname);
|
||||
export const mainDir = join(__dirname);
|
||||
|
||||
export const preloadDir = path.join(mainDir, '../preload');
|
||||
export const preloadDir = join(mainDir, '../preload');
|
||||
|
||||
export const resourcesDir = path.join(mainDir, '../../resources');
|
||||
export const resourcesDir = join(mainDir, '../../resources');
|
||||
|
||||
export const buildDir = path.join(mainDir, '../../build');
|
||||
export const buildDir = join(mainDir, '../../build');
|
||||
|
||||
export const binDir = app.isPackaged
|
||||
? path.join(process.resourcesPath, 'bin')
|
||||
: path.join(resourcesDir, 'bin');
|
||||
? join(process.resourcesPath, 'bin')
|
||||
: join(resourcesDir, 'bin');
|
||||
|
||||
const appPath = app.getAppPath();
|
||||
|
||||
export const rendererDir = path.join(appPath, 'dist', 'renderer');
|
||||
export const rendererDir = join(appPath, 'dist', 'renderer');
|
||||
|
||||
export const userDataDir = app.getPath('userData');
|
||||
|
||||
export const appStorageDir = path.join(userDataDir, 'lobehub-storage');
|
||||
export const appStorageDir = join(userDataDir, 'lobehub-storage');
|
||||
|
||||
// Legacy local database directory used in older desktop versions
|
||||
export const legacyLocalDbDir = path.join(appStorageDir, 'lobehub-local-db');
|
||||
export const legacyLocalDbDir = join(appStorageDir, 'lobehub-local-db');
|
||||
|
||||
// ------ Application storage directory ---- //
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import os from 'node:os';
|
||||
|
||||
import * as electronIs from 'electron-is';
|
||||
import { dev, linux, macOS, windows } from 'electron-is';
|
||||
|
||||
import { getDesktopEnv } from '@/env';
|
||||
|
||||
export const isDev = electronIs.dev();
|
||||
export const isDev = dev();
|
||||
|
||||
export const OFFICIAL_CLOUD_SERVER = getDesktopEnv().OFFICIAL_CLOUD_SERVER;
|
||||
|
||||
export const isMac = electronIs.macOS();
|
||||
export const isWindows = electronIs.windows();
|
||||
export const isLinux = electronIs.linux();
|
||||
export const isMac = macOS();
|
||||
export const isWindows = windows();
|
||||
export const isLinux = linux();
|
||||
|
||||
function getIsMacTahoe(): boolean {
|
||||
if (!isMac) return false;
|
||||
|
||||
@@ -16,21 +16,11 @@ export default class BrowserWindowsCtr extends ControllerModule {
|
||||
static override readonly groupName = 'windows';
|
||||
|
||||
@shortcut('showApp')
|
||||
toggleMainWindow() {
|
||||
async toggleMainWindow() {
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
mainWindow.toggleVisible();
|
||||
}
|
||||
|
||||
@shortcut('quickComposer')
|
||||
async openQuickComposer() {
|
||||
await this.app.screenCaptureManager.startSession();
|
||||
}
|
||||
|
||||
@shortcut('quickChat')
|
||||
openQuickChat() {
|
||||
this.app.browserManager.openQuickChatPopup();
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async openSettingsWindow(options?: string | OpenSettingsWindowOptions) {
|
||||
const normalizedOptions: OpenSettingsWindowOptions =
|
||||
|
||||
@@ -1,420 +0,0 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import type {
|
||||
GitAheadBehind,
|
||||
GitBranchInfo,
|
||||
GitBranchListItem,
|
||||
GitCheckoutResult,
|
||||
GitLinkedPullRequestResult,
|
||||
GitPullResult,
|
||||
GitPushResult,
|
||||
GitWorkingTreeFiles,
|
||||
GitWorkingTreeStatus,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
|
||||
import { detectRepoType, resolveGitDir } from '@/utils/git';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
const logger = createLogger('controllers:GitCtr');
|
||||
|
||||
export default class GitController extends ControllerModule {
|
||||
static override readonly groupName = 'git';
|
||||
|
||||
@IpcMethod()
|
||||
async detectRepoType(dirPath: string): Promise<'git' | 'github' | undefined> {
|
||||
return detectRepoType(dirPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read current git branch from `.git/HEAD`. Returns short sha on detached HEAD.
|
||||
* Handles both standard `.git` directories and `.git` worktree pointer files.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async getGitBranch(dirPath: string): Promise<GitBranchInfo> {
|
||||
try {
|
||||
const gitDir = await resolveGitDir(dirPath);
|
||||
if (!gitDir) return {};
|
||||
|
||||
const head = (await readFile(path.join(gitDir, 'HEAD'), 'utf8')).trim();
|
||||
const refMatch = /^ref:\s*refs\/heads\/(.+)$/.exec(head);
|
||||
if (refMatch) {
|
||||
return { branch: refMatch[1] };
|
||||
}
|
||||
// Detached HEAD — HEAD file contains the full sha
|
||||
if (/^[\da-f]{40}$/i.test(head)) {
|
||||
return { branch: head.slice(0, 7), detached: true };
|
||||
}
|
||||
return {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query `gh` CLI for an open pull request whose head branch matches `branch`.
|
||||
* Returns status = 'gh-missing' when `gh` is not installed / not authenticated,
|
||||
* so the UI can render a helpful tooltip instead of an error.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async getLinkedPullRequest(payload: {
|
||||
branch: string;
|
||||
path: string;
|
||||
}): Promise<GitLinkedPullRequestResult> {
|
||||
const { path: dirPath, branch } = payload;
|
||||
if (!branch) {
|
||||
return { pullRequest: null, status: 'ok' };
|
||||
}
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'gh',
|
||||
[
|
||||
'pr',
|
||||
'list',
|
||||
'--head',
|
||||
branch,
|
||||
'--state',
|
||||
'open',
|
||||
'--limit',
|
||||
'5',
|
||||
'--json',
|
||||
'number,url,title,state',
|
||||
],
|
||||
{ cwd: dirPath, timeout: 8000 },
|
||||
);
|
||||
const parsed = JSON.parse(stdout.trim() || '[]') as Array<{
|
||||
number: number;
|
||||
state: string;
|
||||
title: string;
|
||||
url: string;
|
||||
}>;
|
||||
if (parsed.length === 0) {
|
||||
return { pullRequest: null, status: 'ok' };
|
||||
}
|
||||
const [primary, ...rest] = parsed;
|
||||
return {
|
||||
extraCount: rest.length,
|
||||
pullRequest: primary,
|
||||
status: 'ok',
|
||||
};
|
||||
} catch (error: any) {
|
||||
const code = error?.code;
|
||||
const stderr: string = error?.stderr ?? '';
|
||||
// `gh` binary not on PATH
|
||||
if (code === 'ENOENT') {
|
||||
return { pullRequest: null, status: 'gh-missing' };
|
||||
}
|
||||
// gh reports auth issues via stderr; treat as a soft-fail
|
||||
if (/auth\s+login|not\s+logged\s+in|authentication/i.test(stderr)) {
|
||||
return { pullRequest: null, status: 'gh-missing' };
|
||||
}
|
||||
logger.debug('[getLinkedPullRequest] failed', { branch, code, stderr });
|
||||
return { pullRequest: null, status: 'error' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List local git branches ordered by most recent commit.
|
||||
* `current` is true for the checked-out branch.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async listGitBranches(dirPath: string): Promise<GitBranchListItem[]> {
|
||||
const execFileAsync = promisify(execFile);
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'git',
|
||||
[
|
||||
'for-each-ref',
|
||||
'--sort=-committerdate',
|
||||
'--format=%(HEAD)%09%(refname:short)%09%(upstream:short)',
|
||||
'refs/heads',
|
||||
],
|
||||
{ cwd: dirPath, timeout: 5000 },
|
||||
);
|
||||
return stdout
|
||||
.replaceAll('\r', '')
|
||||
.split('\n')
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => {
|
||||
// Line format: "<HEAD-marker>\t<branch>\t<upstream>" where HEAD-marker is '*' or ' '
|
||||
const [head, name, upstream] = line.split('\t');
|
||||
return {
|
||||
current: head === '*',
|
||||
name: name ?? '',
|
||||
upstream: upstream || undefined,
|
||||
};
|
||||
})
|
||||
.filter((b) => b.name);
|
||||
} catch (error: any) {
|
||||
logger.warn('[listGitBranches] git command failed', {
|
||||
code: error?.code,
|
||||
cwd: dirPath,
|
||||
message: error?.message,
|
||||
stderr: error?.stderr?.toString?.() ?? error?.stderr,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket dirty files into added / modified / deleted via `git status --porcelain -z`.
|
||||
* Each file is counted once: untracked (`??`) and staged-add (`A`) → added,
|
||||
* any `D` in index or working tree → deleted, everything else (`M`/`R`/`C`/`T`/`U`) → modified.
|
||||
*
|
||||
* Uses `-z` so paths are NUL-terminated (no C-style quoting, no `\n` splitting bugs).
|
||||
* Rename/copy entries (`R`/`C`) emit two NUL-separated tokens — dest path then source
|
||||
* path — so the source token must be consumed to keep counts correct.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async getGitWorkingTreeStatus(dirPath: string): Promise<GitWorkingTreeStatus> {
|
||||
const execFileAsync = promisify(execFile);
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['status', '--porcelain', '-z'], {
|
||||
cwd: dirPath,
|
||||
timeout: 5000,
|
||||
});
|
||||
const tokens = stdout.split('\0');
|
||||
let added = 0;
|
||||
let modified = 0;
|
||||
let deleted = 0;
|
||||
let i = 0;
|
||||
while (i < tokens.length) {
|
||||
const entry = tokens[i];
|
||||
i++;
|
||||
if (entry.length < 2) continue;
|
||||
const x = entry[0];
|
||||
const y = entry[1];
|
||||
// R/C entries carry an extra source-path token we must consume.
|
||||
if (x === 'R' || x === 'C') i++;
|
||||
if (x === '?' && y === '?') {
|
||||
added++;
|
||||
} else if (x === '!' && y === '!') {
|
||||
// ignored — skip
|
||||
} else if (x === 'D' || y === 'D') {
|
||||
deleted++;
|
||||
} else if (x === 'A' || y === 'A') {
|
||||
added++;
|
||||
} else {
|
||||
modified++;
|
||||
}
|
||||
}
|
||||
const total = added + modified + deleted;
|
||||
return { added, clean: total === 0, deleted, modified, total };
|
||||
} catch {
|
||||
return { added: 0, clean: true, deleted: 0, modified: 0, total: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return dirty file paths bucketed into added / modified / deleted.
|
||||
* Same classification as getGitWorkingTreeStatus, but with per-file paths.
|
||||
*
|
||||
* Uses `git status --porcelain -z` so paths are NUL-terminated and never C-quoted,
|
||||
* which avoids misparsing filenames that legitimately contain ` -> `, quote chars,
|
||||
* or newlines. For R/C entries the two NUL-separated tokens are `DEST\0SRC`; we
|
||||
* report DEST (the current working-tree path) and discard SRC.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async getGitWorkingTreeFiles(dirPath: string): Promise<GitWorkingTreeFiles> {
|
||||
const execFileAsync = promisify(execFile);
|
||||
const added: string[] = [];
|
||||
const modified: string[] = [];
|
||||
const deleted: string[] = [];
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['status', '--porcelain', '-z'], {
|
||||
cwd: dirPath,
|
||||
timeout: 5000,
|
||||
});
|
||||
const tokens = stdout.split('\0');
|
||||
let i = 0;
|
||||
while (i < tokens.length) {
|
||||
const entry = tokens[i];
|
||||
i++;
|
||||
if (entry.length < 3) continue;
|
||||
const x = entry[0];
|
||||
const y = entry[1];
|
||||
const filePath = entry.slice(3);
|
||||
// R/C entries carry an extra source-path token we must consume.
|
||||
if (x === 'R' || x === 'C') i++;
|
||||
if (!filePath) continue;
|
||||
if (x === '?' && y === '?') {
|
||||
added.push(filePath);
|
||||
} else if (x === '!' && y === '!') {
|
||||
// ignored — skip
|
||||
} else if (x === 'D' || y === 'D') {
|
||||
deleted.push(filePath);
|
||||
} else if (x === 'A' || y === 'A') {
|
||||
added.push(filePath);
|
||||
} else {
|
||||
modified.push(filePath);
|
||||
}
|
||||
}
|
||||
return { added, deleted, modified };
|
||||
} catch {
|
||||
return { added: [], deleted: [], modified: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count commits HEAD is ahead/behind its upstream tracking ref.
|
||||
* Returns `hasUpstream: false` when the branch has no upstream configured
|
||||
* (e.g. local-only branches, or after the remote branch is deleted).
|
||||
*
|
||||
* Does a best-effort `git fetch` first so the result reflects what's
|
||||
* actually on the remote — the renderer calls this via SWR with
|
||||
* `revalidateOnFocus`, so the fetch piggybacks on window re-focus. Fetch
|
||||
* failures (offline, no credentials, no `origin` remote) are swallowed so
|
||||
* we still return whatever can be computed against the cached refs.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async getGitAheadBehind(dirPath: string): Promise<GitAheadBehind> {
|
||||
const execFileAsync = promisify(execFile);
|
||||
try {
|
||||
await execFileAsync('git', ['fetch', '--no-tags', '--quiet', 'origin'], {
|
||||
cwd: dirPath,
|
||||
timeout: 10_000,
|
||||
});
|
||||
} catch {
|
||||
// swallow — fall through to compute against cached refs
|
||||
}
|
||||
try {
|
||||
const { stdout: upstreamOut } = await execFileAsync(
|
||||
'git',
|
||||
['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'],
|
||||
{ cwd: dirPath, timeout: 5000 },
|
||||
);
|
||||
const upstream = upstreamOut.trim();
|
||||
if (!upstream) return { ahead: 0, behind: 0, hasUpstream: false };
|
||||
|
||||
const { stdout } = await execFileAsync(
|
||||
'git',
|
||||
['rev-list', '--left-right', '--count', `${upstream}...HEAD`],
|
||||
{ cwd: dirPath, timeout: 5000 },
|
||||
);
|
||||
const [behindStr, aheadStr] = stdout.trim().split(/\s+/);
|
||||
const behind = Number.parseInt(behindStr ?? '0', 10) || 0;
|
||||
const ahead = Number.parseInt(aheadStr ?? '0', 10) || 0;
|
||||
|
||||
// `git push -u origin HEAD` always targets origin/<current-branch-name>,
|
||||
// which may differ from upstream (the branched-off-canary case).
|
||||
let pushTarget: string | undefined;
|
||||
let pushTargetExists = false;
|
||||
try {
|
||||
const { stdout: branchOut } = await execFileAsync(
|
||||
'git',
|
||||
['symbolic-ref', '--short', 'HEAD'],
|
||||
{ cwd: dirPath, timeout: 5000 },
|
||||
);
|
||||
const branch = branchOut.trim();
|
||||
if (branch) {
|
||||
pushTarget = `origin/${branch}`;
|
||||
try {
|
||||
await execFileAsync(
|
||||
'git',
|
||||
['rev-parse', '--verify', '--quiet', `refs/remotes/${pushTarget}`],
|
||||
{ cwd: dirPath, timeout: 5000 },
|
||||
);
|
||||
pushTargetExists = true;
|
||||
} catch {
|
||||
pushTargetExists = false;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// detached HEAD — leave pushTarget undefined
|
||||
}
|
||||
|
||||
return { ahead, behind, hasUpstream: true, pushTarget, pushTargetExists, upstream };
|
||||
} catch {
|
||||
// No upstream configured, detached HEAD, or git error — all treated as "no upstream"
|
||||
return { ahead: 0, behind: 0, hasUpstream: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check out (or create + check out) a branch.
|
||||
* Relies on git itself to reject unsafe checkouts (dirty tree, non-fast-forward, etc.)
|
||||
* and surfaces git's stderr so the UI can display a meaningful error.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async checkoutGitBranch(payload: {
|
||||
branch: string;
|
||||
create?: boolean;
|
||||
path: string;
|
||||
}): Promise<GitCheckoutResult> {
|
||||
const { path: dirPath, branch, create } = payload;
|
||||
if (!branch?.trim()) {
|
||||
return { error: 'Branch name is required', success: false };
|
||||
}
|
||||
// Reject obviously invalid refs early to avoid a confusing git error
|
||||
if (/[\s~^:?*[\\]/.test(branch) || branch.startsWith('-') || branch.includes('..')) {
|
||||
return { error: `Invalid branch name: ${branch}`, success: false };
|
||||
}
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const args = create ? ['checkout', '-b', branch] : ['checkout', branch];
|
||||
try {
|
||||
await execFileAsync('git', args, { cwd: dirPath, timeout: 10_000 });
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
const stderr: string = (error?.stderr ?? error?.message ?? '').toString().trim();
|
||||
logger.debug('[checkoutGitBranch] failed', { args, stderr });
|
||||
return { error: stderr || 'git checkout failed', success: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the current branch's upstream via fast-forward only.
|
||||
*
|
||||
* `--ff-only` avoids creating accidental merge commits when the local branch
|
||||
* has diverged — in that case the user should resolve merge/rebase in their
|
||||
* own terminal. For the common "just behind" case this is a safe one-click.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async pullGitBranch(payload: { path: string }): Promise<GitPullResult> {
|
||||
const { path: dirPath } = payload;
|
||||
const execFileAsync = promisify(execFile);
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['pull', '--ff-only'], {
|
||||
cwd: dirPath,
|
||||
timeout: 60_000,
|
||||
});
|
||||
const noop = /Already up to date/i.test(stdout);
|
||||
return { noop, success: true };
|
||||
} catch (error: any) {
|
||||
const stderr: string = (error?.stderr ?? error?.message ?? '').toString().trim();
|
||||
logger.debug('[pullGitBranch] failed', { stderr });
|
||||
return { error: stderr || 'git pull failed', success: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push the current branch to its same-named remote on `origin`.
|
||||
*
|
||||
* Uses `git push -u origin HEAD` instead of plain `git push` so the action
|
||||
* works even when local branch name differs from the configured upstream
|
||||
*/
|
||||
@IpcMethod()
|
||||
async pushGitBranch(payload: { path: string }): Promise<GitPushResult> {
|
||||
const { path: dirPath } = payload;
|
||||
const execFileAsync = promisify(execFile);
|
||||
try {
|
||||
const { stderr } = await execFileAsync('git', ['push', '-u', 'origin', 'HEAD'], {
|
||||
cwd: dirPath,
|
||||
timeout: 60_000,
|
||||
});
|
||||
// git push writes progress/status to stderr even on success
|
||||
const noop = /Everything up-to-date/i.test(stderr);
|
||||
return { noop, success: true };
|
||||
} catch (error: any) {
|
||||
const stderr: string = (error?.stderr ?? error?.message ?? '').toString().trim();
|
||||
logger.debug('[pushGitBranch] failed', { stderr });
|
||||
return { error: stderr || 'git push failed', success: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,11 +4,12 @@ import { isEqual, merge } from 'es-toolkit/compat';
|
||||
import { defaultProxySettings } from '@/const/store';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import type { ProxyTestResult } from '../modules/networkProxy';
|
||||
import type {
|
||||
ProxyTestResult} from '../modules/networkProxy';
|
||||
import {
|
||||
ProxyConfigValidator,
|
||||
ProxyConnectionTester,
|
||||
ProxyDispatcherManager,
|
||||
ProxyDispatcherManager
|
||||
} from '../modules/networkProxy';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
@@ -103,7 +104,7 @@ export default class NetworkProxyCtr extends ControllerModule {
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('Proxy connection test failed:', errorMessage);
|
||||
throw new Error(`Connection failed: ${errorMessage}`, { cause: error });
|
||||
throw new Error(`Connection failed: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
ShowDesktopNotificationParams,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import { app, Notification } from 'electron';
|
||||
import * as electronIs from 'electron-is';
|
||||
import { linux, macOS, windows } from 'electron-is';
|
||||
|
||||
import { getIpcContext } from '@/utils/ipc';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
@@ -20,7 +20,7 @@ export default class NotificationCtr extends ControllerModule {
|
||||
if (!Notification.isSupported()) return 'denied';
|
||||
// Keep a stable status string for renderer-side UI mapping.
|
||||
// Screen3 expects macOS to return 'authorized' when granted.
|
||||
if (!electronIs.macOS()) return 'authorized';
|
||||
if (!macOS()) return 'authorized';
|
||||
|
||||
// Electron 38 no longer exposes `systemPreferences.getNotificationSettings()` in types,
|
||||
// and some runtimes don't provide it at all. Use the renderer's Notification.permission
|
||||
@@ -43,7 +43,7 @@ export default class NotificationCtr extends ControllerModule {
|
||||
|
||||
// On macOS, ask permission via Web Notification API first when possible.
|
||||
// This helps keep `Notification.permission` in sync for subsequent status checks.
|
||||
if (electronIs.macOS()) {
|
||||
if (macOS()) {
|
||||
try {
|
||||
const mainWindow = this.app.browserManager.getMainWindow().browserWindow;
|
||||
await mainWindow.webContents.executeJavaScript('Notification.requestPermission()', true);
|
||||
@@ -83,12 +83,12 @@ export default class NotificationCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
// On macOS, we may need to explicitly request notification permissions
|
||||
if (electronIs.macOS()) {
|
||||
if (macOS()) {
|
||||
logger.debug('macOS detected, notification permissions should be handled by system');
|
||||
}
|
||||
|
||||
// Set app user model ID on Windows
|
||||
if (electronIs.windows()) {
|
||||
if (windows()) {
|
||||
app.setAppUserModelId('com.lobehub.chat');
|
||||
logger.debug('Set Windows App User Model ID for notifications');
|
||||
}
|
||||
@@ -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,
|
||||
@@ -142,7 +136,7 @@ export default class NotificationCtr extends ControllerModule {
|
||||
// due to heavy gnome-shell processing. Using 'low' urgency routes notifications to the
|
||||
// message tray instead, preventing the banner's X button from being shown.
|
||||
// The urgency option is ignored on macOS and Windows.
|
||||
urgency: electronIs.linux() ? 'low' : 'normal',
|
||||
urgency: linux() ? 'low' : 'normal',
|
||||
});
|
||||
|
||||
// Add more event listeners for debugging
|
||||
@@ -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.
|
||||
@@ -218,7 +192,7 @@ export default class NotificationCtr extends ControllerModule {
|
||||
try {
|
||||
const next = Math.max(0, Math.floor(count));
|
||||
app.setBadgeCount(next);
|
||||
if (electronIs.macOS() && app.dock) {
|
||||
if (macOS() && app.dock) {
|
||||
app.dock.setBadge(next > 0 ? String(next) : '');
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import type {
|
||||
CapturePreviewResult,
|
||||
CaptureRectParams,
|
||||
OverlayCaptureUploadStatusPayload,
|
||||
ScreenCaptureSubmitParams,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
|
||||
import type { OverlaySnapshotPayload } from '@/modules/screenCapture/ScreenCaptureManager';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
const logger = createLogger('controllers:ScreenCaptureCtr');
|
||||
|
||||
export default class ScreenCaptureCtr extends ControllerModule {
|
||||
static override readonly groupName = 'screenCapture';
|
||||
|
||||
@IpcMethod()
|
||||
async traceOverlayEvent(payload: { data?: unknown; event: string }): Promise<void> {
|
||||
console.info('[screenCapture:overlay]', payload.event, payload.data ?? '');
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async previewWindow(windowId: number): Promise<CapturePreviewResult> {
|
||||
logger.debug(`previewWindow request: ${windowId}`);
|
||||
return this.app.screenCaptureManager.handlePreviewWindow(windowId);
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async previewRect(params: CaptureRectParams): Promise<CapturePreviewResult> {
|
||||
logger.debug(`previewRect request: ${JSON.stringify(params)}`);
|
||||
return this.app.screenCaptureManager.handlePreviewRect(params);
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async submit(params: ScreenCaptureSubmitParams): Promise<void> {
|
||||
logger.debug(`submit request: prompt-len=${params.prompt.length}`);
|
||||
await this.app.screenCaptureManager.handleSubmit(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Status update reported by the main renderer after it finishes (or fails)
|
||||
* uploading a capture's bytes. Forwarded to the overlay to drive the send
|
||||
* button's enabled state.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async reportUploadStatus(payload: OverlayCaptureUploadStatusPayload): Promise<void> {
|
||||
logger.debug(
|
||||
`reportUploadStatus captureId=${payload.captureId} status=${payload.status} fileId=${payload.fileId ?? '-'}`,
|
||||
);
|
||||
this.app.screenCaptureManager.reportUploadStatus(payload);
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async close(): Promise<void> {
|
||||
logger.debug('close overlay request');
|
||||
this.app.screenCaptureManager.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderer-driven snapshot of agents/models for the overlay selector. The
|
||||
* main renderer pushes this whenever its data layer (TRPC stores) reports
|
||||
* a change; main process only caches and forwards — it does not fetch.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async publishOverlaySnapshot(payload: OverlaySnapshotPayload): Promise<void> {
|
||||
logger.debug(
|
||||
`publishOverlaySnapshot — agents=${payload.agents?.length ?? 0} models=${payload.models?.length ?? 0}`,
|
||||
);
|
||||
this.app.screenCaptureManager.publishOverlaySnapshot(payload);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,23 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import type { ElectronAppState, ThemeMode } from '@lobechat/electron-client-ipc';
|
||||
import type {
|
||||
ElectronAppState,
|
||||
GitBranchInfo,
|
||||
GitBranchListItem,
|
||||
GitCheckoutResult,
|
||||
GitLinkedPullRequestResult,
|
||||
GitWorkingTreeStatus,
|
||||
ThemeMode,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import { app, dialog, nativeTheme, shell } from 'electron';
|
||||
import * as electronIs from 'electron-is';
|
||||
import { macOS } from 'electron-is';
|
||||
import { pathExists, readdir } from 'fs-extra';
|
||||
|
||||
import { legacyLocalDbDir } from '@/const/dir';
|
||||
import { detectRepoType } from '@/utils/git';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
import {
|
||||
getAccessibilityStatus,
|
||||
@@ -103,7 +114,7 @@ export default class SystemController extends ControllerModule {
|
||||
return 'granted';
|
||||
}
|
||||
|
||||
if (!electronIs.macOS()) {
|
||||
if (!macOS()) {
|
||||
logger.info('[FullDiskAccess] Not macOS, returning granted');
|
||||
return 'granted';
|
||||
}
|
||||
@@ -184,7 +195,7 @@ export default class SystemController extends ControllerModule {
|
||||
}
|
||||
|
||||
const folderPath = result.filePaths[0];
|
||||
const repoType = await detectRepoType(folderPath);
|
||||
const repoType = await this.detectRepoType(folderPath);
|
||||
|
||||
return { path: folderPath, repoType };
|
||||
}
|
||||
@@ -234,6 +245,225 @@ export default class SystemController extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async detectRepoType(dirPath: string): Promise<'git' | 'github' | undefined> {
|
||||
const gitConfigPath = path.join(dirPath, '.git', 'config');
|
||||
try {
|
||||
const config = await readFile(gitConfigPath, 'utf8');
|
||||
if (config.includes('github.com')) return 'github';
|
||||
return 'git';
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read current git branch from `.git/HEAD`. Returns short sha on detached HEAD.
|
||||
* Handles both standard `.git` directories and `.git` worktree pointer files.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async getGitBranch(dirPath: string): Promise<GitBranchInfo> {
|
||||
try {
|
||||
const gitDir = await this.resolveGitDir(dirPath);
|
||||
if (!gitDir) return {};
|
||||
|
||||
const head = (await readFile(path.join(gitDir, 'HEAD'), 'utf8')).trim();
|
||||
const refMatch = /^ref:\s*refs\/heads\/(.+)$/.exec(head);
|
||||
if (refMatch) {
|
||||
return { branch: refMatch[1] };
|
||||
}
|
||||
// Detached HEAD — HEAD file contains the full sha
|
||||
if (/^[\da-f]{40}$/i.test(head)) {
|
||||
return { branch: head.slice(0, 7), detached: true };
|
||||
}
|
||||
return {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query `gh` CLI for an open pull request whose head branch matches `branch`.
|
||||
* Returns status = 'gh-missing' when `gh` is not installed / not authenticated,
|
||||
* so the UI can render a helpful tooltip instead of an error.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async getLinkedPullRequest(payload: {
|
||||
branch: string;
|
||||
path: string;
|
||||
}): Promise<GitLinkedPullRequestResult> {
|
||||
const { path: dirPath, branch } = payload;
|
||||
if (!branch) {
|
||||
return { pullRequest: null, status: 'ok' };
|
||||
}
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'gh',
|
||||
[
|
||||
'pr',
|
||||
'list',
|
||||
'--head',
|
||||
branch,
|
||||
'--state',
|
||||
'open',
|
||||
'--limit',
|
||||
'5',
|
||||
'--json',
|
||||
'number,url,title,state',
|
||||
],
|
||||
{ cwd: dirPath, timeout: 8000 },
|
||||
);
|
||||
const parsed = JSON.parse(stdout.trim() || '[]') as Array<{
|
||||
number: number;
|
||||
state: string;
|
||||
title: string;
|
||||
url: string;
|
||||
}>;
|
||||
if (parsed.length === 0) {
|
||||
return { pullRequest: null, status: 'ok' };
|
||||
}
|
||||
const [primary, ...rest] = parsed;
|
||||
return {
|
||||
extraCount: rest.length,
|
||||
pullRequest: primary,
|
||||
status: 'ok',
|
||||
};
|
||||
} catch (error: any) {
|
||||
const code = error?.code;
|
||||
const stderr: string = error?.stderr ?? '';
|
||||
// `gh` binary not on PATH
|
||||
if (code === 'ENOENT') {
|
||||
return { pullRequest: null, status: 'gh-missing' };
|
||||
}
|
||||
// gh reports auth issues via stderr; treat as a soft-fail
|
||||
if (/auth\s+login|not\s+logged\s+in|authentication/i.test(stderr)) {
|
||||
return { pullRequest: null, status: 'gh-missing' };
|
||||
}
|
||||
logger.debug('[getLinkedPullRequest] failed', { branch, code, stderr });
|
||||
return { pullRequest: null, status: 'error' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List local git branches ordered by most recent commit.
|
||||
* `current` is true for the checked-out branch.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async listGitBranches(dirPath: string): Promise<GitBranchListItem[]> {
|
||||
const execFileAsync = promisify(execFile);
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'git',
|
||||
[
|
||||
'for-each-ref',
|
||||
'--sort=-committerdate',
|
||||
'--format=%(HEAD)%09%(refname:short)%09%(upstream:short)',
|
||||
'refs/heads',
|
||||
],
|
||||
{ cwd: dirPath, timeout: 5000 },
|
||||
);
|
||||
return stdout
|
||||
.replaceAll('\r', '')
|
||||
.split('\n')
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => {
|
||||
// Line format: "<HEAD-marker>\t<branch>\t<upstream>" where HEAD-marker is '*' or ' '
|
||||
const [head, name, upstream] = line.split('\t');
|
||||
return {
|
||||
current: head === '*',
|
||||
name: name ?? '',
|
||||
upstream: upstream || undefined,
|
||||
};
|
||||
})
|
||||
.filter((b) => b.name);
|
||||
} catch (error: any) {
|
||||
logger.warn('[listGitBranches] git command failed', {
|
||||
code: error?.code,
|
||||
cwd: dirPath,
|
||||
message: error?.message,
|
||||
stderr: error?.stderr?.toString?.() ?? error?.stderr,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count unstaged / staged / untracked files via `git status --porcelain`.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async getGitWorkingTreeStatus(dirPath: string): Promise<GitWorkingTreeStatus> {
|
||||
const execFileAsync = promisify(execFile);
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['status', '--porcelain'], {
|
||||
cwd: dirPath,
|
||||
timeout: 5000,
|
||||
});
|
||||
const lines = stdout.split('\n').filter((line) => line.trim().length > 0);
|
||||
return { clean: lines.length === 0, modified: lines.length };
|
||||
} catch {
|
||||
return { clean: true, modified: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check out (or create + check out) a branch.
|
||||
* Relies on git itself to reject unsafe checkouts (dirty tree, non-fast-forward, etc.)
|
||||
* and surfaces git's stderr so the UI can display a meaningful error.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async checkoutGitBranch(payload: {
|
||||
branch: string;
|
||||
create?: boolean;
|
||||
path: string;
|
||||
}): Promise<GitCheckoutResult> {
|
||||
const { path: dirPath, branch, create } = payload;
|
||||
if (!branch?.trim()) {
|
||||
return { error: 'Branch name is required', success: false };
|
||||
}
|
||||
// Reject obviously invalid refs early to avoid a confusing git error
|
||||
if (/[\s~^:?*[\\]/.test(branch) || branch.startsWith('-') || branch.includes('..')) {
|
||||
return { error: `Invalid branch name: ${branch}`, success: false };
|
||||
}
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const args = create ? ['checkout', '-b', branch] : ['checkout', branch];
|
||||
try {
|
||||
await execFileAsync('git', args, { cwd: dirPath, timeout: 10_000 });
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
const stderr: string = (error?.stderr ?? error?.message ?? '').toString().trim();
|
||||
logger.debug('[checkoutGitBranch] failed', { args, stderr });
|
||||
return { error: stderr || 'git checkout failed', success: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the actual `.git` directory for a working tree.
|
||||
* Supports both standard layouts and worktree pointer files (`.git` as a regular file).
|
||||
*/
|
||||
private async resolveGitDir(dirPath: string): Promise<string | undefined> {
|
||||
const gitPath = path.join(dirPath, '.git');
|
||||
try {
|
||||
const content = await readFile(gitPath, 'utf8');
|
||||
const worktreeMatch = /^gitdir:\s*(\S.*)$/m.exec(content.trim());
|
||||
if (worktreeMatch) {
|
||||
const resolved = worktreeMatch[1].trim();
|
||||
return path.isAbsolute(resolved) ? resolved : path.resolve(dirPath, resolved);
|
||||
}
|
||||
} catch {
|
||||
// `.git` is a directory (EISDIR) or missing — fall through
|
||||
}
|
||||
try {
|
||||
const stat = await readdir(gitPath);
|
||||
if (stat.length > 0) return gitPath;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async setSystemThemeMode(themeMode: ThemeMode) {
|
||||
nativeTheme.themeSource = themeMode;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ vi.mock('@/const/env', () => ({
|
||||
let randomBytesCounter = 0;
|
||||
vi.mock('node:crypto', () => ({
|
||||
default: {
|
||||
randomBytes: vi.fn((_size: number) => {
|
||||
randomBytes: vi.fn((size: number) => {
|
||||
randomBytesCounter++;
|
||||
return {
|
||||
toString: vi.fn(() => `mock-random-${randomBytesCounter}`),
|
||||
|
||||
@@ -30,7 +30,6 @@ const mockMinimizeWindow = vi.fn();
|
||||
const mockMaximizeWindow = vi.fn();
|
||||
const mockIsWindowMaximized = vi.fn();
|
||||
const mockRetrieveByIdentifier = vi.fn();
|
||||
const mockStartSession = vi.fn();
|
||||
const testSenderIdentifierString: string = 'test-window-event-id';
|
||||
|
||||
const mockGetIdentifierByWebContents = vi.fn(() => testSenderIdentifierString);
|
||||
@@ -67,9 +66,6 @@ const mockApp = {
|
||||
},
|
||||
),
|
||||
},
|
||||
screenCaptureManager: {
|
||||
startSession: mockStartSession,
|
||||
},
|
||||
} as unknown as App;
|
||||
|
||||
describe('BrowserWindowsCtr', () => {
|
||||
@@ -82,21 +78,10 @@ describe('BrowserWindowsCtr', () => {
|
||||
});
|
||||
|
||||
describe('toggleMainWindow', () => {
|
||||
it('should toggle the main window visibility', () => {
|
||||
browserWindowsCtr.toggleMainWindow();
|
||||
|
||||
it('should get the main window and toggle its visibility', async () => {
|
||||
await browserWindowsCtr.toggleMainWindow();
|
||||
expect(mockGetMainWindow).toHaveBeenCalled();
|
||||
expect(mockToggleVisible).toHaveBeenCalled();
|
||||
expect(mockStartSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('openQuickComposer', () => {
|
||||
it('should start the quick composer session', async () => {
|
||||
await browserWindowsCtr.openQuickComposer();
|
||||
expect(mockStartSession).toHaveBeenCalled();
|
||||
expect(mockGetMainWindow).not.toHaveBeenCalled();
|
||||
expect(mockToggleVisible).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -116,10 +82,7 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
|
||||
describe('resolveImage', () => {
|
||||
it('stores traversal-looking ids inside the cache root via a stable hash key', async () => {
|
||||
const ctr = new HeterogeneousAgentCtr({
|
||||
appStoragePath,
|
||||
storeManager: { get: vi.fn() },
|
||||
} as any);
|
||||
const ctr = new HeterogeneousAgentCtr({ appStoragePath } as any);
|
||||
const cacheDir = path.join(appStoragePath, 'heteroAgent/files');
|
||||
const escapedTargetName = `${path.basename(appStoragePath)}-outside-storage`;
|
||||
const escapePath = path.join(cacheDir, `../../../${escapedTargetName}`);
|
||||
@@ -149,10 +112,7 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
});
|
||||
|
||||
it('does not trust pre-seeded out-of-root traversal cache files as cache hits', async () => {
|
||||
const ctr = new HeterogeneousAgentCtr({
|
||||
appStoragePath,
|
||||
storeManager: { get: vi.fn() },
|
||||
} as any);
|
||||
const ctr = new HeterogeneousAgentCtr({ appStoragePath } as any);
|
||||
const cacheDir = path.join(appStoragePath, 'heteroAgent/files');
|
||||
const traversalId = '../../preexisting-secret';
|
||||
const outOfRootDataPath = path.join(cacheDir, traversalId);
|
||||
@@ -178,21 +138,13 @@ 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({
|
||||
appStoragePath,
|
||||
storeManager: { get: vi.fn() },
|
||||
} as any);
|
||||
const ctr = new HeterogeneousAgentCtr({ appStoragePath } as any);
|
||||
const { sessionId } = await ctr.startSession({
|
||||
agentType: 'claude-code',
|
||||
command: 'claude',
|
||||
@@ -201,7 +153,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 +212,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);
|
||||
|
||||
@@ -19,7 +19,7 @@ const mockGetShortcutsConfig = vi.fn().mockReturnValue({
|
||||
toggleMainWindow: 'CommandOrControl+Shift+L',
|
||||
openSettings: 'CommandOrControl+,',
|
||||
});
|
||||
const mockUpdateShortcutConfig = vi.fn().mockImplementation((_id, _accelerator) => {
|
||||
const mockUpdateShortcutConfig = vi.fn().mockImplementation((id, accelerator) => {
|
||||
// Simply mock a successful update
|
||||
return 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,9 +16,7 @@ const shortcutDecorator = (name: string) => (target: any, methodName: string, de
|
||||
/**
|
||||
* shortcut inject decorator
|
||||
*/
|
||||
type DesktopHotkeyIdCompatible = DesktopHotkeyId | 'quickComposer';
|
||||
|
||||
export const shortcut = (method: DesktopHotkeyIdCompatible) => shortcutDecorator(method);
|
||||
export const shortcut = (method: DesktopHotkeyId) => shortcutDecorator(method);
|
||||
|
||||
const protocolDecorator =
|
||||
(urlType: string, action: string) => (target: any, methodName: string, descriptor?: any) => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import BrowserWindowsCtr from './BrowserWindowsCtr';
|
||||
import CliCtr from './CliCtr';
|
||||
import DevtoolsCtr from './DevtoolsCtr';
|
||||
import GatewayConnectionCtr from './GatewayConnectionCtr';
|
||||
import GitCtr from './GitCtr';
|
||||
import HeterogeneousAgentCtr from './HeterogeneousAgentCtr';
|
||||
import LocalFileCtr from './LocalFileCtr';
|
||||
import McpCtr from './McpCtr';
|
||||
@@ -15,13 +14,13 @@ import NetworkProxyCtr from './NetworkProxyCtr';
|
||||
import NotificationCtr from './NotificationCtr';
|
||||
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
|
||||
import RemoteServerSyncCtr from './RemoteServerSyncCtr';
|
||||
import ScreenCaptureCtr from './ScreenCaptureCtr';
|
||||
import ShellCommandCtr from './ShellCommandCtr';
|
||||
import ShortcutController from './ShortcutCtr';
|
||||
import SystemController from './SystemCtr';
|
||||
import ToolDetectorCtr from './ToolDetectorCtr';
|
||||
import TrayMenuCtr from './TrayMenuCtr';
|
||||
import UpdaterCtr from './UpdaterCtr';
|
||||
import UploadFileCtr from './UploadFileCtr';
|
||||
|
||||
export const controllerIpcConstructors = [
|
||||
HeterogeneousAgentCtr,
|
||||
@@ -30,7 +29,6 @@ export const controllerIpcConstructors = [
|
||||
CliCtr,
|
||||
DevtoolsCtr,
|
||||
GatewayConnectionCtr,
|
||||
GitCtr,
|
||||
LocalFileCtr,
|
||||
McpCtr,
|
||||
McpInstallCtr,
|
||||
@@ -39,13 +37,13 @@ export const controllerIpcConstructors = [
|
||||
NotificationCtr,
|
||||
RemoteServerConfigCtr,
|
||||
RemoteServerSyncCtr,
|
||||
ScreenCaptureCtr,
|
||||
ShellCommandCtr,
|
||||
ShortcutController,
|
||||
SystemController,
|
||||
ToolDetectorCtr,
|
||||
TrayMenuCtr,
|
||||
UpdaterCtr,
|
||||
UploadFileCtr,
|
||||
] as const satisfies readonly IpcServiceConstructor[];
|
||||
|
||||
type DesktopControllerIpcConstructors = typeof controllerIpcConstructors;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import type { ElectronIPCEventHandler } from '@lobechat/electron-server-ipc';
|
||||
import { ElectronIPCServer } from '@lobechat/electron-server-ipc';
|
||||
import { app, nativeTheme, protocol } from 'electron';
|
||||
import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer';
|
||||
import * as electronIs from 'electron-is';
|
||||
import { macOS, windows } from 'electron-is';
|
||||
|
||||
import { name } from '@/../../package.json';
|
||||
import { binDir, buildDir } from '@/const/dir';
|
||||
@@ -14,7 +14,6 @@ import { ELECTRON_BE_PROTOCOL_SCHEME } from '@/const/protocol';
|
||||
import type { IControlModule } from '@/controllers';
|
||||
import AuthCtr from '@/controllers/AuthCtr';
|
||||
import { generateCliWrapper, getCliWrapperDir } from '@/modules/cliEmbedding';
|
||||
import { ScreenCaptureManager } from '@/modules/screenCapture/ScreenCaptureManager';
|
||||
import {
|
||||
astSearchDetectors,
|
||||
browserAutomationDetectors,
|
||||
@@ -63,7 +62,6 @@ export class App {
|
||||
protocolManager: ProtocolManager;
|
||||
rendererUrlManager: RendererUrlManager;
|
||||
toolDetectorManager: ToolDetectorManager;
|
||||
screenCaptureManager: ScreenCaptureManager;
|
||||
chromeFlags: string[] = ['OverlayScrollbar', 'FluentOverlayScrollbar', 'FluentScrollbar'];
|
||||
|
||||
/**
|
||||
@@ -143,7 +141,6 @@ export class App {
|
||||
this.staticFileServerManager = new StaticFileServerManager(this);
|
||||
this.protocolManager = new ProtocolManager(this);
|
||||
this.toolDetectorManager = new ToolDetectorManager(this);
|
||||
this.screenCaptureManager = new ScreenCaptureManager(this);
|
||||
|
||||
// Register built-in tool detectors
|
||||
this.registerBuiltinToolDetectors();
|
||||
@@ -249,8 +246,10 @@ export class App {
|
||||
|
||||
await this.browserManager.initializeBrowsers();
|
||||
|
||||
// Initialize tray manager on all platforms (macOS menu bar, Windows / Linux tray).
|
||||
this.trayManager.initializeTrays();
|
||||
// Initialize tray manager
|
||||
if (process.platform === 'win32') {
|
||||
this.trayManager.initializeTrays();
|
||||
}
|
||||
|
||||
// Initialize updater manager
|
||||
await this.updaterManager.initialize();
|
||||
@@ -259,7 +258,7 @@ export class App {
|
||||
this.isQuiting = false;
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (electronIs.windows() || process.platform === 'linux') {
|
||||
if (windows() || process.platform === 'linux') {
|
||||
logger.info(`All windows closed, quitting application (${process.platform})`);
|
||||
app.quit();
|
||||
}
|
||||
@@ -421,8 +420,8 @@ export class App {
|
||||
|
||||
logger.debug('Setting up dev branding');
|
||||
app.setName('lobehub-desktop-dev');
|
||||
if (electronIs.macOS()) {
|
||||
app.dock!.setIcon(path.join(buildDir, 'icon-dev.png'));
|
||||
if (macOS()) {
|
||||
app.dock!.setIcon(join(buildDir, 'icon-dev.png'));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import console from 'node:console';
|
||||
import path from 'node:path';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { APP_WINDOW_MIN_SIZE } from '@lobechat/desktop-bridge';
|
||||
import type { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-client-ipc';
|
||||
@@ -139,7 +139,7 @@ export default class Browser {
|
||||
webPreferences: {
|
||||
backgroundThrottling: false,
|
||||
contextIsolation: true,
|
||||
preload: path.join(preloadDir, 'index.js'),
|
||||
preload: join(preloadDir, 'index.js'),
|
||||
sandbox: false,
|
||||
webviewTag: true,
|
||||
},
|
||||
@@ -238,7 +238,7 @@ export default class Browser {
|
||||
logger.debug(`[${this.identifier}] Window 'ready-to-show' event fired.`);
|
||||
if (this.options.showOnInit) {
|
||||
logger.debug(`Showing window ${this.identifier} because showOnInit is true.`);
|
||||
this.show();
|
||||
browserWindow.show();
|
||||
} else {
|
||||
logger.debug(`Window ${this.identifier} not shown because showOnInit is false.`);
|
||||
}
|
||||
@@ -296,7 +296,6 @@ export default class Browser {
|
||||
|
||||
show(): void {
|
||||
logger.debug(`Showing window: ${this.identifier}`);
|
||||
this.ensureForegroundAppOnMac();
|
||||
if (!this._browserWindow?.isDestroyed()) {
|
||||
this.determineWindowPosition();
|
||||
}
|
||||
@@ -329,7 +328,7 @@ export default class Browser {
|
||||
if (this._browserWindow?.isVisible() && this._browserWindow.isFocused()) {
|
||||
this.hide();
|
||||
} else {
|
||||
this.show();
|
||||
this._browserWindow?.show();
|
||||
this._browserWindow?.focus();
|
||||
}
|
||||
}
|
||||
@@ -388,22 +387,11 @@ export default class Browser {
|
||||
this._browserWindow!.setPosition(newX, newY, false);
|
||||
}
|
||||
|
||||
private ensureForegroundAppOnMac(): void {
|
||||
if (!isMac || this.identifier !== 'app') return;
|
||||
|
||||
try {
|
||||
app.setActivationPolicy('regular');
|
||||
app.dock?.show();
|
||||
} catch (error) {
|
||||
logger.warn(`[${this.identifier}] Failed to restore regular activation policy:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Content Loading ====================
|
||||
|
||||
loadPlaceholder = async (): Promise<void> => {
|
||||
logger.debug(`[${this.identifier}] Loading splash screen placeholder`);
|
||||
await this._browserWindow!.loadFile(path.join(resourcesDir, 'splash.html'));
|
||||
await this._browserWindow!.loadFile(join(resourcesDir, 'splash.html'));
|
||||
logger.debug(`[${this.identifier}] Splash screen placeholder loaded.`);
|
||||
};
|
||||
|
||||
@@ -434,7 +422,7 @@ export default class Browser {
|
||||
private async handleLoadError(urlWithLocale: string): Promise<void> {
|
||||
try {
|
||||
logger.info(`[${this.identifier}] Attempting to load error page...`);
|
||||
await this._browserWindow!.loadFile(path.join(resourcesDir, 'error.html'));
|
||||
await this._browserWindow!.loadFile(join(resourcesDir, 'error.html'));
|
||||
logger.info(`[${this.identifier}] Error page loaded successfully.`);
|
||||
|
||||
this.setupRetryHandler(urlWithLocale);
|
||||
@@ -457,7 +445,7 @@ export default class Browser {
|
||||
} catch (err: any) {
|
||||
logger.error(`[${this.identifier}] Retry connection failed:`, err);
|
||||
try {
|
||||
await this._browserWindow?.loadFile(path.join(resourcesDir, 'error.html'));
|
||||
await this._browserWindow?.loadFile(join(resourcesDir, 'error.html'));
|
||||
} catch (loadErr) {
|
||||
logger.error(`[${this.identifier}] Failed to reload error page:`, loadErr);
|
||||
}
|
||||
|
||||
@@ -39,15 +39,8 @@ export class BrowserManager {
|
||||
|
||||
showMainWindow() {
|
||||
logger.debug('Showing main window');
|
||||
const browser = this.getMainWindow();
|
||||
const window = browser.browserWindow;
|
||||
|
||||
if (window.isMinimized()) {
|
||||
window.restore();
|
||||
}
|
||||
|
||||
browser.show();
|
||||
window.focus();
|
||||
const window = this.getMainWindow();
|
||||
window.show();
|
||||
}
|
||||
|
||||
broadcastToAllWindows = <T extends MainBroadcastEventKey>(
|
||||
@@ -211,23 +204,6 @@ export class BrowserManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open (or focus) the single-instance Quick Chat popup.
|
||||
*
|
||||
* The window is backed by the `topicPopup` template and the route
|
||||
* `/popup/agent/inbox`, so it mounts a fresh Inbox conversation with no
|
||||
* active topic. The first message creates a topic via the normal agent
|
||||
* flow. The `uniqueId` is fixed — repeated invocations focus the existing
|
||||
* window rather than spawning additional ones.
|
||||
*/
|
||||
openQuickChatPopup() {
|
||||
const uniqueId = 'topicPopup_quick_inbox';
|
||||
const result = this.createMultiInstanceWindow('topicPopup', '/popup/agent/inbox', uniqueId);
|
||||
result.browser.show();
|
||||
result.browser.browserWindow.focus();
|
||||
return result;
|
||||
}
|
||||
|
||||
private emitTopicPopupsChanged(): void {
|
||||
this.broadcastToAllWindows('topicPopupsChanged', { popups: this.listTopicPopups() });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import path from 'node:path';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { TITLE_BAR_HEIGHT } from '@lobechat/desktop-bridge';
|
||||
import { type BrowserWindow, type BrowserWindowConstructorOptions, nativeTheme } from 'electron';
|
||||
@@ -118,7 +118,7 @@ export class WindowThemeManager {
|
||||
private getWindowsConfig(isDarkMode: boolean): WindowsThemeConfig {
|
||||
return {
|
||||
backgroundColor: isDarkMode ? BACKGROUND_DARK : BACKGROUND_LIGHT,
|
||||
icon: isDev ? path.join(buildDir, 'icon-dev.ico') : undefined,
|
||||
icon: isDev ? join(buildDir, 'icon-dev.ico') : undefined,
|
||||
titleBarOverlay: this.getWindowsTitleBarOverlay(isDarkMode),
|
||||
titleBarStyle: 'hidden',
|
||||
};
|
||||
|
||||
@@ -5,13 +5,12 @@ import Browser, { type BrowserWindowOpts } from '../Browser';
|
||||
|
||||
// Use vi.hoisted to define mocks before hoisting
|
||||
const {
|
||||
mockAppModule,
|
||||
mockElectronApp,
|
||||
mockBrowserWindow,
|
||||
mockNativeTheme,
|
||||
mockIpcMain,
|
||||
mockScreen,
|
||||
MockBrowserWindow,
|
||||
mockEnv,
|
||||
} = vi.hoisted(() => {
|
||||
const mockBrowserWindow = {
|
||||
center: vi.fn(),
|
||||
@@ -52,24 +51,15 @@ const {
|
||||
},
|
||||
};
|
||||
|
||||
const mockElectronApp = {
|
||||
dock: { setBadge: vi.fn() },
|
||||
setBadgeCount: vi.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
mockAppModule: {
|
||||
dock: {
|
||||
setBadge: vi.fn(),
|
||||
show: vi.fn(),
|
||||
},
|
||||
setActivationPolicy: vi.fn(),
|
||||
setBadgeCount: vi.fn(),
|
||||
},
|
||||
MockBrowserWindow: vi.fn().mockImplementation(() => mockBrowserWindow),
|
||||
mockElectronApp,
|
||||
mockBrowserWindow,
|
||||
mockEnv: {
|
||||
isDev: false,
|
||||
isLinux: false,
|
||||
isMac: false,
|
||||
isMacTahoe: false,
|
||||
isWindows: true,
|
||||
},
|
||||
mockIpcMain: {
|
||||
handle: vi.fn(),
|
||||
removeHandler: vi.fn(),
|
||||
@@ -96,7 +86,7 @@ const {
|
||||
|
||||
// Mock electron
|
||||
vi.mock('electron', () => ({
|
||||
app: mockAppModule,
|
||||
app: mockElectronApp,
|
||||
BrowserWindow: MockBrowserWindow,
|
||||
ipcMain: mockIpcMain,
|
||||
nativeTheme: mockNativeTheme,
|
||||
@@ -121,21 +111,11 @@ vi.mock('@/const/dir', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/const/env', () => ({
|
||||
get isDev() {
|
||||
return mockEnv.isDev;
|
||||
},
|
||||
get isLinux() {
|
||||
return mockEnv.isLinux;
|
||||
},
|
||||
get isMac() {
|
||||
return mockEnv.isMac;
|
||||
},
|
||||
get isMacTahoe() {
|
||||
return mockEnv.isMacTahoe;
|
||||
},
|
||||
get isWindows() {
|
||||
return mockEnv.isWindows;
|
||||
},
|
||||
isDev: false,
|
||||
isLinux: false,
|
||||
isMac: false,
|
||||
isMacTahoe: false,
|
||||
isWindows: true,
|
||||
}));
|
||||
|
||||
vi.mock('../../../const/theme', () => ({
|
||||
@@ -178,10 +158,6 @@ describe('Browser', () => {
|
||||
mockBrowserWindow.loadURL.mockResolvedValue(undefined);
|
||||
mockBrowserWindow.loadFile.mockResolvedValue(undefined);
|
||||
mockNativeTheme.shouldUseDarkColors = false;
|
||||
mockEnv.isLinux = false;
|
||||
mockEnv.isMac = false;
|
||||
mockEnv.isMacTahoe = false;
|
||||
mockEnv.isWindows = true;
|
||||
|
||||
// Create mock App
|
||||
mockStoreManagerGet = vi.fn().mockReturnValue(undefined);
|
||||
@@ -506,19 +482,6 @@ describe('Browser', () => {
|
||||
|
||||
expect(mockBrowserWindow.show).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should restore regular activation policy when showing the main window on macOS', () => {
|
||||
mockEnv.isMac = true;
|
||||
mockEnv.isWindows = false;
|
||||
|
||||
const mainBrowser = new Browser({ ...defaultOptions, identifier: 'app' }, mockApp);
|
||||
vi.spyOn(mainBrowser, 'loadUrl').mockResolvedValue(undefined as any);
|
||||
|
||||
mainBrowser.show();
|
||||
|
||||
expect(mockAppModule.setActivationPolicy).toHaveBeenCalledWith('regular');
|
||||
expect(mockAppModule.dock.show).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hide', () => {
|
||||
|
||||
@@ -6,13 +6,10 @@ import { BrowserManager } from '../BrowserManager';
|
||||
// Use vi.hoisted to define mocks before hoisting
|
||||
const { MockBrowser, mockAppBrowsers, mockWindowTemplates } = vi.hoisted(() => {
|
||||
const createMockBrowserWindow = () => ({
|
||||
focus: vi.fn(),
|
||||
isMaximized: vi.fn().mockReturnValue(false),
|
||||
isMinimized: vi.fn().mockReturnValue(false),
|
||||
maximize: vi.fn(),
|
||||
minimize: vi.fn(),
|
||||
on: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
unmaximize: vi.fn(),
|
||||
webContents: { id: Math.random() },
|
||||
});
|
||||
@@ -139,16 +136,6 @@ describe('BrowserManager', () => {
|
||||
|
||||
const appBrowser = manager.browsers.get('app');
|
||||
expect(appBrowser?.show).toHaveBeenCalled();
|
||||
expect(appBrowser?.browserWindow.focus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should restore a minimized main window before showing it', () => {
|
||||
const appBrowser = manager.getMainWindow();
|
||||
vi.mocked(appBrowser.browserWindow.isMinimized).mockReturnValue(true);
|
||||
|
||||
manager.showMainWindow();
|
||||
|
||||
expect(appBrowser.browserWindow.restore).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { basename, extname } from 'node:path';
|
||||
|
||||
import { app, protocol } from 'electron';
|
||||
import { pathExistsSync } from 'fs-extra';
|
||||
@@ -234,7 +234,7 @@ export class RendererProtocolManager {
|
||||
|
||||
private isAssetRequest(pathname: string) {
|
||||
const normalizedPathname = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
||||
const ext = path.extname(normalizedPathname);
|
||||
const ext = extname(normalizedPathname);
|
||||
|
||||
return (
|
||||
pathname.startsWith('/assets/') ||
|
||||
@@ -246,6 +246,6 @@ export class RendererProtocolManager {
|
||||
}
|
||||
|
||||
private is404Html(filePath: string) {
|
||||
return path.basename(filePath) === '404.html';
|
||||
return basename(filePath) === '404.html';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import path from 'node:path';
|
||||
import { extname, join } from 'node:path';
|
||||
|
||||
import { pathExistsSync } from 'fs-extra';
|
||||
|
||||
@@ -12,10 +12,9 @@ import { RendererProtocolManager } from './RendererProtocolManager';
|
||||
const logger = createLogger('core:RendererUrlManager');
|
||||
|
||||
// Vite build with root=monorepo preserves input path structure,
|
||||
// so index.html / overlay.html / popup.html end up under apps/desktop/ in outDir.
|
||||
const SPA_ENTRY_HTML = path.join(rendererDir, 'apps', 'desktop', 'index.html');
|
||||
const OVERLAY_ENTRY_HTML = path.join(rendererDir, 'apps', 'desktop', 'overlay.html');
|
||||
const POPUP_ENTRY_HTML = path.join(rendererDir, 'apps', 'desktop', 'popup.html');
|
||||
// so index.html / popup.html end up under apps/desktop/ in outDir.
|
||||
const SPA_ENTRY_HTML = join(rendererDir, 'apps', 'desktop', 'index.html');
|
||||
const POPUP_ENTRY_HTML = join(rendererDir, 'apps', 'desktop', 'popup.html');
|
||||
|
||||
export class RendererUrlManager {
|
||||
private readonly rendererProtocolManager: RendererProtocolManager;
|
||||
@@ -63,30 +62,23 @@ export class RendererUrlManager {
|
||||
*/
|
||||
buildRendererUrl(path: string): string {
|
||||
const cleanPath = path.startsWith('/') ? path : `/${path}`;
|
||||
const normalizedBase = this.rendererLoadedUrl.replace(/\/+$/, '');
|
||||
|
||||
return `${normalizedBase}${cleanPath}`;
|
||||
return `${this.rendererLoadedUrl}${cleanPath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve renderer file path in production.
|
||||
* Static assets map directly; /overlay routes fall back to overlay.html;
|
||||
* popup routes go to popup.html; all other routes fall back to index.html (SPA).
|
||||
* Static assets map directly; popup routes go to popup.html, all other
|
||||
* routes fall back to index.html (SPA).
|
||||
*/
|
||||
resolveRendererFilePath = async (url: URL): Promise<string | null> => {
|
||||
const pathname = url.pathname;
|
||||
|
||||
// Static assets: direct file mapping
|
||||
if (pathname.startsWith('/assets/') || path.extname(pathname)) {
|
||||
const filePath = path.join(rendererDir, pathname);
|
||||
if (pathname.startsWith('/assets/') || extname(pathname)) {
|
||||
const filePath = join(rendererDir, pathname);
|
||||
return pathExistsSync(filePath) ? filePath : null;
|
||||
}
|
||||
|
||||
// Overlay entry (separate MPA page)
|
||||
if (pathname === '/overlay' || pathname === '/overlay.html') {
|
||||
return OVERLAY_ENTRY_HTML;
|
||||
}
|
||||
|
||||
// Topic popup window has its own SPA bundle.
|
||||
if (pathname === '/popup' || pathname.startsWith('/popup/')) {
|
||||
return POPUP_ENTRY_HTML;
|
||||
|
||||
@@ -92,18 +92,6 @@ describe('RendererUrlManager', () => {
|
||||
expect(manager.buildRendererUrl('/settings')).toBe('http://localhost:5173/settings');
|
||||
});
|
||||
|
||||
it('should normalize trailing slashes from ELECTRON_RENDERER_URL', async () => {
|
||||
mockIsDev = true;
|
||||
process.env['ELECTRON_RENDERER_URL'] = 'http://localhost:5173/';
|
||||
|
||||
const { RendererUrlManager } = await import('../RendererUrlManager');
|
||||
const manager = new RendererUrlManager();
|
||||
manager.configureRendererLoader();
|
||||
|
||||
expect(manager.buildRendererUrl('/')).toBe('http://localhost:5173/');
|
||||
expect(manager.buildRendererUrl('/overlay')).toBe('http://localhost:5173/overlay');
|
||||
});
|
||||
|
||||
it('should fall back to protocol handler when ELECTRON_RENDERER_URL is not set', async () => {
|
||||
mockIsDev = true;
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import path from 'node:path';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import type { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-client-ipc';
|
||||
import type {
|
||||
DisplayBalloonOptions,
|
||||
Menu as ElectronMenu,
|
||||
MenuItemConstructorOptions,
|
||||
MenuItemConstructorOptions} from 'electron';
|
||||
import {
|
||||
app,
|
||||
Menu,
|
||||
nativeImage,
|
||||
Tray as ElectronTray,
|
||||
} from 'electron';
|
||||
import { app, Menu, nativeImage, Tray as ElectronTray } from 'electron';
|
||||
|
||||
import { resourcesDir } from '@/const/dir';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
@@ -27,12 +30,6 @@ export interface TrayOptions {
|
||||
*/
|
||||
identifier: string;
|
||||
|
||||
/**
|
||||
* Mark the icon as a macOS template image (black + alpha). macOS will
|
||||
* then tint it to match the menu bar appearance automatically.
|
||||
*/
|
||||
isTemplateImage?: boolean;
|
||||
|
||||
/**
|
||||
* Tray tooltip text
|
||||
*/
|
||||
@@ -47,13 +44,6 @@ export class Tray {
|
||||
*/
|
||||
private _tray?: ElectronTray;
|
||||
|
||||
/**
|
||||
* Current context menu. We keep this in-house and pop it up manually on
|
||||
* right-click so that macOS does not swallow the left-click (which would
|
||||
* happen automatically if we called `_tray.setContextMenu(menu)`).
|
||||
*/
|
||||
private _contextMenu?: ElectronMenu;
|
||||
|
||||
/**
|
||||
* Identifier
|
||||
*/
|
||||
@@ -97,16 +87,15 @@ export class Tray {
|
||||
return this._tray;
|
||||
}
|
||||
|
||||
const { iconPath, isTemplateImage, tooltip } = this.options;
|
||||
const { iconPath, tooltip } = this.options;
|
||||
|
||||
// Load tray icon
|
||||
logger.info(`Creating new tray instance: ${this.identifier}`);
|
||||
const iconFile = path.join(resourcesDir, iconPath);
|
||||
const iconFile = join(resourcesDir, iconPath);
|
||||
logger.debug(`[${this.identifier}] Loading icon: ${iconFile}`);
|
||||
|
||||
try {
|
||||
const icon = nativeImage.createFromPath(iconFile);
|
||||
if (isTemplateImage) icon.setTemplateImage(true);
|
||||
this._tray = new ElectronTray(icon);
|
||||
|
||||
// Set tooltip
|
||||
@@ -118,22 +107,12 @@ export class Tray {
|
||||
// Set default context menu
|
||||
this.setContextMenu();
|
||||
|
||||
// Left-click: open Quick Composer.
|
||||
// Set click event
|
||||
this._tray.on('click', () => {
|
||||
logger.debug(`[${this.identifier}] Tray clicked`);
|
||||
this.onClick();
|
||||
});
|
||||
|
||||
// Right-click: pop the stored context menu manually so left-click stays
|
||||
// free (macOS would auto-open the menu on either button if we called
|
||||
// `_tray.setContextMenu`).
|
||||
this._tray.on('right-click', () => {
|
||||
logger.debug(`[${this.identifier}] Tray right-clicked`);
|
||||
if (this._contextMenu && this._tray) {
|
||||
this._tray.popUpContextMenu(this._contextMenu);
|
||||
}
|
||||
});
|
||||
|
||||
logger.debug(`[${this.identifier}] Tray instance created successfully`);
|
||||
return this._tray;
|
||||
} catch (error) {
|
||||
@@ -169,51 +148,40 @@ export class Tray {
|
||||
];
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate(defaultTemplate);
|
||||
// Store the menu instead of calling `_tray.setContextMenu`. The latter
|
||||
// makes macOS intercept left-clicks to show the menu, which conflicts
|
||||
// with our Quick Composer trigger on click.
|
||||
this._contextMenu = contextMenu;
|
||||
this._tray?.setContextMenu(contextMenu);
|
||||
logger.debug(`[${this.identifier}] Tray context menu has been set`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tray click event — opens the Quick Composer overlay.
|
||||
* Right-click opens the context menu (handled by Electron automatically).
|
||||
* Handle tray click event
|
||||
*/
|
||||
onClick() {
|
||||
logger.debug(`[${this.identifier}] Tray click → startSession`);
|
||||
try {
|
||||
void this.app.screenCaptureManager.startSession();
|
||||
} catch (error) {
|
||||
logger.error(`[${this.identifier}] Failed to start capture session:`, error);
|
||||
}
|
||||
}
|
||||
logger.debug(`[${this.identifier}] Handling tray click event`);
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
|
||||
/**
|
||||
* Replace the tray context menu with a pre-built Electron Menu instance.
|
||||
* Stored in-house and popped up manually on right-click to preserve
|
||||
* left-click for the Quick Composer trigger.
|
||||
*/
|
||||
setMenu(menu: ElectronMenu) {
|
||||
logger.debug(`[${this.identifier}] Attaching prebuilt context menu`);
|
||||
this._contextMenu = menu;
|
||||
if (mainWindow) {
|
||||
if (mainWindow.browserWindow.isVisible() && mainWindow.browserWindow.isFocused()) {
|
||||
logger.debug(`[${this.identifier}] Main window is visible and focused, hiding it now`);
|
||||
mainWindow.hide();
|
||||
} else {
|
||||
logger.debug(`[${this.identifier}] Showing and focusing main window`);
|
||||
mainWindow.show();
|
||||
mainWindow.browserWindow.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update tray icon
|
||||
* @param iconPath New icon path (relative to resource directory)
|
||||
* @param isTemplateImage Whether to mark the new icon as a macOS template image
|
||||
*/
|
||||
updateIcon(iconPath: string, isTemplateImage?: boolean) {
|
||||
updateIcon(iconPath: string) {
|
||||
logger.debug(`[${this.identifier}] Updating icon: ${iconPath}`);
|
||||
try {
|
||||
const iconFile = path.join(resourcesDir, iconPath);
|
||||
const iconFile = join(resourcesDir, iconPath);
|
||||
const icon = nativeImage.createFromPath(iconFile);
|
||||
const nextIsTemplate = isTemplateImage ?? this.options.isTemplateImage;
|
||||
if (nextIsTemplate) icon.setTemplateImage(true);
|
||||
this._tray?.setImage(icon);
|
||||
this.options.iconPath = iconPath;
|
||||
if (isTemplateImage !== undefined) this.options.isTemplateImage = isTemplateImage;
|
||||
logger.debug(`[${this.identifier}] Icon updated successfully`);
|
||||
} catch (error) {
|
||||
logger.error(`[${this.identifier}] Failed to update icon:`, error);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-client-ipc';
|
||||
import { nativeTheme } from 'electron';
|
||||
|
||||
import { name } from '@/../../package.json';
|
||||
import { isMac } from '@/const/env';
|
||||
@@ -40,15 +41,7 @@ export class TrayManager {
|
||||
logger.debug('Initialize application tray');
|
||||
|
||||
// Initialize main tray
|
||||
const mainTray = this.initializeMainTray();
|
||||
|
||||
// Attach the platform-specific context menu built by MenuManager so the
|
||||
// tray right-click entries stay in sync with the app menu i18n.
|
||||
try {
|
||||
mainTray.setMenu(this.app.menuManager.buildTrayMenu());
|
||||
} catch (error) {
|
||||
logger.error('Failed to attach tray context menu:', error);
|
||||
}
|
||||
this.initializeMainTray();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,16 +52,18 @@ export class TrayManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize main tray. On macOS we ship a template image (black + alpha)
|
||||
* so the system recolors it automatically for light / dark menu bars.
|
||||
* Initialize main tray
|
||||
*/
|
||||
initializeMainTray() {
|
||||
logger.debug('Initialize main tray');
|
||||
return this.retrieveOrInitialize({
|
||||
iconPath: isMac ? 'trayTemplate.png' : 'tray.png',
|
||||
identifier: 'main',
|
||||
isTemplateImage: isMac,
|
||||
tooltip: name,
|
||||
iconPath: isMac
|
||||
? nativeTheme.shouldUseDarkColorsForSystemIntegratedUI
|
||||
? 'tray-dark.png'
|
||||
: 'tray-light.png'
|
||||
: 'tray.png',
|
||||
identifier: 'main', // Use app icon, ensure this file exists in resources directory
|
||||
tooltip: name, // Can use app.getName() or localized string
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ vi.mock('@/utils/logger', () => ({
|
||||
// Mock desktop global shortcut defaults
|
||||
vi.mock('@lobechat/const/desktopGlobalShortcuts', () => ({
|
||||
DEFAULT_ELECTRON_DESKTOP_SHORTCUTS: {
|
||||
quickComposer: 'Alt+Shift+Space',
|
||||
showApp: '',
|
||||
openSettings: 'CommandOrControl+,',
|
||||
},
|
||||
@@ -57,10 +56,8 @@ describe('ShortcutManager', () => {
|
||||
|
||||
// Mock shortcut method map
|
||||
mockShortcutMethodMap = new Map();
|
||||
const quickComposerMethod = vi.fn();
|
||||
const showAppMethod = vi.fn();
|
||||
const openSettingsMethod = vi.fn();
|
||||
mockShortcutMethodMap.set('quickComposer', quickComposerMethod);
|
||||
mockShortcutMethodMap.set('showApp', showAppMethod);
|
||||
mockShortcutMethodMap.set('openSettings', openSettingsMethod);
|
||||
|
||||
@@ -80,8 +77,7 @@ describe('ShortcutManager', () => {
|
||||
});
|
||||
|
||||
it('should populate shortcuts map from app shortcut method map', () => {
|
||||
expect(shortcutManager['shortcuts'].size).toBe(3);
|
||||
expect(shortcutManager['shortcuts'].has('quickComposer')).toBe(true);
|
||||
expect(shortcutManager['shortcuts'].size).toBe(2);
|
||||
expect(shortcutManager['shortcuts'].has('showApp')).toBe(true);
|
||||
expect(shortcutManager['shortcuts'].has('openSettings')).toBe(true);
|
||||
});
|
||||
@@ -118,17 +114,15 @@ describe('ShortcutManager', () => {
|
||||
|
||||
expect(mockStoreManager.get).toHaveBeenCalledWith('shortcuts');
|
||||
expect(globalShortcut.unregisterAll).toHaveBeenCalled();
|
||||
expect(globalShortcut.register).toHaveBeenCalledWith('Alt+Shift+Space', expect.any(Function));
|
||||
expect(globalShortcut.register).not.toHaveBeenCalledWith('Control+E', expect.any(Function));
|
||||
expect(globalShortcut.register).toHaveBeenCalledWith(
|
||||
'CommandOrControl+,',
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(globalShortcut.register).not.toHaveBeenCalledWith('', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should handle stored config with filtering', () => {
|
||||
const storedConfig = {
|
||||
quickComposer: 'Alt+Shift+Q',
|
||||
showApp: 'Alt+E',
|
||||
openSettings: 'Ctrl+Shift+P',
|
||||
invalidKey: 'Ctrl+I', // Should be filtered out
|
||||
@@ -138,7 +132,6 @@ describe('ShortcutManager', () => {
|
||||
shortcutManager.initialize();
|
||||
|
||||
const config = shortcutManager.getShortcutsConfig();
|
||||
expect(config.quickComposer).toBe('Alt+Shift+Q');
|
||||
expect(config.showApp).toBe('Alt+E');
|
||||
expect(config.openSettings).toBe('Ctrl+Shift+P');
|
||||
expect(config.invalidKey).toBeUndefined();
|
||||
@@ -340,13 +333,6 @@ describe('ShortcutManager', () => {
|
||||
|
||||
describe('unregisterAll', () => {
|
||||
it('should unregister all shortcuts', () => {
|
||||
shortcutManager['shortcutsConfig'] = {
|
||||
quickComposer: 'Alt+Shift+Space',
|
||||
showApp: 'Alt+E',
|
||||
openSettings: 'Ctrl+P',
|
||||
};
|
||||
shortcutManager['registerConfiguredShortcuts']();
|
||||
|
||||
shortcutManager.unregisterAll();
|
||||
|
||||
expect(globalShortcut.unregisterAll).toHaveBeenCalled();
|
||||
@@ -376,7 +362,6 @@ describe('ShortcutManager', () => {
|
||||
|
||||
it('should filter invalid keys from stored config', () => {
|
||||
const storedConfig = {
|
||||
quickComposer: 'Alt+Shift+Q',
|
||||
showApp: 'Alt+E',
|
||||
openSettings: 'Ctrl+P',
|
||||
invalidKey1: 'Ctrl+I',
|
||||
@@ -387,7 +372,6 @@ describe('ShortcutManager', () => {
|
||||
shortcutManager['loadShortcutsConfig']();
|
||||
|
||||
const config = shortcutManager['shortcutsConfig'];
|
||||
expect(config.quickComposer).toBe('Alt+Shift+Q');
|
||||
expect(config.showApp).toBe('Alt+E');
|
||||
expect(config.openSettings).toBe('Ctrl+P');
|
||||
expect(config.invalidKey1).toBeUndefined();
|
||||
@@ -400,21 +384,19 @@ describe('ShortcutManager', () => {
|
||||
it('should add missing default shortcuts', () => {
|
||||
const incompleteConfig = {
|
||||
showApp: 'Alt+E',
|
||||
// Missing quickComposer and openSettings
|
||||
// Missing openSettings
|
||||
};
|
||||
mockStoreManager.get.mockReturnValue(incompleteConfig);
|
||||
|
||||
shortcutManager['loadShortcutsConfig']();
|
||||
|
||||
const config = shortcutManager['shortcutsConfig'];
|
||||
expect(config.quickComposer).toBe('Alt+Shift+Space');
|
||||
expect(config.showApp).toBe('Alt+E');
|
||||
expect(config.openSettings).toBe('CommandOrControl+,'); // Default value
|
||||
});
|
||||
|
||||
it('should not save config if no invalid keys were found', () => {
|
||||
const validConfig = {
|
||||
quickComposer: 'Alt+Shift+Q',
|
||||
showApp: 'Alt+E',
|
||||
openSettings: 'Ctrl+P',
|
||||
};
|
||||
@@ -443,16 +425,11 @@ describe('ShortcutManager', () => {
|
||||
|
||||
describe('saveShortcutsConfig', () => {
|
||||
it('should save shortcuts config to store', () => {
|
||||
shortcutManager['shortcutsConfig'] = {
|
||||
quickComposer: 'Alt+Shift+Q',
|
||||
showApp: 'Alt+E',
|
||||
openSettings: 'Ctrl+P',
|
||||
};
|
||||
shortcutManager['shortcutsConfig'] = { showApp: 'Alt+E', openSettings: 'Ctrl+P' };
|
||||
|
||||
shortcutManager['saveShortcutsConfig']();
|
||||
|
||||
expect(mockStoreManager.set).toHaveBeenCalledWith('shortcuts', {
|
||||
quickComposer: 'Alt+Shift+Q',
|
||||
showApp: 'Alt+E',
|
||||
openSettings: 'Ctrl+P',
|
||||
});
|
||||
@@ -471,7 +448,6 @@ describe('ShortcutManager', () => {
|
||||
describe('registerConfiguredShortcuts', () => {
|
||||
beforeEach(() => {
|
||||
shortcutManager['shortcutsConfig'] = {
|
||||
quickComposer: 'Alt+Shift+Q',
|
||||
showApp: 'Alt+E',
|
||||
openSettings: 'Ctrl+P',
|
||||
};
|
||||
@@ -483,28 +459,24 @@ describe('ShortcutManager', () => {
|
||||
shortcutManager['registerConfiguredShortcuts']();
|
||||
|
||||
expect(globalShortcut.unregisterAll).toHaveBeenCalled();
|
||||
expect(globalShortcut.register).toHaveBeenCalledWith('Alt+Shift+Q', expect.any(Function));
|
||||
expect(globalShortcut.register).toHaveBeenCalledWith('Alt+E', expect.any(Function));
|
||||
expect(globalShortcut.register).toHaveBeenCalledWith('Ctrl+P', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should skip shortcuts not defined in default electron desktop shortcuts', () => {
|
||||
shortcutManager['shortcutsConfig'] = {
|
||||
quickComposer: 'Alt+Shift+Q',
|
||||
showApp: 'Alt+E',
|
||||
invalidKey: 'Ctrl+I',
|
||||
};
|
||||
|
||||
shortcutManager['registerConfiguredShortcuts']();
|
||||
|
||||
expect(globalShortcut.register).toHaveBeenCalledWith('Alt+Shift+Q', expect.any(Function));
|
||||
expect(globalShortcut.register).toHaveBeenCalledWith('Alt+E', expect.any(Function));
|
||||
expect(globalShortcut.register).not.toHaveBeenCalledWith('Ctrl+I', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should skip shortcuts with empty accelerator', () => {
|
||||
shortcutManager['shortcutsConfig'] = {
|
||||
quickComposer: '',
|
||||
showApp: '',
|
||||
openSettings: 'Ctrl+P',
|
||||
};
|
||||
@@ -520,14 +492,12 @@ describe('ShortcutManager', () => {
|
||||
mockShortcutMethodMap.delete('openSettings');
|
||||
shortcutManager = new ShortcutManager(mockApp);
|
||||
shortcutManager['shortcutsConfig'] = {
|
||||
quickComposer: 'Alt+Shift+Q',
|
||||
showApp: 'Alt+E',
|
||||
openSettings: 'Ctrl+P',
|
||||
};
|
||||
|
||||
shortcutManager['registerConfiguredShortcuts']();
|
||||
|
||||
expect(globalShortcut.register).toHaveBeenCalledWith('Alt+Shift+Q', expect.any(Function));
|
||||
expect(globalShortcut.register).toHaveBeenCalledWith('Alt+E', expect.any(Function));
|
||||
expect(globalShortcut.register).not.toHaveBeenCalledWith('Ctrl+P', expect.any(Function));
|
||||
});
|
||||
@@ -536,7 +506,6 @@ describe('ShortcutManager', () => {
|
||||
describe('integration tests', () => {
|
||||
it('should complete full initialization flow', () => {
|
||||
const storedConfig = {
|
||||
quickComposer: 'Alt+Shift+Q',
|
||||
showApp: 'Alt+E',
|
||||
openSettings: 'Ctrl+Shift+P',
|
||||
invalidKey: 'Ctrl+I',
|
||||
@@ -548,12 +517,11 @@ describe('ShortcutManager', () => {
|
||||
|
||||
// Should filter config and register valid shortcuts
|
||||
const config = shortcutManager.getShortcutsConfig();
|
||||
expect(config.quickComposer).toBe('Alt+Shift+Q');
|
||||
expect(config.showApp).toBe('Alt+E');
|
||||
expect(config.openSettings).toBe('Ctrl+Shift+P');
|
||||
expect(config.invalidKey).toBeUndefined();
|
||||
|
||||
expect(globalShortcut.register).toHaveBeenCalledTimes(3);
|
||||
expect(globalShortcut.register).toHaveBeenCalledTimes(2);
|
||||
expect(mockStoreManager.set).toHaveBeenCalledWith('shortcuts', config);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { app, Menu, nativeImage, Tray as ElectronTray } from 'electron';
|
||||
import { app, Menu, nativeImage,Tray as ElectronTray } from 'electron';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '../../App';
|
||||
@@ -47,7 +47,6 @@ describe('Tray', () => {
|
||||
mockElectronTray = {
|
||||
setToolTip: vi.fn(),
|
||||
setContextMenu: vi.fn(),
|
||||
popUpContextMenu: vi.fn(),
|
||||
setImage: vi.fn(),
|
||||
on: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
@@ -75,16 +74,11 @@ describe('Tray', () => {
|
||||
showMainWindow: vi.fn(),
|
||||
getMainWindow: vi.fn(() => mockMainWindow),
|
||||
},
|
||||
screenCaptureManager: {
|
||||
startSession: vi.fn(),
|
||||
},
|
||||
} as unknown as App;
|
||||
|
||||
// Mock electron constructors
|
||||
vi.mocked(ElectronTray).mockImplementation(() => mockElectronTray);
|
||||
vi.mocked(nativeImage.createFromPath).mockReturnValue({
|
||||
setTemplateImage: vi.fn(),
|
||||
} as any);
|
||||
vi.mocked(nativeImage.createFromPath).mockReturnValue({} as any);
|
||||
vi.mocked(Menu.buildFromTemplate).mockReturnValue({} as any);
|
||||
});
|
||||
|
||||
@@ -174,7 +168,7 @@ describe('Tray', () => {
|
||||
expect(mockElectronTray.on).toHaveBeenCalledWith('click', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should build the default context menu and store it in-house', () => {
|
||||
it('should set default context menu', () => {
|
||||
tray = new Tray(
|
||||
{
|
||||
iconPath: 'tray.png',
|
||||
@@ -184,23 +178,7 @@ describe('Tray', () => {
|
||||
);
|
||||
|
||||
expect(Menu.buildFromTemplate).toHaveBeenCalled();
|
||||
// We no longer hand the menu to Electron directly; macOS would hijack
|
||||
// left-click if we did. The menu is popped up manually on right-click.
|
||||
expect(mockElectronTray.setContextMenu).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should register both click and right-click listeners', () => {
|
||||
tray = new Tray(
|
||||
{
|
||||
iconPath: 'tray.png',
|
||||
identifier: 'test-tray',
|
||||
},
|
||||
mockApp,
|
||||
);
|
||||
|
||||
const events = mockElectronTray.on.mock.calls.map((c: any[]) => c[0]);
|
||||
expect(events).toContain('click');
|
||||
expect(events).toContain('right-click');
|
||||
expect(mockElectronTray.setContextMenu).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle errors when creating tray', () => {
|
||||
@@ -243,9 +221,7 @@ describe('Tray', () => {
|
||||
expect.objectContaining({ label: 'Quit' }),
|
||||
]),
|
||||
);
|
||||
// Menu is stored for manual popup on right-click — never handed to
|
||||
// `_tray.setContextMenu`, which would steal left-click on macOS.
|
||||
expect(mockElectronTray.setContextMenu).not.toHaveBeenCalled();
|
||||
expect(mockElectronTray.setContextMenu).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set custom context menu when template provided', () => {
|
||||
@@ -257,37 +233,7 @@ describe('Tray', () => {
|
||||
tray.setContextMenu(customTemplate);
|
||||
|
||||
expect(Menu.buildFromTemplate).toHaveBeenCalledWith(customTemplate);
|
||||
expect(mockElectronTray.setContextMenu).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pop up the stored menu on right-click', () => {
|
||||
// beforeEach cleared mocks after constructing the tray, so capture the
|
||||
// right-click handler from a fresh instance.
|
||||
const mockTrayForRightClick = {
|
||||
setToolTip: vi.fn(),
|
||||
setContextMenu: vi.fn(),
|
||||
popUpContextMenu: vi.fn(),
|
||||
setImage: vi.fn(),
|
||||
on: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
displayBalloon: vi.fn(),
|
||||
};
|
||||
vi.mocked(ElectronTray).mockImplementationOnce(() => mockTrayForRightClick as any);
|
||||
|
||||
const builtMenu = { _mockMenu: true } as any;
|
||||
vi.mocked(Menu.buildFromTemplate).mockReturnValue(builtMenu);
|
||||
|
||||
const freshTray = new Tray({ iconPath: 'tray.png', identifier: 'rc-tray' }, mockApp);
|
||||
freshTray.setContextMenu();
|
||||
|
||||
const rightClickHandler = mockTrayForRightClick.on.mock.calls.find(
|
||||
(c: any[]) => c[0] === 'right-click',
|
||||
)?.[1];
|
||||
expect(rightClickHandler).toBeDefined();
|
||||
|
||||
rightClickHandler?.();
|
||||
|
||||
expect(mockTrayForRightClick.popUpContextMenu).toHaveBeenCalledWith(builtMenu);
|
||||
expect(mockElectronTray.setContextMenu).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call showMainWindow when Show Main Window is clicked', () => {
|
||||
@@ -324,23 +270,40 @@ describe('Tray', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should start the Quick Composer capture session', () => {
|
||||
it('should hide window when it is visible and focused', () => {
|
||||
mockBrowserWindow.isVisible.mockReturnValue(true);
|
||||
mockBrowserWindow.isFocused.mockReturnValue(true);
|
||||
|
||||
tray.onClick();
|
||||
|
||||
expect(mockApp.screenCaptureManager.startSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not touch main window visibility', () => {
|
||||
tray.onClick();
|
||||
|
||||
expect(mockMainWindow.hide).not.toHaveBeenCalled();
|
||||
expect(mockMainWindow.hide).toHaveBeenCalled();
|
||||
expect(mockMainWindow.show).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not throw when startSession rejects', () => {
|
||||
vi.mocked(mockApp.screenCaptureManager.startSession).mockImplementationOnce(() => {
|
||||
throw new Error('capture failed');
|
||||
});
|
||||
it('should show and focus window when it is not visible', () => {
|
||||
mockBrowserWindow.isVisible.mockReturnValue(false);
|
||||
mockBrowserWindow.isFocused.mockReturnValue(false);
|
||||
|
||||
tray.onClick();
|
||||
|
||||
expect(mockMainWindow.show).toHaveBeenCalled();
|
||||
expect(mockBrowserWindow.focus).toHaveBeenCalled();
|
||||
expect(mockMainWindow.hide).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show and focus window when it is visible but not focused', () => {
|
||||
mockBrowserWindow.isVisible.mockReturnValue(true);
|
||||
mockBrowserWindow.isFocused.mockReturnValue(false);
|
||||
|
||||
tray.onClick();
|
||||
|
||||
expect(mockMainWindow.show).toHaveBeenCalled();
|
||||
expect(mockBrowserWindow.focus).toHaveBeenCalled();
|
||||
expect(mockMainWindow.hide).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle case when main window is null', () => {
|
||||
vi.mocked(mockApp.browserManager.getMainWindow).mockReturnValue(null);
|
||||
|
||||
expect(() => tray.onClick()).not.toThrow();
|
||||
});
|
||||
@@ -541,9 +504,11 @@ describe('Tray', () => {
|
||||
tray.updateTooltip('New Tooltip');
|
||||
expect(mockElectronTray.setToolTip).toHaveBeenCalledWith('New Tooltip');
|
||||
|
||||
// Test click behavior — now opens the Quick Composer session
|
||||
// Test click behavior
|
||||
mockBrowserWindow.isVisible.mockReturnValue(true);
|
||||
mockBrowserWindow.isFocused.mockReturnValue(true);
|
||||
tray.onClick();
|
||||
expect(mockApp.screenCaptureManager.startSession).toHaveBeenCalled();
|
||||
expect(mockMainWindow.hide).toHaveBeenCalled();
|
||||
|
||||
// Destroy
|
||||
tray.destroy();
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { nativeTheme } from 'electron';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '../../App';
|
||||
import { Tray } from '../Tray';
|
||||
import { TrayManager } from '../TrayManager';
|
||||
|
||||
// Mock electron modules (empty shim — TrayManager no longer reads nativeTheme)
|
||||
vi.mock('electron', () => ({}));
|
||||
// Mock electron modules
|
||||
vi.mock('electron', () => ({
|
||||
nativeTheme: {
|
||||
shouldUseDarkColorsForSystemIntegratedUI: false,
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
@@ -45,17 +50,12 @@ describe('TrayManager', () => {
|
||||
identifier: 'main',
|
||||
broadcast: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
setMenu: vi.fn(),
|
||||
updateIcon: vi.fn(),
|
||||
updateTooltip: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock App — initializeTrays now pulls a prebuilt menu from MenuManager.
|
||||
mockApp = {
|
||||
menuManager: {
|
||||
buildTrayMenu: vi.fn(() => ({ _mockMenu: true }) as any),
|
||||
},
|
||||
} as unknown as App;
|
||||
// Mock App
|
||||
mockApp = {} as unknown as App;
|
||||
|
||||
// Mock Tray constructor
|
||||
vi.mocked(Tray).mockImplementation(() => mockTray);
|
||||
@@ -86,24 +86,22 @@ describe('TrayManager', () => {
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should attach the platform tray menu to the main tray', () => {
|
||||
trayManager.initializeTrays();
|
||||
|
||||
expect(mockApp.menuManager.buildTrayMenu).toHaveBeenCalled();
|
||||
expect(mockTray.setMenu).toHaveBeenCalledWith({ _mockMenu: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('initializeMainTray', () => {
|
||||
it('should create main tray with a template image on macOS', () => {
|
||||
it('should create main tray with dark icon on macOS when dark mode is enabled', () => {
|
||||
Object.defineProperty(nativeTheme, 'shouldUseDarkColorsForSystemIntegratedUI', {
|
||||
value: true,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const result = trayManager.initializeMainTray();
|
||||
|
||||
expect(Tray).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
iconPath: 'trayTemplate.png',
|
||||
iconPath: 'tray-dark.png',
|
||||
identifier: 'main',
|
||||
isTemplateImage: true,
|
||||
tooltip: 'test-app',
|
||||
}),
|
||||
mockApp,
|
||||
@@ -111,6 +109,25 @@ describe('TrayManager', () => {
|
||||
expect(result).toBe(mockTray);
|
||||
});
|
||||
|
||||
it('should create main tray with light icon on macOS when light mode is enabled', () => {
|
||||
Object.defineProperty(nativeTheme, 'shouldUseDarkColorsForSystemIntegratedUI', {
|
||||
value: false,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
trayManager.initializeMainTray();
|
||||
|
||||
expect(Tray).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
iconPath: 'tray-light.png',
|
||||
identifier: 'main',
|
||||
tooltip: 'test-app',
|
||||
}),
|
||||
mockApp,
|
||||
);
|
||||
});
|
||||
|
||||
it('should add created tray to trays map', () => {
|
||||
trayManager.initializeMainTray();
|
||||
|
||||
|
||||
Vendored
+3
-1
@@ -1,4 +1,4 @@
|
||||
/// <reference types="vite/client" />
|
||||
import 'vite/client';
|
||||
|
||||
/**
|
||||
* `node-mac-permissions` is a macOS-only native module.
|
||||
@@ -30,3 +30,5 @@ declare module 'node-mac-permissions' {
|
||||
export function askForScreenCaptureAccess(openPreferences?: boolean): void;
|
||||
export function askForFullDiskAccess(): void;
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -48,7 +48,6 @@ const menu = {
|
||||
'file.newAgent': 'New Agent',
|
||||
'file.newAgentGroup': 'New Agent Group',
|
||||
'file.newPage': 'New Page',
|
||||
'file.newTab': 'New Tab',
|
||||
'file.newTopic': 'New Topic',
|
||||
'file.preferences': 'Preferences',
|
||||
'file.quit': 'Quit',
|
||||
@@ -71,9 +70,7 @@ const menu = {
|
||||
'macOS.preferences': 'Preferences...',
|
||||
'macOS.services': 'Services',
|
||||
'macOS.unhide': 'Show All',
|
||||
'tray.openMiniToolbar': 'Quick Composer',
|
||||
'tray.open': 'Open {{appName}}',
|
||||
'tray.quickChat': 'Quick Chat',
|
||||
'tray.quit': 'Quit',
|
||||
'tray.show': 'Show {{appName}}',
|
||||
'view.forceReload': 'Force Reload',
|
||||
|
||||
@@ -61,7 +61,6 @@ const createMockApp = () => {
|
||||
'dev.forceReload': 'Force Reload',
|
||||
'dev.devTools': 'Developer Tools',
|
||||
'dev.devPanel': 'Dev Panel',
|
||||
'tray.openMiniToolbar': 'Quick Composer',
|
||||
'tray.open': `Open ${params?.appName || 'App'}`,
|
||||
'tray.quit': 'Quit',
|
||||
};
|
||||
@@ -310,16 +309,14 @@ describe('LinuxMenu', () => {
|
||||
expect(copyItem.role).toBe('copy');
|
||||
});
|
||||
|
||||
it('should bind CmdOrCtrl+W to a smart close handler (tab first, then window)', () => {
|
||||
it('should use role for close (accelerator handled by Electron)', () => {
|
||||
linuxMenu.buildAndSetAppMenu();
|
||||
|
||||
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
|
||||
const fileMenu = template.find((item: any) => item.label === 'File');
|
||||
const closeItem = fileMenu.submenu.find((item: any) => item.label === 'Close');
|
||||
|
||||
expect(closeItem.accelerator).toBe('CmdOrCtrl+W');
|
||||
expect(typeof closeItem.click).toBe('function');
|
||||
expect(closeItem.role).toBeUndefined();
|
||||
expect(closeItem.role).toBe('close');
|
||||
});
|
||||
|
||||
it('should use role for minimize (accelerator handled by Electron)', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MenuItemConstructorOptions } from 'electron';
|
||||
import { app, BrowserWindow, clipboard, dialog, Menu, shell } from 'electron';
|
||||
import { app, clipboard, dialog, Menu, shell } from 'electron';
|
||||
|
||||
import { isDev } from '@/const/env';
|
||||
|
||||
@@ -64,15 +64,6 @@ export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||
},
|
||||
label: t('file.newTopic'),
|
||||
},
|
||||
{
|
||||
accelerator: 'Ctrl+T',
|
||||
click: () => {
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
mainWindow.show();
|
||||
mainWindow.broadcast('createNewTab');
|
||||
},
|
||||
label: t('file.newTab'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
accelerator: 'Alt+Ctrl+A',
|
||||
@@ -113,20 +104,7 @@ export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||
label: t('common.checkUpdates'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
accelerator: 'CmdOrCtrl+W',
|
||||
click: () => {
|
||||
const focused = BrowserWindow.getFocusedWindow();
|
||||
if (!focused) return;
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
if (focused === mainWindow.browserWindow) {
|
||||
mainWindow.broadcast('closeCurrentTabOrWindow');
|
||||
} else {
|
||||
focused.close();
|
||||
}
|
||||
},
|
||||
label: t('window.close'),
|
||||
},
|
||||
{ label: t('window.close'), role: 'close' },
|
||||
{ label: t('window.minimize'), role: 'minimize' },
|
||||
{ type: 'separator' },
|
||||
{ label: t('file.quit'), role: 'quit' },
|
||||
@@ -455,14 +433,6 @@ export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||
click: () => this.app.browserManager.showMainWindow(),
|
||||
label: t('tray.open', { appName }),
|
||||
},
|
||||
{
|
||||
click: () => this.app.screenCaptureManager.startSession(),
|
||||
label: t('tray.openMiniToolbar'),
|
||||
},
|
||||
{
|
||||
click: () => this.app.browserManager.openQuickChatPopup(),
|
||||
label: t('tray.quickChat'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
click: () => this.app.browserManager.retrieveByIdentifier('settings').show(),
|
||||
|
||||
@@ -13,9 +13,6 @@ vi.mock('electron', () => ({
|
||||
setApplicationMenu: vi.fn(),
|
||||
},
|
||||
app: {
|
||||
dock: {
|
||||
setMenu: vi.fn(),
|
||||
},
|
||||
getAppPath: vi.fn(() => '/mock/app/path'),
|
||||
getName: vi.fn(() => 'LobeChat'),
|
||||
getPath: vi.fn((type: string) => {
|
||||
@@ -66,9 +63,6 @@ const createMockApp = () => {
|
||||
show: vi.fn(),
|
||||
})),
|
||||
},
|
||||
screenCaptureManager: {
|
||||
startSession: vi.fn(),
|
||||
},
|
||||
updaterManager: {
|
||||
checkForUpdates: vi.fn(),
|
||||
getUpdaterState: vi.fn(() => ({ stage: 'idle' })),
|
||||
@@ -102,7 +96,6 @@ describe('MacOSMenu', () => {
|
||||
|
||||
expect(Menu.buildFromTemplate).toHaveBeenCalled();
|
||||
expect(Menu.setApplicationMenu).toHaveBeenCalled();
|
||||
expect(app.dock.setMenu).toHaveBeenCalled();
|
||||
expect(menu).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -179,13 +172,6 @@ describe('MacOSMenu', () => {
|
||||
expect(template.some((item: any) => item.label?.includes('Show'))).toBe(true);
|
||||
expect(template.some((item: any) => item.label === 'Quit')).toBe(true);
|
||||
});
|
||||
|
||||
it('should include the mini toolbar action in the dock menu', () => {
|
||||
macOSMenu.buildAndSetAppMenu();
|
||||
|
||||
const dockMenu = (app.dock.setMenu as any).mock.calls[0][0];
|
||||
expect(dockMenu.template.some((item: any) => item.label === 'Quick Composer')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh', () => {
|
||||
@@ -290,19 +276,6 @@ describe('MacOSMenu', () => {
|
||||
expect(preferencesItem.accelerator).toBe('Command+,');
|
||||
});
|
||||
|
||||
it('should not show a fixed accelerator for Quick Composer', () => {
|
||||
macOSMenu.buildAndSetAppMenu();
|
||||
|
||||
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
|
||||
const fileMenu = template.find((item: any) => item.label === 'File');
|
||||
const quickComposerItem = fileMenu.submenu.find(
|
||||
(item: any) => item.label === 'Quick Composer',
|
||||
);
|
||||
|
||||
expect(quickComposerItem).toBeDefined();
|
||||
expect(quickComposerItem.accelerator).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should use role for quit (accelerator handled by Electron)', () => {
|
||||
macOSMenu.buildAndSetAppMenu();
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user