Compare commits

..

1 Commits

Author SHA1 Message Date
Arvin Xu 0752b4ec18 feat(device): add structured working_dirs column to devices
Introduce a `working_dirs` `jsonb` column on the `devices` table plus the
`WorkingDirEntry` type (`{ path, repoType?: 'git' | 'github' }`). Structured
entries let a remote client surface a device's working directories with their
detected repo type — a bare path string would lose that, and the client can't
re-probe a remote filesystem.

Additive migration only: `recent_cwds` is kept (now `@deprecated`) and no data
is converted, so this carries no destructive SQL. Consumers switch over in a
follow-up PR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 20:24:32 +08:00
1247 changed files with 23869 additions and 156837 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: agent-runtime-hooks
description: 'Agent runtime lifecycle hooks. Use for before/after tool or step hooks, tool mocks, human intervention, sub-agent calls, context compression, evals, tracing, callAgent, or lifecycle events.'
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
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: agent-signal
description: 'Build or extend LobeHub Agent Signal pipelines. Use for signal sources, signal/action types, policies, middleware, workflow handoff, dedupe, scope behavior, or observability.'
description: Build or extend LobeHub Agent Signal pipelines for background or quiet agent work driven by event sources, semantic signals, and action handlers. Use when adding a new Agent Signal source, signal or action type, policy, middleware handler, workflow handoff, dedupe or scope behavior, or observability around `src/server/services/agentSignal/**`, `packages/agent-signal`, or `packages/observability-otel/src/modules/agent-signal`.
---
# Agent Signal
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: agent-tracing
description: 'Agent tracing CLI for execution snapshots. Use for agent-tracing, traces, snapshots, LLM call inspection, context engine data, agent step analysis, or execution debugging.'
description: "Agent tracing CLI for inspecting agent execution snapshots. Use when user mentions 'agent-tracing', 'trace', 'snapshot', wants to debug agent execution, inspect LLM calls, view context engine data, or analyze agent steps. Triggers on agent debugging, trace inspection, or execution analysis tasks."
user-invocable: false
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: builtin-tool
description: 'Build LobeHub builtin tool packages. Use when adding agent-callable tools, manifests, executors, runtimes, inspectors, renders, placeholders, streaming, interventions, portals, or tool registries.'
description: Build a new builtin tool package under `packages/builtin-tool-<name>/`. Use when adding a new agent-callable toolset, designing its API surface (manifest / ApiName / Params / State), implementing the Executor + ExecutionRuntime, building the Inspector / Render / Placeholder / Streaming / Intervention / Portal UI, or wiring a tool into the central registries (`packages/builtin-tools/src/{index,identifiers,inspectors,renders,placeholders,streamings,interventions,portals}.ts` and `src/store/tool/slices/builtin/executors/index.ts`). Triggers on "new builtin tool", "add a tool", "tool inspector", "tool render", "tool placeholder", "tool streaming", "tool intervention", "BuiltinToolManifest", "BaseExecutor", "ExecutionRuntime".
---
# Builtin Tool Authoring Guide
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: chat-sdk
description: 'Build multi-platform chat bots with the chat SDK. Use for Slack, Teams, Google Chat, Discord, GitHub, Linear bots, webhooks, mentions, slash commands, cards, modals, or streaming responses.'
description: "Build multi-platform chat bots with the Chat SDK (`chat` npm package) — Slack, Teams, Google Chat, Discord, GitHub, Linear. Use when building a chat bot, handling mentions / messages / reactions / slash commands / cards / modals / streaming, setting up a webhook handler, or sending interactive cards / streaming AI responses to a chat platform. Triggers on `@chat-adapter`, 'chat sdk', 'chat bot', 'slack bot', 'teams bot', 'discord bot', 'webhook handler', 'cross-platform bot'."
user-invocable: false
---
+58 -11
View File
@@ -29,9 +29,10 @@ Standard workflow for verifying backend changes using the LobeHub CLI (`lh`) aga
## Quick Reference
All CLI dev commands run from `lobehub/apps/cli/`. Subsequent examples use `$CLI`:
All CLI dev commands run from `lobehub/apps/cli/`:
```bash
# Shorthand for all commands below
CLI="LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts"
```
@@ -39,14 +40,17 @@ CLI="LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts"
### Step 1: Ensure Dev Server is Running
Check if the dev server is already running:
```bash
curl -s -o /dev/null -w '%{http_code}' http://localhost:3011/ 2> /dev/null
```
- **If reachable**: skip to Step 2.
- **If unreachable**: start from cloud repo root:
- **If reachable** (returns any HTTP status): server is running. Skip to Step 2.
- **If unreachable**: start the server:
```bash
# From cloud repo root
pnpm run dev:next
```
@@ -61,33 +65,37 @@ pnpm run dev:next
### Step 2: Check CLI Authentication
Check if dev credentials already exist:
```bash
cat lobehub/apps/cli/.lobehub-dev/settings.json 2> /dev/null
```
- **If file exists and contains `"serverUrl": "http://localhost:3011"`**: skip to Step 3.
- **If missing or wrong server**: ask the user to run:
- **If file exists and contains `"serverUrl": "http://localhost:3011"`**: already authenticated. Skip to Step 3.
- **If file missing or points to wrong server**: login is needed. Ask the user to run:
```bash
! cd lobehub/apps/cli && LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts login --server http://localhost:3011
```
> Login requires interactive browser authorization (OIDC Device Code Flow), so the user must run it themselves via `!` prefix. Credentials persist in `lobehub/apps/cli/.lobehub-dev/`.
> Login requires interactive browser authorization (OIDC Device Code Flow), so the user must run it themselves via `!` prefix. After login, credentials are saved to `lobehub/apps/cli/.lobehub-dev/` and persist across sessions.
### Step 3: Test with CLI Commands
CLI runs from source, so CLI-side code changes take effect immediately without rebuilding.
CLI runs from source (`bun src/index.ts`), so CLI-side code changes take effect immediately without rebuilding.
```bash
cd lobehub/apps/cli
$CLI <command>
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts <command>
```
### Step 4: Clean Up Test Data
Delete any test data created during verification:
```bash
$CLI task delete < id > -y
$CLI agent delete < id > -y
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts task delete < id > -y
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts agent delete < id > -y
```
## Common Testing Patterns
@@ -95,30 +103,51 @@ $CLI agent delete < id > -y
### Task System
```bash
# List tasks
$CLI task list
# Create test data with nesting
$CLI task create -n "Root Task" -i "Test instruction"
$CLI task create -n "Child Task" -i "Sub instruction" --parent T-1
# View task detail (tests getTaskDetail service)
$CLI task view T-1
# View task tree
$CLI task tree T-1
# Test lifecycle
$CLI task edit T-1 --status running
$CLI task comment T-1 -m "Test comment"
# Clean up
$CLI task delete T-1 -y
```
### Agent System
```bash
# List agents
$CLI agent list
# View agent detail
$CLI agent view <agent-id>
# Run agent (tests agent execution pipeline)
$CLI agent run <agent-id> -m "Test prompt"
```
### Document & Knowledge Base
```bash
# List documents
$CLI doc list
# Create and view
$CLI doc create -t "Test Doc" -c "Content here"
$CLI doc view <doc-id>
# Knowledge base
$CLI kb list
$CLI kb tree <kb-id>
```
@@ -126,13 +155,18 @@ $CLI kb tree <kb-id>
### Model & Provider
```bash
# List models and providers
$CLI model list
$CLI provider list
# Test provider connectivity
$CLI provider test <provider-id>
```
## Dev-Test Cycle
The standard cycle for backend development:
```
1. Make code changes (service/model/router/type)
|
@@ -143,7 +177,7 @@ $CLI provider test <provider-id>
lsof -ti:3011 | xargs kill && pnpm run dev:next
|
4. CLI verification (end-to-end)
$CLI <command>
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts <command>
|
5. Clean up test data
```
@@ -159,6 +193,10 @@ $CLI provider test <provider-id>
| `lobehub/apps/cli/` (CLI code) | No |
| `src/` (cloud overrides) | Yes |
### When Server Restart is NOT Needed
CLI runs from source via `bun src/index.ts`, so any changes to `lobehub/apps/cli/src/` take effect immediately on next command invocation.
## Troubleshooting
| Issue | Solution |
@@ -169,3 +207,12 @@ $CLI provider test <provider-id>
| CLI shows old data/behavior | Server needs restart to pick up code changes |
| `EADDRINUSE` on port 3011 | Server already running; kill with `lsof -ti:3011 \| xargs kill` |
| Login opens wrong server | Must use `--server http://localhost:3011` flag (env var doesn't work) |
## Credential Isolation
| Mode | Credential Dir | Server |
| ---------- | -------------------------------- | ----------------- |
| Dev | `lobehub/apps/cli/.lobehub-dev/` | `localhost:3011` |
| Production | `~/.lobehub/` | `app.lobehub.com` |
The two environments are completely isolated. Dev mode credentials are gitignored.
@@ -1,6 +1,6 @@
---
name: data-fetching-architecture
description: 'LobeHub data-fetching pipeline guide. Use for service layer, Zustand store, SWR, lambdaClient, useClientDataSWR, useFetchXxx hooks, or migrating useEffect fetches.'
description: Standardized data-fetching pipeline guide — Service layer + Zustand Store + SWR. Use when implementing a data-fetching feature, creating a `xxxService`, adding a `useFetchXxx` hook, wiring `useClientDataSWR`, or migrating ad-hoc `useEffect + fetch` to the standard pipeline. Triggers on `lambdaClient`, `useClientDataSWR`, `xxxService`, `useFetchXxx`, 'data fetching', 'fetch architecture', 'service layer', 'SWR hook', 'migrate useEffect'.
user-invocable: false
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: db-migrations
description: 'Use for Drizzle migrations: schema/table/column changes, migration generation or regeneration, sequence conflicts after rebase, idempotent SQL review, or migration renames.'
description: 'Use when generating or regenerating Drizzle migration files, changing database schema tables or columns, resolving migration sequence conflicts after rebase, reviewing migration SQL for idempotent patterns, or renaming migration files.'
user-invocable: false
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: debug-package
description: 'LobeHub debug package and log namespace guide. Use when adding debug() logging, choosing lobe-* namespaces, troubleshooting DEBUG output, localStorage.debug, or log format specifiers.'
description: "Guide for the `debug` npm package and LobeHub log namespaces (lobe-server:*, lobe-desktop:*, lobe-client:*, lobe-*-router:*). Use whenever adding a `debug(...)` logger, picking a namespace for new server/desktop/client/router code, troubleshooting why DEBUG=lobe-* logs don't show up, or when the user asks to 'add logging', 'add a logger', 'instrument this', 'trace this call', 'why isn't my log printing', or mentions `debug(`, `DEBUG=`, `localStorage.debug`, or log format specifiers like %O / %o / %s / %d in a LobeHub codebase."
user-invocable: false
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: docs-changelog
description: 'Write website changelog pages under docs/changelog/*.mdx. Use for EN/ZH product update posts, changelog posts, update-log copy, or docs changelog edits; not GitHub Release notes.'
description: "Writing guide for website changelog pages under `docs/changelog/*.mdx` (NOT GitHub Release notes — those live in the `version-release` skill). Use when creating or editing a product update post in EN/ZH. Triggers on `docs/changelog/*.mdx`, 'changelog post', 'product update post', 'add a changelog', '更新日志', 'changelog 文案'."
---
# Docs Changelog Writing Guide
+4 -92
View File
@@ -1,6 +1,6 @@
---
name: drizzle
description: 'LobeHub Drizzle ORM schema and query style. Use for pgTable schemas, indexes, joins, inferred types, db.select/db.query, schema fields, foreign keys, junction tables, or postgres query patterns.'
description: "Drizzle ORM schema authoring and query style for LobeHub (postgres, strict mode). Use when editing anything under `src/database/schemas/`, defining `pgTable` columns/indexes/junction tables, spreading `...timestamps`, generating `createInsertSchema`/`$inferSelect`/`$inferInsert` types, writing `db.select().from(...).leftJoin(...)` queries, or deciding when to split a relational `with:` into two queries. Triggers on `pgTable`, `db.select`, `db.query`, `eq()`/`and()`/`inArray()`, `uniqueIndex`, `primaryKey`, `references({ onDelete })`, 'add a column', 'new table', 'foreign key', 'junction table', 'schema field'. For migration files specifically, see the `db-migrations` skill."
user-invocable: false
---
@@ -9,13 +9,13 @@ user-invocable: false
## Configuration
- Config: `drizzle.config.ts`
- Schemas: `packages/database/src/schemas/`
- Migrations: `packages/database/migrations/`
- Schemas: `src/database/schemas/`
- Migrations: `src/database/migrations/`
- Dialect: `postgresql` with `strict: true`
## Helper Functions
Location: `packages/database/src/schemas/_helpers.ts`
Location: `src/database/schemas/_helpers.ts`
- `timestamptz(name)`: Timestamp with timezone
- `createdAt()`, `updatedAt()`, `accessedAt()`: Standard timestamp columns
@@ -174,94 +174,6 @@ const rows = await this.db
.groupBy(agentEvalDatasets.id);
```
### Raw SQL and Advanced Queries
Prefer Drizzle builders whenever the query can be expressed clearly with `select`,
`insert().select()`, `update().from()`, joins, CTEs, `groupBy`, and typed selected
columns. This keeps table and column references tied to schema definitions, so
schema changes are more likely to surface as TypeScript errors.
Expression-level `sql<T>` is fine inside a Drizzle builder for PostgreSQL features
that do not have a dedicated helper, such as JSON path extraction, casts, aggregate
expressions, `CASE`, `NOW()`, or advisory locks. Row locks are query clauses, not
expressions; use the select builder's `.for('update')` instead of raw
`FOR UPDATE` SQL fragments.
When refactoring raw SQL:
- Preserve the original query shape for latency-sensitive paths. If raw SQL is one
database roundtrip, do not replace it with multiple depth-based queries just to
remove `execute`.
- Use `$with(...)` plus `insert().select()` / `update().from()` for multi-step
single-roundtrip writes when Drizzle can express the data flow.
- Avoid generic `execute<MyRow>(sql...)` as the main safety mechanism. It types the
returned rows, but it does not keep selected columns in sync with schema changes.
- If the only clean implementation is a PostgreSQL feature that Drizzle cannot
express well, keep the raw SQL and tighten it instead: use schema references in
interpolations, explicit user scope, a narrow row interface, and regression tests.
Recursive CTEs are a special case: current Drizzle usage in this repo does not have
a clean `WITH RECURSIVE` builder pattern. Keep recursive CTE raw SQL when replacing
it would add extra database roundtrips or materially worsen performance.
Example: convert an aggregate query when Drizzle can preserve one roundtrip:
```typescript
// ✅ Good: builder owns table and column references; sql<T> stays expression-level.
const rows = await trx
.select({
model: messages.model,
provider: messages.provider,
totalCost: sql<string | null>`sum((${messages.metadata}->'usage'->>'cost')::numeric)`.as(
'totalCost',
),
})
.from(messages)
.where(
and(
eq(messages.topicId, topicId),
eq(messages.userId, userId),
eq(messages.role, 'assistant'),
sql`${messages.metadata} ? 'usage'`,
),
)
.groupBy(messages.provider, messages.model);
```
Example: use the select lock builder for row locks:
```typescript
const [user] = await trx.select().from(users).where(eq(users.id, userId)).for('update');
```
Example: keep a recursive CTE raw when replacing it would add depth-based DB
roundtrips:
```typescript
interface TaskTreeRow {
id: string;
parent_task_id: string | null;
}
// execute<T> is acceptable here only because Drizzle has no clean WITH RECURSIVE
// builder; a builder rewrite would add depth-based roundtrips. Keep schema refs in
// the interpolations and scope every leg to the user.
const { rows } = await db.execute<TaskTreeRow>(sql`
WITH RECURSIVE task_tree AS (
SELECT ${tasks.id}, ${tasks.parentTaskId}
FROM ${tasks}
WHERE ${tasks.id} = ${rootTaskId}
AND ${tasks.createdByUserId} = ${userId}
UNION ALL
SELECT ${tasks.id}, ${tasks.parentTaskId}
FROM ${tasks}
JOIN task_tree ON ${tasks.parentTaskId} = task_tree.id
WHERE ${tasks.createdByUserId} = ${userId}
)
SELECT * FROM task_tree
`);
```
### One-to-Many (Separate Queries)
When you need a parent record with its children, use two queries instead of relational `with:`:
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: heterogeneous-agent
description: 'Implement or debug LobeHub heterogeneous agents. Use for Claude Code/Codex adapters, external CLI agents, event mapping, IPC, persistence, tool-call chains, sessions, traces, or adapter bugs.'
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
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: hotkey
description: 'Add or edit LobeHub keyboard shortcuts. Use for HotkeyEnum, HOTKEYS_REGISTRATION, combineKeys, useHotkeyById, tooltip hotkeys, shortcut scope, conflicts, or Cmd/Ctrl key combos.'
description: "Adding or editing keyboard shortcuts in LobeHub. Use when registering a new hotkey, changing a key combo, scoping a shortcut to chat vs global, or wiring a hotkey hook + tooltip. Covers the 5-step flow: add to `HotkeyEnum` in `src/types/hotkey.ts`, register in `HOTKEYS_REGISTRATION` (`src/const/hotkeys.ts`) with `combineKeys([Key.Mod, …])`, add i18n in `src/locales/default/hotkey.ts`, expose via `useHotkeyById` in `src/hooks/useHotkeys/`, and render `<Tooltip hotkey={…}>`. Triggers on `HotkeyEnum`, `HOTKEYS_REGISTRATION`, `useHotkeyById`, `combineKeys`, `Key.Mod`/`Key.Shift`, 'add a hotkey', 'add a shortcut', '加快捷键', '快捷键', 'Cmd+K', 'keyboard shortcut', 'hotkey scope', 'hotkey conflict'."
user-invocable: false
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: i18n
description: 'LobeHub i18n with react-i18next. Use for user-facing strings, locale keys, namespaces, useTranslation, t(), interpolation, zh-CN/en-US previews, hardcoded UI copy, or pnpm i18n.'
description: "LobeHub internationalization with react-i18next. Use when adding any user-facing string in `.tsx`/`.ts` files, creating or renaming a key under `src/locales/default/{namespace}.ts`, deciding the `{feature}.{context}.{action}` flat-key pattern, wiring a new namespace into `src/locales/default/index.ts`, or translating zh-CN/en-US JSON for dev preview. Triggers on `useTranslation`, `t('foo.bar')`, `i18next.t`, `{{variable}}` interpolation, hardcoded UI strings (zh or en) that should be extracted, 'add i18n', '加 i18n key', '翻译', 'locale key', 'namespace', 'pnpm i18n'."
user-invocable: false
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: linear
description: 'Linear issue management. Use for LOBE-xxx issues, Linear links, PRs referencing Linear, retrieving issues, updating status, completion comments, or sub-issue trees.'
description: "Linear issue management. Use when the user mentions LOBE-xxx issue IDs (e.g. LOBE-4540), says 'linear' / 'linear issue' / 'link linear', or when creating PRs that reference Linear issues. Covers retrieving issues, updating status, adding completion comments, and creating sub-issue trees."
user-invocable: false
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: microcopy
description: 'UI copy and microcopy guidelines. Use for user-facing copy, buttons, errors, empty states, onboarding, i18n wording, translation, or copy improvements in Chinese or English.'
description: UI copy and microcopy guidelines. Use when writing UI text, buttons, error messages, empty states, onboarding, or any user-facing copy. Triggers on i18n translation, UI text writing, or copy improvement tasks. Supports both Chinese and English.
user-invocable: false
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: modal
description: 'LobeHub imperative modal conventions. Use when creating or migrating modals, dialogs, popups, confirm flows, ModalHost wiring, createModal, confirmModal, useModalContext, or base-ui modal APIs.'
description: "LobeHub imperative-modal conventions. Use whenever creating, editing, opening, or migrating a modal/dialog/popup — prefer `createModal` / `confirmModal` / `useModalContext` from `@lobehub/ui/base-ui` (headless) over the legacy root `@lobehub/ui` `createModal` (antd Modal props) and over any declarative `open` state + `<Modal />` pattern. Covers required `ModalHost` mounting, the `Content` + `index.tsx` file layout, `content` vs `children` slot, i18n inside `createModal()` (`import { t } from 'i18next'`), and migration notes. Triggers on `createModal`, `confirmModal`, `useModalContext`, `ModalHost`, `antd Modal`, `<Modal open>`, 'open a modal', 'popup', 'dialog', 'confirm dialog', '弹框', '弹窗', '确认框', 'migrate to base-ui'."
user-invocable: false
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: project-overview
description: 'LobeHub open-source monorepo architecture map. Use when locating code layers, understanding apps/packages/src layout, business stubs, project structure, or onboarding to the repository.'
description: "LobeHub open-source monorepo architecture map — flat `apps/` + `packages/@lobechat/*` + `src/` layout, per-layer location table, and `src/business/` stubs that the cloud repo overrides. Use when exploring an unfamiliar part of the codebase, locating where a layer lives (store / service / router / schema / etc.), or onboarding to the monorepo. Triggers on 'where does X live', 'project structure', 'monorepo layout', `src/business/` stub, 'architecture overview', '项目结构', '架构总览'."
user-invocable: false
---
+1 -5
View File
@@ -1,6 +1,6 @@
---
name: react
description: 'LobeHub React component conventions. Use when editing TSX UI, choosing base-ui vs @lobehub/ui vs antd, styling with antd-style, routing, desktop variants, layouts, or component state.'
description: "LobeHub React component conventions — base-ui (`@lobehub/ui/base-ui`) first for headless primitives (Select, Modal, DropdownMenu, ContextMenu, Popover, ScrollArea, Switch, Toast, FloatingSheet), then `@lobehub/ui` root, antd as last resort; styling via `antd-style` `createStaticStyles` + `cssVar.*` (zero-runtime preferred over `createStyles` + `token`); routing via `react-router-dom` (not `next/link`). Use when writing or editing any `.tsx` under `src/**`. Triggers on `createStaticStyles`, `createStyles`, `cssVar`, `antd-style`, `Flexbox`, `Center`, `Select`, `Modal`, `Drawer`, `Button`, `Tooltip`, `DropdownMenu`, `ContextMenu`, `Popover`, `Switch`, `ScrollArea`, `Toast`, `FloatingSheet`, `Link`, `useNavigate`, `react-router-dom`, `next/link`, `desktopRouter`, `componentMap.desktop`, `.desktop.tsx`, `base-ui`, `@lobehub/ui/base-ui`, 'new component', 'new page', 'edit layout', 'add styles', 'zustand selector', '@lobehub/ui', 'antd import'."
user-invocable: false
---
@@ -53,10 +53,6 @@ For Modal specifically, see the dedicated **modal** skill — use the imperative
| Layout | Center, DraggablePanel, Flexbox, Grid, Header, MaskShadow |
| Navigation | Burger, Menu, SideNav, Tabs |
## State
When a feature component manages more than 3 pieces of state (`useState`/`useReducer`/derived state), extract the logic into a custom hook (e.g. `useXxx`). Keep the component focused on rendering — the hook holds state and handlers, so logic can be unit-tested without rendering the component.
## Layout
Use `Flexbox` and `Center` from `@lobehub/ui`. See `references/layout-kit.md` for full props and examples.
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: response-compliance
description: 'OpenResponses API compliance testing. Use for Response API endpoint tests, compliance runs, schema debugging, response api test, or openresponses test tasks.'
description: OpenResponses API compliance testing. Use when testing the Response API endpoint, running compliance tests, or debugging Response API schema issues. Triggers on 'compliance', 'response api test', 'openresponses test'.
---
# OpenResponses Compliance Test
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: review-checklist
description: 'LobeHub code review checklist. Use when reviewing a PR, diff, or branch for console leftovers, return await, secrets, i18n, desktop router drift, UI imports, migrations, or cloud impact.'
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 a PR, diff, or branch change. Triggers on 'code review', 'review the diff', 'review this PR', 'review changes', 'PR review checklist', '审一下', '审 PR'."
user-invocable: false
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: skills-audit
description: 'Audit .agents/skills SKILL.md files. Use for recurring checks of duplicate, overlapping, stale, inconsistent, or broken skills and merge/delete candidates.'
description: Weekly audit of `.agents/skills/*/SKILL.md` — surfaces duplicate / overlapping / stale skills, inconsistent descriptions, broken cross-references, and merge/delete candidates. Run as a recurring health-check, not during normal feature work.
disable-model-invocation: true
argument-hint: '[--verbose | --apply]'
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: spa-routes
description: 'LobeHub SPA route architecture. Use when editing src/routes, src/features delegation, desktop/mobile/popup router configs, .desktop variants, route segments, redirects, or new pages.'
description: "SPA roots-vs-features split for LobeHub — thin route segments under `src/routes/` delegate to domain components under `src/features/`. Use when editing `src/routes/` segments, `src/spa/router/desktopRouter.config.tsx` or `desktopRouter.config.desktop.tsx` (MUST update both together — `desktopRouter.sync.test.tsx` enforces this), `mobileRouter.config.tsx`, `popupRouter.config.tsx`, any colocated `<name>.desktop.{ts,tsx}` variant (e.g. settings `componentMap.ts` × `componentMap.desktop.ts`, page-level `index.tsx` × `index.desktop.tsx`), or moving UI/logic between `routes/` and `features/`. Triggers on `desktopRouter.config`, `mobileRouter.config`, `popupRouter.config`, `componentMap.desktop`, `index.desktop.tsx`, `.desktop.tsx` variant, `src/routes/**`, `src/features/**`, 'add a route', 'new page', 'route segment', '路由'."
user-invocable: false
---
@@ -1,6 +1,6 @@
---
name: store-data-structures
description: 'LobeHub Zustand store data-shape patterns. Use when designing store state, list/detail splits, normalized maps, reducers, messagesMap, topicsMap, or choosing shared type sources.'
description: "Zustand store data-shape patterns for LobeHub — List vs Detail split, Map + Reducer, type definitions sourced from `@lobechat/types` (not `@lobechat/database`). Use when designing store state, choosing between Array (list) and `Record<string, Detail>` (detail map), or implementing a list/detail page pair. Triggers on `messagesMap`, `topicsMap`, `Record<string, Detail>`, 'list vs detail', 'store data shape', 'normalize state', 'state structure'."
user-invocable: false
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: testing
description: 'Vitest testing guide. Use when writing or updating tests, fixing failing tests, improving coverage, debugging test issues, or setting up mocks.'
description: Testing guide using Vitest. Use when writing tests (.test.ts, .test.tsx), fixing failing tests, improving test coverage, or debugging test issues. Triggers on test creation, test debugging, mock setup, or test-related questions.
user-invocable: false
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: trpc-router
description: 'TRPC router development guide. Use when creating or modifying src/server/routers, adding procedures, or implementing server-side API endpoints.'
description: TRPC router development guide. Use when creating or modifying TRPC routers (src/server/routers/**), adding procedures, or working with server-side API endpoints. Triggers on TRPC router creation, procedure implementation, or API endpoint tasks.
user-invocable: false
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: typescript
description: 'LobeHub TypeScript style and type-safety guide. Use when editing TS/TSX/MTS, fixing types, choosing interface vs type, avoiding any/object, import type, async flow, or ts-expect-error.'
description: "TypeScript code style and type-safety guide for LobeHub. Read before writing or editing any `.ts` / `.tsx` / `.mts` — covers `interface` vs `type`, `Record<PropertyKey, unknown>` over `any`/`object`, `as const satisfies`, `@ts-expect-error` over `@ts-ignore`, `import type` (`separate-type-imports`), `async`/`await` + `Promise.all`, `for…of` over indexed `for`, and the no-silent-`.catch(() => fallback)` rule. Also use when reviewing type quality, deciding module augmentation (`declare module`) over `namespace`, or designing extensible types (e.g. `PipelineContext.metadata`). Triggers on any TypeScript file edit, 'fix the type', 'why is this `any`', 'should this be interface or type', 'eslint type-import', 'ts-expect-error'."
user-invocable: false
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: upstash-workflow
description: 'LobeHub Upstash Workflow and QStash guide. Use for async workflows, process/paginate/execute fan-out, serve handlers, context.run/call/sleep, or workflow triggers.'
description: "Upstash Workflow + QStash implementation guide for LobeHub — 3-layer architecture (process → paginate → execute), fan-out patterns. Use when creating an async workflow, implementing fan-out (paginateexecute), or wiring `serve()` + `context.run` / `context.call` steps. Triggers on `serve()`, `context.run`, `context.call`, `context.sleep`, `qstash`, 'async workflow', 'fan-out workflow', 'QStash workflow'."
user-invocable: false
---
+41 -5
View File
@@ -1,6 +1,6 @@
---
name: zustand
description: 'LobeHub Zustand store conventions. Use when editing src/store, store slices, public/internal actions, dispatch actions, flattenActions, optimistic updates, selectors, maps, or class action migration.'
description: "LobeHub Zustand store conventions: public/internal/dispatch action layers, optimistic update pattern, slice composition via `flattenActions`, and class-based action migration. Use whenever working under `src/store/**`, adding a `createXxxSlice`, writing `internal_*` or `internal_dispatch*` actions, designing `messagesMap`/`topicsMap` reducers, refactoring a `StateCreator` object slice into a `XxxActionImpl` class, or debugging stale store reads. Triggers on `useChatStore`/`useUserStore`/`useGlobalStore`, `createStore`, `flattenActions`, `StoreSetter`, `internal_dispatch`, 'add an action', 'zustand selector', 'store slice', 'class action', 'optimistic update'."
user-invocable: false
---
@@ -177,12 +177,29 @@ export const chatGroupAction: StateCreator<
### Slices That Don't Currently Need `set`
When a slice doesn't write local state (e.g. it delegates to another store or just runs hooks), drop `#set` and mark the constructor param as `_set` with `void _set` to keep the `(set, get, api)` shape:
When a slice doesn't write local state at the moment — e.g. it reads context
from `#get()` and forwards calls to another store, or just runs hooks — drop
the `#set` field. Otherwise ESLint's `no-unused-vars` flags the unused private
field.
Mark the constructor's `set` param as `_set` and `void _set` it to keep the
`(set, get, api)` shape aligned with `StateCreator`. This is **a snapshot of
the current need, not a permanent contract** — if a later change needs `set`,
restore the `#set` field and use it; do not invent a workaround to keep the
"unused" form.
```ts
type Setter = StoreSetter<ConversationStore>;
export const toolSlice = (set: Setter, get: () => ConversationStore, _api?: unknown) =>
new ToolActionImpl(set, get, _api);
export class ToolActionImpl {
readonly #get: () => ConversationStore;
// Mark unused params with `_` prefix and `void _x` so the constructor still
// matches StateCreator's `(set, get, api)` shape without triggering unused
// diagnostics.
constructor(_set: Setter, get: () => ConversationStore, _api?: unknown) {
void _set;
void _api;
@@ -195,8 +212,27 @@ export class ToolActionImpl {
hooks.onToolCallComplete?.(id, undefined);
};
}
export type ToolAction = Pick<ToolActionImpl, keyof ToolActionImpl>;
```
- Drop `#set` when unused; restore it when a later edit needs `set` — re-adding costs nothing.
- Don't add `setNamespace` for slices that don't write state.
- Don't keep both old slice objects and class actions active at the same time during migration.
Rules of thumb:
- If a slice doesn't currently call `set`, drop `#set` (use `_set` + `void _set`
in the constructor). When a later edit needs `set`, restore `#set` and use it.
- Don't add `setNamespace` for slices that don't write state. Add it when the
slice starts writing state.
- Never leave `#set` declared but unused "for future use" — lint will fail and
re-adding it later costs nothing.
### Do / Don't
- **Do**: keep constructor signature aligned with `StateCreator` params `(set, get, api)`.
- **Do**: use `#private` to avoid `set/get` being exposed.
- **Do**: use `flattenActions` instead of spreading class instances.
- **Do**: drop `#set` (and use `_set` + `void _set` in the constructor) for
delegate-only slices that never write state — keeps lint green without
breaking the `(set, get, api)` shape.
- **Don't**: keep both old slice objects and class actions active at the same time.
- **Don't**: keep an unused `#set` field "for future use" — it fails ESLint and
re-adding it later costs nothing.
+1 -2
View File
@@ -1,7 +1,6 @@
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
locales/
apps/desktop/resources/locales/
**/__snapshots__/
**/fixtures/
packages/database/migrations/
src/database/migrations/
-28
View File
@@ -223,29 +223,6 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# The LobeChat agents market index url
# AGENTS_INDEX_URL=https://chat-agents.lobehub.com
# #######################################
# ######### Cloud Sandbox Service #######
# #######################################
# Sandbox provider for built-in code execution, shell, file operations, and export.
# Supported values: market, onlyboxes
# SANDBOX_PROVIDER=market
# Required when SANDBOX_PROVIDER=onlyboxes. Base URL of the Onlyboxes console API, without /api/v1.
# ONLYBOXES_BASE_URL=https://onlyboxes.example.com
# Required when SANDBOX_PROVIDER=onlyboxes. Must match Onlyboxes CONSOLE_JIT_SIGNING_KEY.
# ONLYBOXES_JIT_SIGNING_KEY=onlyboxes-jit-signing-secret
# Optional JIT token issuer. Defaults to APP_URL.
# ONLYBOXES_JIT_ISSUER=https://lobehub.example.com
# Optional JIT token TTL in seconds.
# ONLYBOXES_JIT_TTL_SEC=1800
# Optional terminal session lease in seconds for the Onlyboxes provider.
# ONLYBOXES_LEASE_TTL_SEC=900
# #######################################
# ########### Plugin Service ############
# #######################################
@@ -399,11 +376,6 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# Postgres database URL
# DATABASE_URL=postgres://username:password@host:port/database
# Optional: server-side timeout (in milliseconds) for a single SQL statement.
# When set, Postgres aborts any statement/idle transaction exceeding it, so a stuck
# query can't block indefinitely. Leave unset to keep Postgres' default of no timeout.
# DATABASE_STATEMENT_TIMEOUT=300000
# use `openssl rand -base64 32` to generate a key for the encryption of the database
# we use this key to encrypt the user api key and proxy url
# KEY_VAULTS_SECRET=xxxxx/xxxxxxxxxxxxxx=
-75
View File
@@ -1,75 +0,0 @@
name: Release CLI
permissions:
contents: write
on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
inputs:
tag:
description: 'Tag name for the release (e.g. v0.1.0)'
required: true
default: 'v0.0.0'
jobs:
build:
name: Build ${{ matrix.target }}
runs-on: ${{ matrix.os }}
# skip pre-release tags (containing '-') on auto-trigger; always run on workflow_dispatch
if: ${{ github.event_name == 'workflow_dispatch' || !contains(github.ref_name, '-') }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: lobe-linux-x64
- os: macos-latest
target: lobe-macos-arm64
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build binary
run: |
mkdir -p dist
bun build ./apps/cli/src/index.ts --compile --minify --outfile ./dist/${{ matrix.target }}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.target }}
path: ./dist/${{ matrix.target }}
release:
name: Upload to Release
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: ./dist
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
files: |
./dist/lobe-linux-x64/lobe-linux-x64
./dist/lobe-macos-arm64/lobe-macos-arm64
./apps/cli/install.sh
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
runs-on: ubuntu-latest
name: Test Packages
env:
PACKAGES: '@lobechat/file-loaders @lobechat/prompts @lobechat/model-runtime @lobechat/web-crawler @lobechat/electron-server-ipc @lobechat/utils @lobechat/python-interpreter @lobechat/context-engine @lobechat/agent-runtime @lobechat/conversation-flow @lobechat/ssrf-safe-fetch @lobechat/memory-user-memory @lobechat/types @lobechat/builtin-tool-lobe-agent model-bank @lobechat/agent-gateway-client @lobechat/agent-manager-runtime @lobechat/device-gateway-client @lobechat/device-identity @lobechat/eval-dataset-parser @lobechat/eval-rubric @lobechat/fetch-sse @lobechat/heterogeneous-agents'
PACKAGES: '@lobechat/file-loaders @lobechat/prompts @lobechat/model-runtime @lobechat/web-crawler @lobechat/electron-server-ipc @lobechat/utils @lobechat/python-interpreter @lobechat/context-engine @lobechat/agent-runtime @lobechat/conversation-flow @lobechat/ssrf-safe-fetch @lobechat/memory-user-memory @lobechat/types @lobechat/builtin-tool-lobe-agent model-bank'
steps:
- name: Checkout
+4 -13
View File
@@ -115,23 +115,14 @@ cd packages/database && bunx vitest run --silent='passed-only' '[file]'
```
- Prefer `vi.spyOn` over `vi.mock`
### Type Checking
```bash
bun run type-check
```
- Tests must pass type check: `bun run type-check`
- After 2 failed fix attempts, stop and ask for help
### i18n
- Add keys to a namespace file under `src/locales/default/` (e.g. `agent.ts`, `auth.ts`)
- Ship en-US and zh-CN by hand in the same PR: write the English source in `src/locales/default/*.ts` and mirror it to `locales/en-US/`; hand-translate `locales/zh-CN/`. Leave all other locales to CI.
- Don't run `pnpm i18n` manually by default — a daily CI workflow (`auto-i18n.yml`) runs it and opens an automated translation PR for any missing keys.
- Run `pnpm i18n` manually only when your branch needs the translated locales immediately, instead of waiting for the daily job (slow; requires `OPENAI_API_KEY`). Note it only fills keys missing from other locales — value-only edits never need it.
### Code Style
- When a single file grows beyond \~800 lines, consider splitting it into multiple files (extract sub-components, hooks, helpers, or types). Smaller, focused files are friendly to humans and agents.
- 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).
### Code Review
-29
View File
@@ -2,35 +2,6 @@
# Changelog
## [Version 2.2.1](https://github.com/lobehub/lobe-chat/compare/v0.0.0-nightly.pr15228.13999...v2.2.1)
<sup>Released on **2026-05-29**</sup>
#### ✨ Features
- **device**: device registry TRPC (register / list / update / remove).
- **bot**: add iMessage Desktop setup and bridge.
- **desktop**: show zoom level HUD on Cmd+/- and Cmd+0.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's improved
- **device**: device registry TRPC (register / list / update / remove), closes [#15299](https://github.com/lobehub/lobe-chat/issues/15299) ([671b252](https://github.com/lobehub/lobe-chat/commit/671b252))
- **bot**: add iMessage Desktop setup and bridge, closes [#15228](https://github.com/lobehub/lobe-chat/issues/15228) ([6d94635](https://github.com/lobehub/lobe-chat/commit/6d94635))
- **desktop**: show zoom level HUD on Cmd+/- and Cmd+0, closes [#15294](https://github.com/lobehub/lobe-chat/issues/15294) ([109545c](https://github.com/lobehub/lobe-chat/commit/109545c))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
### [Version 2.2.0](https://github.com/lobehub/lobe-chat/compare/v2.1.59-canary.27...v2.2.0)
<sup>Released on **2026-05-18**</sup>
-8
View File
@@ -210,14 +210,6 @@ ENV NEXT_PUBLIC_S3_DOMAIN="" \
S3_ENABLE_PATH_STYLE="" \
S3_SET_ACL=""
# Cloud Sandbox
ENV SANDBOX_PROVIDER="" \
ONLYBOXES_BASE_URL="" \
ONLYBOXES_JIT_ISSUER="" \
ONLYBOXES_JIT_SIGNING_KEY="" \
ONLYBOXES_JIT_TTL_SEC="" \
ONLYBOXES_LEASE_TTL_SEC=""
# Model Variables
ENV \
# AI21
-88
View File
@@ -1,88 +0,0 @@
import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
assertGoldenFinalState,
extractGoldenOutcomes,
} from './fixtures/agent-signal/assertGoldenFinalState';
/**
* E2E tests for `lh agent-signal trigger`.
*
* The "golden fixture" block runs fully offline — it is the structural
* regression baseline that the execAgent migration asserts
* against. The "live trigger" block requires a running server + authenticated
* CLI and is gated behind AGENT_SIGNAL_AGENT_ID (or AGENT_ID).
*
* Prerequisites for the live block:
* - `lh` (or LH_CLI_PATH) points at the built CLI
* - User is authenticated (`lh login`) against a dev server with Agent Signal enabled
* - AGENT_SIGNAL_AGENT_ID=<agentId> identifies a target agent the user owns
*/
const CLI = process.env.LH_CLI_PATH || 'lh';
const AGENT_ID = process.env.AGENT_SIGNAL_AGENT_ID || process.env.AGENT_ID;
const TIMEOUT = 60_000;
const goldenPath = fileURLToPath(
new URL('./fixtures/agent-signal/nightly-review.golden.json', import.meta.url),
);
const golden = JSON.parse(readFileSync(goldenPath, 'utf-8'));
function run(args: string): string {
return execSync(`${CLI} ${args}`, {
encoding: 'utf-8',
env: { ...process.env, PATH: `${process.env.HOME}/.bun/bin:${process.env.PATH}` },
timeout: TIMEOUT,
}).trim();
}
describe('agent-signal golden fixture - structural regression', () => {
it('captures a recognizable nightly-review source payload', () => {
expect(golden.source.sourceType).toBe('agent.nightly_review.requested');
expect(golden.source.payload.agentId).toBeTruthy();
expect(golden.source.payload.userId).toBeTruthy();
expect(golden.source.scopeKey).toContain('agent:');
});
it('extracts ideas / write outcomes / brief from finalState', () => {
const outcomes = extractGoldenOutcomes(golden.finalState);
expect(outcomes.ideas.length).toBeGreaterThanOrEqual(1);
expect(outcomes.writeOutcomes.length).toBeGreaterThanOrEqual(1);
expect(outcomes.brief).toBeDefined();
});
it('passes the shared structural assertion', () => {
expect(() => assertGoldenFinalState(golden.finalState)).not.toThrow();
});
it('rejects an empty finalState', () => {
expect(() => assertGoldenFinalState({ messages: [] })).toThrow(/artifact/i);
});
});
describe.skipIf(!AGENT_ID)('lh agent-signal trigger - live', () => {
it('triggers a nightly review and returns a workflow run id', () => {
const output = run(
`agent-signal trigger --source-type agent.nightly_review.requested --agent ${AGENT_ID} --json`,
);
const result = JSON.parse(output);
expect(result).toHaveProperty('accepted');
expect(result).toHaveProperty('scopeKey');
// When Agent Signal is enabled for the account, a workflow run id is returned.
if (result.accepted) {
expect(typeof result.workflowRunId).toBe('string');
expect(result.workflowRunId.length).toBeGreaterThan(0);
}
});
it('exits non-zero on an invalid source type', () => {
expect(() =>
run(`agent-signal trigger --source-type not.a.real.type --agent ${AGENT_ID}`),
).toThrow();
});
});
@@ -1,127 +0,0 @@
/**
* Standalone structural assertions for self-iteration finalState snapshots.
*
* Dependency-free on purpose: the execAgent migration PRs
* import this from server tests AND the CLI e2e suite, so it must not pull in
* vitest or any server-only module. Mirrors the `kind` discrimination used by
* `src/server/services/agentSignal/services/selfIteration/finalStateExtractor.ts`.
*/
export type ToolResultKind = 'artifact' | 'mutation' | 'read';
export interface ToolResultWithKind {
apiName?: string;
data: Record<string, unknown> | unknown;
kind: ToolResultKind;
toolCallId?: string;
}
export interface GoldenOutcomes {
/** The single brief mutation, if any (apiName matches /brief/i). */
brief?: ToolResultWithKind;
/** Artifact tool results whose apiName mentions an idea. */
ideas: ToolResultWithKind[];
/** Artifact tool results whose apiName mentions an intent. */
intents: ToolResultWithKind[];
/** Durable mutation tool results, excluding the brief. */
writeOutcomes: ToolResultWithKind[];
}
interface FinalStateLike {
messages?: unknown[];
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const parseContent = (content: unknown): unknown => {
if (typeof content !== 'string') return content;
try {
return JSON.parse(content);
} catch {
return content;
}
};
/** Extract every tool result of `kind` from a finalState, in message order. */
export const extractFromFinalState = (
finalState: FinalStateLike,
kind: ToolResultKind,
): ToolResultWithKind[] => {
const results: ToolResultWithKind[] = [];
for (const message of finalState.messages ?? []) {
if (!isRecord(message)) continue;
if (message.role !== 'tool') continue;
const content = parseContent(message.content);
const contentRecord = isRecord(content) ? content : undefined;
const pluginState = isRecord(message.pluginState) ? message.pluginState : undefined;
const resultKind = contentRecord?.kind ?? pluginState?.kind;
if (resultKind !== kind) continue;
results.push({
apiName: typeof message.apiName === 'string' ? message.apiName : undefined,
data: contentRecord ?? content,
kind,
toolCallId: typeof message.tool_call_id === 'string' ? message.tool_call_id : undefined,
});
}
return results;
};
const matchesApiName = (result: ToolResultWithKind, pattern: RegExp): boolean =>
typeof result.apiName === 'string' && pattern.test(result.apiName);
const briefText = (brief?: ToolResultWithKind): string => {
if (!brief || !isRecord(brief.data)) return '';
const summary = typeof brief.data.summary === 'string' ? brief.data.summary : '';
const body = typeof brief.data.body === 'string' ? brief.data.body : '';
return `${summary}${body}`.trim();
};
/** Partition a finalState into ideas / intents / writeOutcomes / brief buckets. */
export const extractGoldenOutcomes = (finalState: FinalStateLike): GoldenOutcomes => {
const artifacts = extractFromFinalState(finalState, 'artifact');
const mutations = extractFromFinalState(finalState, 'mutation');
const brief = mutations.find((m) => matchesApiName(m, /brief/i));
return {
brief,
ideas: artifacts.filter((a) => matchesApiName(a, /idea/i)),
intents: artifacts.filter((a) => matchesApiName(a, /intent/i)),
writeOutcomes: mutations.filter((m) => !matchesApiName(m, /brief/i)),
};
};
/**
* Structural regression assertion for a self-iteration finalState.
*
* Throws (with a descriptive message) when the run produced no structured
* output: it requires at least one artifact (idea or intent), at least one
* durable write outcome, and a non-empty brief. Never compares text verbatim.
*/
export const assertGoldenFinalState = (finalState: FinalStateLike): GoldenOutcomes => {
const outcomes = extractGoldenOutcomes(finalState);
const artifactCount = outcomes.ideas.length + outcomes.intents.length;
if (artifactCount < 1) {
throw new Error(`Expected >= 1 artifact (idea/intent) in finalState, found ${artifactCount}`);
}
if (outcomes.writeOutcomes.length < 1) {
throw new Error(
`Expected >= 1 write outcome (mutation) in finalState, found ${outcomes.writeOutcomes.length}`,
);
}
const text = briefText(outcomes.brief);
if (text.length === 0) {
throw new Error('Expected a non-empty brief in finalState, found none');
}
return outcomes;
};
@@ -1,61 +0,0 @@
{
"description": "Desensitized golden snapshot of one nightly-review self-iteration run. Used as a structural regression baseline by the execAgent migration which converges all agent execution paths (chat, self-iteration, memoryWriter, skillManagement) onto a single execAgent entry point. Assert structure, never byte-for-byte: the LLM output is non-deterministic.",
"finalState": {
"messages": [
{
"content": "Run the nightly self-review for the local window.",
"role": "user"
},
{
"apiName": "getEvidenceDigest",
"content": "{\"kind\":\"read\",\"topicCount\":3,\"messageCount\":42,\"window\":\"2026-05-30/2026-05-31\"}",
"role": "tool",
"tool_call_id": "call_read_1"
},
{
"apiName": "recordSelfReviewIdea",
"content": "{\"kind\":\"artifact\",\"idempotencyKey\":\"idea:pref:tone\",\"title\":\"Prefer concise replies\",\"rationale\":\"User repeatedly asked to shorten answers in topic tpc_demo\",\"risk\":\"low\"}",
"role": "tool",
"tool_call_id": "call_idea_1"
},
{
"apiName": "recordSelfReviewIdea",
"content": "{\"kind\":\"artifact\",\"idempotencyKey\":\"idea:skill:drizzle\",\"title\":\"Document Drizzle join helper\",\"rationale\":\"Recurring question about leftJoin usage\",\"risk\":\"medium\"}",
"role": "tool",
"tool_call_id": "call_idea_2"
},
{
"apiName": "writeMemory",
"content": "{\"kind\":\"mutation\",\"status\":\"applied\",\"resourceId\":\"mem_001\",\"summary\":\"Stored tone preference: prefer concise replies\"}",
"pluginState": { "kind": "mutation" },
"role": "tool",
"tool_call_id": "call_mut_1"
},
{
"apiName": "createSelfReviewBrief",
"content": "{\"kind\":\"mutation\",\"briefId\":\"brief_001\",\"summary\":\"Nightly review captured 2 ideas and wrote 1 memory.\",\"body\":\"## Highlights\\n- Prefer concise replies\\n- Document Drizzle join helper\"}",
"role": "tool",
"tool_call_id": "call_brief_1"
},
{
"content": "Nightly review complete. Captured 2 ideas and wrote 1 memory.",
"role": "assistant"
}
]
},
"source": {
"payload": {
"agentId": "agent_demo",
"localDate": "2026-05-30",
"requestedAt": "2026-05-31T04:00:00.000Z",
"reviewWindowEnd": "2026-05-31T04:00:00.000Z",
"reviewWindowStart": "2026-05-30T04:00:00.000Z",
"timezone": "UTC",
"userId": "user_demo"
},
"scopeKey": "agent:agent_demo:user:user_demo",
"sourceId": "nightly-review:user_demo:agent_demo:2026-05-30",
"sourceType": "agent.nightly_review.requested",
"timestamp": 1748664000000
}
}
-78
View File
@@ -1,78 +0,0 @@
#!/bin/sh
set -e
REPO="lobehub/lobe-chat"
BIN_NAME="lh"
# Detect OS
case "$(uname -s)" in
Linux) OS="linux" ;;
Darwin) OS="macos" ;;
*)
printf 'Error: Unsupported OS: %s\n' "$(uname -s)" >&2
exit 1
;;
esac
# Detect architecture
case "$(uname -m)" in
x86_64) ARCH="x64" ;;
aarch64|arm64) ARCH="arm64" ;;
*)
printf 'Error: Unsupported architecture: %s\n' "$(uname -m)" >&2
exit 1
;;
esac
BINARY="lobe-${OS}-${ARCH}"
URL="https://github.com/${REPO}/releases/latest/download/${BINARY}"
printf 'Detected: %s/%s\n' "$OS" "$ARCH"
printf 'Downloading %s...\n' "$BINARY"
TMP="$(mktemp)"
trap 'rm -f "$TMP"' EXIT
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$URL" -o "$TMP"
elif command -v wget >/dev/null 2>&1; then
wget -qO "$TMP" "$URL"
else
printf 'Error: curl or wget is required\n' >&2
exit 1
fi
chmod +x "$TMP"
# Choose install directory: prefer /usr/local/bin, fall back to ~/.local/bin
USE_SUDO=0
if [ -w "/usr/local/bin" ]; then
INSTALL_DIR="/usr/local/bin"
elif command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then
INSTALL_DIR="/usr/local/bin"
USE_SUDO=1
else
INSTALL_DIR="${HOME}/.local/bin"
mkdir -p "$INSTALL_DIR"
printf 'Note: No sudo access. Installing to %s\n' "$INSTALL_DIR"
printf 'Add the following to your shell profile if needed:\n'
printf ' export PATH="%s:$PATH"\n' "$INSTALL_DIR"
fi
# Install binary and create symlinks
if [ "$USE_SUDO" = "1" ]; then
sudo cp "$TMP" "${INSTALL_DIR}/${BIN_NAME}"
sudo chmod +x "${INSTALL_DIR}/${BIN_NAME}"
sudo ln -sf "${INSTALL_DIR}/${BIN_NAME}" "${INSTALL_DIR}/lobe"
sudo ln -sf "${INSTALL_DIR}/${BIN_NAME}" "${INSTALL_DIR}/lobehub"
else
cp "$TMP" "${INSTALL_DIR}/${BIN_NAME}"
chmod +x "${INSTALL_DIR}/${BIN_NAME}"
ln -sf "${INSTALL_DIR}/${BIN_NAME}" "${INSTALL_DIR}/lobe"
ln -sf "${INSTALL_DIR}/${BIN_NAME}" "${INSTALL_DIR}/lobehub"
fi
printf '\nInstalled successfully!\n'
printf ' Binary: %s/%s\n' "$INSTALL_DIR" "$BIN_NAME"
printf ' Symlinks: lobe, lobehub -> lh\n\n'
"${INSTALL_DIR}/${BIN_NAME}" --version
+1 -4
View File
@@ -1,6 +1,6 @@
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
.\" Manual command details come from the Commander command tree.
.TH LH 1 "" "@lobehub/cli 0.0.24" "User Commands"
.TH LH 1 "" "@lobehub/cli 0.0.22" "User Commands"
.SH NAME
lh \- LobeHub CLI \- manage and connect to LobeHub services
.SH SYNOPSIS
@@ -65,9 +65,6 @@ Manage agents
.B agent\-group
Manage agent groups
.TP
.B agent\-signal
Inspect and trigger Agent Signal source events
.TP
.B bot
Manage bot integrations
.TP
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@lobehub/cli",
"version": "0.0.24",
"version": "0.0.22",
"type": "module",
"bin": {
"lh": "./dist/index.js",
@@ -33,7 +33,6 @@
"@lobechat/device-identity": "workspace:*",
"@lobechat/heterogeneous-agents": "workspace:*",
"@lobechat/local-file-shell": "workspace:*",
"@lobechat/tool-runtime": "workspace:*",
"@trpc/client": "^11.8.1",
"@types/node": "^22.13.5",
"@types/ws": "^8.18.1",
-3
View File
@@ -4,9 +4,6 @@ packages:
- '../../packages/device-identity'
- '../../packages/heterogeneous-agents'
- '../../packages/local-file-shell'
- '../../packages/tool-runtime'
- '../../packages/prompts'
- '../../packages/const'
- '../../packages/types'
- '../../packages/model-bank'
- '../../packages/business/const'
+2 -4
View File
@@ -13,7 +13,7 @@ interface CurrentUserResponse {
export async function getUserIdFromApiKey(apiKey: string, serverUrl?: string): Promise<string> {
const normalizedServerUrl = normalizeUrl(serverUrl) || resolveServerUrl();
const response = await fetch(`${normalizedServerUrl}/api/v1/users/me?includeCount=0`, {
const response = await fetch(`${normalizedServerUrl}/api/v1/users/me`, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
@@ -23,9 +23,7 @@ export async function getUserIdFromApiKey(apiKey: string, serverUrl?: string): P
try {
body = (await response.json()) as CurrentUserResponse;
} catch {
throw new Error(
`Failed to parse response from ${normalizedServerUrl}/api/v1/users/me?includeCount=0.`,
);
throw new Error(`Failed to parse response from ${normalizedServerUrl}/api/v1/users/me.`);
}
if (!response.ok || body?.success === false) {
+1 -1
View File
@@ -20,7 +20,7 @@ interface ResolvedAuth {
/**
* Parse the `sub` claim from a JWT without verifying the signature.
*/
export function parseJwtSub(token: string): string | undefined {
function parseJwtSub(token: string): string | undefined {
try {
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString());
return payload.sub;
-129
View File
@@ -1,129 +0,0 @@
import type { Command } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../../api/client';
import { log } from '../../utils/logger';
/**
* Producer source types a developer may trigger manually for local testing.
* Mirrors `AGENT_SIGNAL_TRIGGER_SOURCE_TYPES` on the server; kept inline so the
* CLI bundle does not pull in server-only modules.
*/
const TRIGGER_SOURCE_TYPES = [
'agent.nightly_review.requested',
'agent.self_reflection.requested',
'agent.self_feedback_intent.declared',
'agent.user.message',
'tool.outcome.completed',
'tool.outcome.failed',
] as const;
type TriggerSourceType = (typeof TRIGGER_SOURCE_TYPES)[number];
export function registerAgentSignalCommand(program: Command) {
const agentSignal = program
.command('agent-signal')
.description('Inspect and trigger Agent Signal source events');
agentSignal
.command('trigger')
.description('Trigger an Agent Signal source event for the authenticated user')
.requiredOption(
'--source-type <type>',
`Source type to emit. One of:\n ${TRIGGER_SOURCE_TYPES.join('\n ')}`,
)
.option('--agent <agentId>', 'Target agent ID (required for agent-scoped source types)')
.option('--topic <topicId>', 'Topic ID to scope the event to')
.option('--payload-json <json>', 'JSON object shallow-merged over the default payload')
.option('--source-id <id>', 'Override the auto-derived dedupe source id')
.option('--scope-key <key>', 'Override the auto-derived scope key')
.option('--timestamp <ms>', 'Event timestamp in milliseconds')
.option('--json', 'Output JSON')
.action(
async (options: {
agent?: string;
json?: boolean;
payloadJson?: string;
scopeKey?: string;
sourceId?: string;
sourceType: string;
timestamp?: string;
topic?: string;
}) => {
const sourceType = options.sourceType as TriggerSourceType;
if (!TRIGGER_SOURCE_TYPES.includes(sourceType)) {
console.error(
`${pc.red('✗')} Invalid --source-type "${options.sourceType}". Expected one of: ${TRIGGER_SOURCE_TYPES.join(', ')}`,
);
process.exit(1);
return;
}
let payloadOverride: Record<string, unknown> | undefined;
if (options.payloadJson) {
try {
const parsed = JSON.parse(options.payloadJson);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('payload must be a JSON object');
}
payloadOverride = parsed as Record<string, unknown>;
} catch (error: any) {
console.error(`${pc.red('✗')} Failed to parse --payload-json: ${error.message}`);
process.exit(1);
return;
}
}
let timestamp: number | undefined;
if (options.timestamp !== undefined) {
timestamp = Number(options.timestamp);
if (!Number.isFinite(timestamp)) {
console.error(`${pc.red('✗')} --timestamp must be a number (milliseconds)`);
process.exit(1);
return;
}
}
log.debug(
'agent-signal trigger: sourceType=%s agent=%s topic=%s',
sourceType,
options.agent,
options.topic,
);
const client = await getTrpcClient();
try {
const result = await client.agentSignal.triggerSourceEvent.mutate({
agentId: options.agent,
payloadOverride,
scopeKey: options.scopeKey,
sourceId: options.sourceId,
sourceType,
timestamp,
topicId: options.topic,
});
if (options.json) {
console.log(JSON.stringify(result, null, 2));
return;
}
if (!result.accepted) {
console.log(
`${pc.yellow('!')} Agent Signal is disabled for this account — event was not enqueued (scopeKey: ${pc.bold(result.scopeKey)})`,
);
return;
}
console.log(`${pc.green('✓')} Triggered ${pc.bold(sourceType)}`);
console.log(` Scope key: ${result.scopeKey}`);
console.log(` Workflow run id: ${result.workflowRunId}`);
} catch (error: any) {
console.error(`${pc.red('✗')} Failed to trigger source event: ${error.message}`);
process.exit(1);
}
},
);
}
+16 -80
View File
@@ -347,33 +347,22 @@ export function registerAgentCommand(program: Command) {
const { serverUrl, headers, token, tokenType } = await getAgentStreamAuthInfo();
const agentGatewayUrl = options.sse ? undefined : resolveAgentGatewayUrl();
try {
if (agentGatewayUrl) {
await streamAgentEventsViaWebSocket({
gatewayUrl: agentGatewayUrl,
json: options.json,
operationId,
serverUrl,
token,
tokenType,
verbose: options.verbose,
});
} else {
const streamUrl = `${serverUrl}/api/agent/stream?operationId=${encodeURIComponent(operationId)}`;
await streamAgentEvents(streamUrl, headers, {
json: options.json,
verbose: options.verbose,
});
}
} catch (error) {
// The live stream (gateway WS / SSE) dropped before the run finished —
// the run is still executing server-side. Instead of failing, fall back
// to polling the run status until it reaches a terminal state.
if (options.json) throw error;
log.warn(
`Live stream unavailable (${(error as Error).message}). Polling run status every 10s…`,
);
await pollAgentRunStatus(client, operationId);
if (agentGatewayUrl) {
await streamAgentEventsViaWebSocket({
gatewayUrl: agentGatewayUrl,
json: options.json,
operationId,
serverUrl,
token,
tokenType,
verbose: options.verbose,
});
} else {
const streamUrl = `${serverUrl}/api/agent/stream?operationId=${encodeURIComponent(operationId)}`;
await streamAgentEvents(streamUrl, headers, {
json: options.json,
verbose: options.verbose,
});
}
},
);
@@ -637,56 +626,3 @@ function colorStatus(status: string): string {
}
}
}
const TERMINAL_RUN_STATUSES = new Set([
'completed',
'done',
'success',
'failed',
'error',
'cancelled',
'canceled',
'aborted',
]);
/**
* Fallback when the live stream (gateway WebSocket / SSE) drops before the run
* finishes: the run is still executing server-side, so poll its status every 10s
* until it reaches a terminal state (or is no longer tracked, which also means it
* has finished). Avoids hard-exiting on a transient gateway disconnect.
*/
async function pollAgentRunStatus(
client: Awaited<ReturnType<typeof getTrpcClient>>,
operationId: string,
): Promise<void> {
const POLL_MS = 10_000;
let lastStatus = '';
for (let i = 0; ; i++) {
if (i > 0) await new Promise((resolve) => setTimeout(resolve, POLL_MS));
let r: any;
try {
r = await client.aiAgent.getOperationStatus.query({ operationId } as any);
} catch (error) {
log.error(`Status poll failed: ${(error as Error).message}`);
process.exit(1);
}
if (!r) {
log.info('Run is no longer tracked — finished (or expired).');
return;
}
const status = r.status || r.state || 'unknown';
if (status !== lastStatus) {
lastStatus = status;
const steps = r.stepCount !== undefined ? ` · ${r.stepCount} step(s)` : '';
log.info(`Run status: ${colorStatus(status)}${steps}`);
}
if (TERMINAL_RUN_STATUSES.has(status)) {
if (r.error) log.error(`Run error: ${r.error}`);
return;
}
}
}
+20 -10
View File
@@ -8,8 +8,11 @@ import type {
ToolCallRequestMessage,
} from '@lobechat/device-gateway-client';
import { GatewayClient } from '@lobechat/device-gateway-client';
import type { IdentitySource } from '@lobechat/device-identity';
import { deriveDeviceId } from '@lobechat/device-identity';
import type { Command } from 'commander';
import { createLambdaClient } from '../api/client';
import { getValidToken } from '../auth/refresh';
import { resolveToken } from '../auth/resolveToken';
import { CLI_API_KEY_ENV } from '../constants/auth';
@@ -25,7 +28,6 @@ import {
stopDaemon,
writeStatus,
} from '../daemon/manager';
import { registerDevice, resolveDeviceIdentity } from '../device/register';
import { loadOrCreateConnectionId, loadSettings, normalizeUrl, saveSettings } from '../settings';
import { executeToolCall } from '../tools';
import { cleanupAllProcesses } from '../tools/shell';
@@ -196,7 +198,12 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
// Resolve a stable device identity. An explicit `--device-id` wins (lets a
// user pin a VM to a fixed identity); otherwise derive from the machine id so
// the same machine + user maps to one device across reconnects.
const identity = resolveDeviceIdentity(auth.userId, options.deviceId);
const identity: { deviceId: string; identitySource: IdentitySource } | undefined =
options.deviceId
? { deviceId: options.deviceId, identitySource: 'fallback' }
: auth.userId
? deriveDeviceId(auth.userId)
: undefined;
// Freeform channel label (`cli` by default); `LOBEHUB_CLI_CHANNEL` lets a
// dev build tag itself `cli-dev` so the gateway can prioritise / display it.
@@ -280,7 +287,6 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
result: {
content: result.content,
error: result.error,
state: result.state,
success: result.success,
},
});
@@ -400,15 +406,19 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
});
// Register this device in the server registry before opening the WS, so the
// row exists by the time the gateway reports it online. `lh login` already
// registers, but re-running here is cheap (idempotent upsert) and covers
// `--token` sessions that never went through login. Best-effort: a failure
// must not block the connection.
// row exists by the time the gateway reports it online. Best-effort: a
// failure must not block the connection.
if (identity) {
try {
// Reuse the already-resolved auth (respects `--token` mode) so we don't
// re-discover creds and exit when none are found.
await registerDevice(auth, identity);
// Reuse the already-resolved auth (respects `--token` mode) instead of
// getTrpcClient(), which re-discovers creds and exits when none are found.
const trpc = createLambdaClient(auth);
await trpc.device.register.mutate({
deviceId: identity.deviceId,
hostname: os.hostname(),
identitySource: identity.identitySource,
platform: process.platform,
});
} catch (err) {
error(`Device registration failed (non-fatal): ${(err as Error).message}`);
}
-26
View File
@@ -6,10 +6,8 @@ import type { Command } from 'commander';
import { getUserIdFromApiKey } from '../auth/apiKey';
import { saveCredentials } from '../auth/credentials';
import { parseJwtSub } from '../auth/resolveToken';
import { CLI_API_KEY_ENV } from '../constants/auth';
import { OFFICIAL_SERVER_URL } from '../constants/urls';
import { registerDevice, resolveDeviceIdentity } from '../device/register';
import { loadSettings, normalizeUrl, saveSettings } from '../settings';
import { log } from '../utils/logger';
@@ -215,30 +213,6 @@ export function registerLoginCommand(program: Command) {
},
);
// Register this device in the server registry right after auth, so
// the device row exists without waiting for a later `lh connect`
// (which only adds the channel-online step). Mirrors the desktop
// app, which registers on login. Best-effort: a failure here must
// not fail the login.
//
// Skip the `fallback` source: `lh login` has no `--device-id` and
// persists no fallback id, so a machine without a readable
// machine-id would derive a *fresh random* id on every login —
// registering it just spawns orphan device rows that never match
// the id a later `lh connect` resolves. Defer registration to
// `connect` in that case, where the same id is reused for the WS.
const identity = resolveDeviceIdentity(parseJwtSub(body.access_token));
if (identity && identity.identitySource !== 'fallback') {
try {
await registerDevice(
{ serverUrl, token: body.access_token, tokenType: 'jwt' },
identity,
);
} catch (err) {
log.warn(`Device registration failed (non-fatal): ${(err as Error).message}`);
}
}
log.info('Login successful! Credentials saved.');
return;
}
-90
View File
@@ -1,90 +0,0 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { registerVerifyCommand } from './verify';
const { mockTrpcClient } = vi.hoisted(() => ({
mockTrpcClient: {
verify: {
createRubric: { mutate: vi.fn() },
getRubric: { query: vi.fn() },
updateRubric: { mutate: vi.fn() },
},
},
}));
const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({
getTrpcClient: vi.fn(),
}));
vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient }));
vi.mock('../utils/logger', () => ({
log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() },
setVerbose: vi.fn(),
}));
describe('verify rubric config commands', () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
mockTrpcClient.verify.createRubric.mutate.mockReset().mockResolvedValue({ id: 'rub-1' });
mockTrpcClient.verify.updateRubric.mutate.mockReset().mockResolvedValue(undefined);
mockTrpcClient.verify.getRubric.query.mockReset();
});
afterEach(() => consoleSpy.mockRestore());
const run = async (args: string[]) => {
const program = new Command();
program.exitOverride();
registerVerifyCommand(program);
await program.parseAsync(['node', 'lh', 'verify', ...args]);
};
it('passes maxRepairRounds config when creating a rubric', async () => {
await run(['rubric', 'create', '-t', 'Standard', '--max-repair-rounds', '3']);
expect(mockTrpcClient.verify.createRubric.mutate).toHaveBeenCalledWith({
config: { maxRepairRounds: 3 },
description: undefined,
title: 'Standard',
});
});
it('omits config when no max-repair-rounds flag is given', async () => {
await run(['rubric', 'create', '-t', 'Standard']);
expect(mockTrpcClient.verify.createRubric.mutate).toHaveBeenCalledWith({
config: undefined,
description: undefined,
title: 'Standard',
});
});
it('updates only the config when max-repair-rounds is passed', async () => {
await run(['rubric', 'update', 'rub-1', '--max-repair-rounds', '0']);
expect(mockTrpcClient.verify.updateRubric.mutate).toHaveBeenCalledWith({
id: 'rub-1',
value: { config: { maxRepairRounds: 0 } },
});
});
it('views a rubric and prints its repair-round config', async () => {
mockTrpcClient.verify.getRubric.query.mockResolvedValue({
config: { maxRepairRounds: 4 },
description: 'desc',
id: 'rub-1',
title: 'Standard',
});
await run(['rubric', 'view', 'rub-1']);
expect(mockTrpcClient.verify.getRubric.query).toHaveBeenCalledWith({ id: 'rub-1' });
const printed = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n');
expect(printed).toContain('Standard');
expect(printed).toContain('4');
});
});
-455
View File
@@ -1,455 +0,0 @@
import type { Command } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../api/client';
import { confirm, outputJson, printTable, timeAgo, truncate } from '../utils/format';
import { log } from '../utils/logger';
// ── Helpers ────────────────────────────────────────────────
type VerifierType = 'agent' | 'llm' | 'program';
type OnFail = 'auto_repair' | 'manual';
type Decision = 'accepted' | 'overridden' | 'rejected';
const VERIFIER_TYPES: VerifierType[] = ['program', 'agent', 'llm'];
const ON_FAIL: OnFail[] = ['manual', 'auto_repair'];
const DECISIONS: Decision[] = ['accepted', 'rejected', 'overridden'];
function parseConfig(raw?: string): Record<string, unknown> | undefined {
if (!raw) return undefined;
try {
return JSON.parse(raw);
} catch {
log.error('--config must be valid JSON');
process.exit(1);
}
}
function assertEnum<T extends string>(value: T | undefined, allowed: T[], flag: string): void {
if (value !== undefined && !allowed.includes(value)) {
log.error(`${flag} must be one of: ${allowed.join(', ')}`);
process.exit(1);
}
}
// ── Command Registration ───────────────────────────────────
export function registerVerifyCommand(program: Command) {
const verify = program
.command('verify')
.description('Manage the Agent Run delivery checker (criteria, rubrics, plans, results)');
// ════════════ criteria ════════════
const criterion = verify.command('criterion').description('Reusable pass/fail standards');
criterion
.command('list')
.description('List criteria')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(async (options: { json?: boolean | string }) => {
const client = await getTrpcClient();
const items = await client.verify.listCriteria.query();
if (options.json !== undefined) {
outputJson(items, typeof options.json === 'string' ? options.json : undefined);
return;
}
if (items.length === 0) return void console.log('No criteria found.');
printTable(
items.map((c) => [
c.id,
truncate(c.title, 60),
c.verifierType,
c.required ? 'gate' : 'soft',
c.onFail,
c.updatedAt ? timeAgo(c.updatedAt) : '',
]),
['ID', 'TITLE', 'TYPE', 'BLOCK', 'ON-FAIL', 'UPDATED'],
);
});
criterion
.command('create')
.description('Create a criterion')
.requiredOption('-t, --title <title>', 'Criterion title')
.requiredOption('--type <type>', `Verifier type (${VERIFIER_TYPES.join('|')})`)
.option('--on-fail <strategy>', `Action on failure (${ON_FAIL.join('|')})`)
.option('--soft', 'Non-blocking (required=false); defaults to blocking')
.option('--config <json>', 'Verifier config as JSON')
.option('--doc <id>', 'Linked guidance document id')
.action(
async (options: {
config?: string;
doc?: string;
onFail?: OnFail;
soft?: boolean;
title: string;
type: VerifierType;
}) => {
assertEnum(options.type, VERIFIER_TYPES, '--type');
assertEnum(options.onFail, ON_FAIL, '--on-fail');
const client = await getTrpcClient();
const result = await client.verify.createCriterion.mutate({
documentId: options.doc,
onFail: options.onFail,
required: options.soft ? false : undefined,
title: options.title,
verifierConfig: parseConfig(options.config),
verifierType: options.type,
});
console.log(`${pc.green('✓')} Created criterion ${pc.bold((result as any).id)}`);
},
);
criterion
.command('delete <id>')
.description('Delete a criterion')
.option('--yes', 'Skip confirmation')
.action(async (id: string, options: { yes?: boolean }) => {
if (!options.yes && !(await confirm(`Delete criterion ${id}?`)))
return void console.log('Cancelled.');
const client = await getTrpcClient();
await client.verify.deleteCriterion.mutate({ id });
console.log(`${pc.green('✓')} Deleted criterion ${pc.bold(id)}`);
});
// ════════════ rubrics ════════════
const rubric = verify.command('rubric').description('Named groups of criteria');
rubric
.command('list')
.description('List rubrics')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(async (options: { json?: boolean | string }) => {
const client = await getTrpcClient();
const items = await client.verify.listRubrics.query();
if (options.json !== undefined) {
outputJson(items, typeof options.json === 'string' ? options.json : undefined);
return;
}
if (items.length === 0) return void console.log('No rubrics found.');
printTable(
items.map((r) => [
r.id,
truncate(r.title, 60),
truncate(r.description || '', 60),
r.updatedAt ? timeAgo(r.updatedAt) : '',
]),
['ID', 'TITLE', 'DESCRIPTION', 'UPDATED'],
);
});
rubric
.command('create')
.description('Create a rubric')
.requiredOption('-t, --title <title>', 'Rubric title')
.option('-d, --description <text>', 'Rubric description')
.option('--max-repair-rounds <n>', 'Cap on automatic repair rounds (0-5)')
.action(async (options: { description?: string; maxRepairRounds?: string; title: string }) => {
const client = await getTrpcClient();
const result = await client.verify.createRubric.mutate({
config:
options.maxRepairRounds !== undefined
? { maxRepairRounds: Number(options.maxRepairRounds) }
: undefined,
description: options.description,
title: options.title,
});
console.log(`${pc.green('✓')} Created rubric ${pc.bold((result as any).id)}`);
});
rubric
.command('view <id>')
.description('Show a rubric and its run-policy config')
.option('--json [fields]', 'Output JSON')
.action(async (id: string, options: { json?: boolean | string }) => {
const client = await getTrpcClient();
const item = await client.verify.getRubric.query({ id });
if (!item) return void log.error('Rubric not found.');
if (options.json !== undefined) {
outputJson(item, typeof options.json === 'string' ? options.json : undefined);
return;
}
console.log(`${pc.bold('ID')} ${item.id}`);
console.log(`${pc.bold('Title')} ${item.title}`);
if (item.description) console.log(`${pc.bold('Description')} ${item.description}`);
const maxRepairRounds = (item.config as { maxRepairRounds?: number } | null)?.maxRepairRounds;
console.log(`${pc.bold('Repair rounds')} ${maxRepairRounds ?? pc.dim('default')}`);
});
rubric
.command('update <id>')
.description('Update a rubric (title / description / run-policy config)')
.option('-t, --title <title>', 'New title')
.option('-d, --description <text>', 'New description')
.option('--max-repair-rounds <n>', 'Cap on automatic repair rounds (0-5)')
.action(
async (
id: string,
options: { description?: string; maxRepairRounds?: string; title?: string },
) => {
const client = await getTrpcClient();
const value: {
config?: { maxRepairRounds?: number };
description?: string;
title?: string;
} = {};
if (options.title !== undefined) value.title = options.title;
if (options.description !== undefined) value.description = options.description;
if (options.maxRepairRounds !== undefined)
value.config = { maxRepairRounds: Number(options.maxRepairRounds) };
await client.verify.updateRubric.mutate({ id, value });
console.log(`${pc.green('✓')} Updated rubric ${pc.bold(id)}`);
},
);
rubric
.command('delete <id>')
.description('Delete a rubric')
.option('--yes', 'Skip confirmation')
.action(async (id: string, options: { yes?: boolean }) => {
if (!options.yes && !(await confirm(`Delete rubric ${id}?`)))
return void console.log('Cancelled.');
const client = await getTrpcClient();
await client.verify.deleteRubric.mutate({ id });
console.log(`${pc.green('✓')} Deleted rubric ${pc.bold(id)}`);
});
rubric
.command('criteria <rubricId>')
.description('List criteria in a rubric')
.option('--json [fields]', 'Output JSON')
.action(async (rubricId: string, options: { json?: boolean | string }) => {
const client = await getTrpcClient();
const items = await client.verify.getRubricCriteria.query({ rubricId });
if (options.json !== undefined) {
outputJson(items, typeof options.json === 'string' ? options.json : undefined);
return;
}
if (items.length === 0) return void console.log('No criteria in this rubric.');
printTable(
items.map((c: any) => [
c.id,
truncate(c.title, 60),
c.verifierType,
c.required ? 'gate' : 'soft',
]),
['ID', 'TITLE', 'TYPE', 'BLOCK'],
);
});
rubric
.command('set-criteria <rubricId> <criterionIds...>')
.description('Set the criteria a rubric aggregates (order preserved)')
.action(async (rubricId: string, criterionIds: string[]) => {
const client = await getTrpcClient();
await client.verify.setRubricCriteria.mutate({
criteria: criterionIds.map((criterionId, i) => ({ criterionId, sortOrder: i })),
rubricId,
});
console.log(
`${pc.green('✓')} Rubric ${pc.bold(rubricId)} now has ${criterionIds.length} criterion(s)`,
);
});
// ════════════ per-run plan ════════════
const plan = verify.command('plan').description('Per-run check plan lifecycle');
plan
.command('generate <operationId>')
.description('Generate a draft check plan for a run')
.requiredOption('--goal <goal>', "The run's task/instruction the plan must satisfy")
.option('--rubric <id>', 'Mounted rubric id')
.option('--criteria <ids>', 'Ad-hoc criterion ids (comma-separated)')
.option('--ai', 'Let the LLM propose additional criteria')
.option('--max-ai <n>', 'Max AI-proposed criteria')
.option('--model <model>', 'Model (required with --ai)')
.option('--provider <provider>', 'Provider (required with --ai)')
.option('--context <text>', 'Extra context for the AI prompt')
.option('--json [fields]', 'Output JSON')
.action(
async (
operationId: string,
options: {
ai?: boolean;
context?: string;
criteria?: string;
goal: string;
json?: boolean | string;
maxAi?: string;
model?: string;
provider?: string;
rubric?: string;
},
) => {
if (options.ai && (!options.model || !options.provider)) {
log.error('--ai requires --model and --provider');
process.exit(1);
}
const client = await getTrpcClient();
const items = await client.verify.generateDraftPlan.mutate({
context: options.context,
enableAiGeneration: options.ai,
goal: options.goal,
maxAiCriteria: options.maxAi ? Number.parseInt(options.maxAi, 10) : undefined,
modelConfig:
options.model && options.provider
? { model: options.model, provider: options.provider }
: undefined,
operationId,
verifyCriteriaIds: options.criteria
?.split(',')
.map((s) => s.trim())
.filter(Boolean),
verifyRubricId: options.rubric ?? null,
});
if (options.json !== undefined) {
outputJson(items, typeof options.json === 'string' ? options.json : undefined);
return;
}
console.log(`${pc.green('✓')} Draft plan: ${pc.bold(String(items.length))} item(s)`);
printTable(
items.map((i: any) => [
String(i.index),
truncate(i.title, 60),
i.verifierType,
i.required ? 'gate' : 'soft',
]),
['#', 'TITLE', 'TYPE', 'BLOCK'],
);
},
);
plan
.command('state <operationId>')
.description('Show the verify state (status + frozen plan) of a run')
.option('--json [fields]', 'Output JSON')
.action(async (operationId: string, options: { json?: boolean | string }) => {
const client = await getTrpcClient();
const state = await client.verify.getVerifyState.query({ operationId });
if (options.json !== undefined) {
outputJson(state, typeof options.json === 'string' ? options.json : undefined);
return;
}
if (!state) return void console.log('No verify state for this run.');
console.log(`${pc.bold('status')}: ${state.verifyStatus ?? pc.dim('(none)')}`);
console.log(
`${pc.bold('confirmed')}: ${state.verifyPlanConfirmedAt ? timeAgo(state.verifyPlanConfirmedAt) : pc.dim('no')}`,
);
const items = (state.verifyPlan ?? []) as any[];
console.log(`${pc.bold('plan')}: ${items.length} item(s)`);
if (items.length > 0)
printTable(
items.map((i) => [
String(i.index),
truncate(i.title, 60),
i.verifierType,
i.required ? 'gate' : 'soft',
]),
['#', 'TITLE', 'TYPE', 'BLOCK'],
);
});
plan
.command('confirm <operationId>')
.description('Freeze (confirm) the draft plan')
.action(async (operationId: string) => {
const client = await getTrpcClient();
await client.verify.confirmPlan.mutate({ operationId });
console.log(`${pc.green('✓')} Confirmed plan for run ${pc.bold(operationId)}`);
});
plan
.command('skip <operationId>')
.description('Skip verification for a run')
.action(async (operationId: string) => {
const client = await getTrpcClient();
await client.verify.skipPlan.mutate({ operationId });
console.log(`${pc.green('✓')} Skipped verification for run ${pc.bold(operationId)}`);
});
// ════════════ run / results ════════════
verify
.command('run <operationId>')
.description('Execute the confirmed plan against a deliverable (LLM judge)')
.requiredOption('--goal <goal>', "The run's task")
.requiredOption('--deliverable <text>', 'The output to judge')
.requiredOption('--model <model>', 'Judge model')
.requiredOption('--provider <provider>', 'Judge provider')
.option('--no-batch', 'Judge each item separately instead of one batched call')
.option('--json [fields]', 'Output JSON')
.action(
async (
operationId: string,
options: {
batch?: boolean;
deliverable: string;
goal: string;
json?: boolean | string;
model: string;
provider: string;
},
) => {
const client = await getTrpcClient();
const results = await client.verify.executeVerify.mutate({
batchLlm: options.batch,
deliverable: options.deliverable,
goal: options.goal,
modelConfig: { model: options.model, provider: options.provider },
operationId,
});
if (options.json !== undefined) {
outputJson(results, typeof options.json === 'string' ? options.json : undefined);
return;
}
printResults(results);
},
);
verify
.command('results <operationId>')
.description('List check results for a run')
.option('--json [fields]', 'Output JSON')
.action(async (operationId: string, options: { json?: boolean | string }) => {
const client = await getTrpcClient();
const results = await client.verify.listResults.query({ operationId });
if (options.json !== undefined) {
outputJson(results, typeof options.json === 'string' ? options.json : undefined);
return;
}
if (results.length === 0) return void console.log('No results yet.');
printResults(results);
});
// ════════════ feedback ════════════
verify
.command('decision <resultId> <decision>')
.description(`Record human feedback on a result (${DECISIONS.join('|')})`)
.action(async (resultId: string, decision: Decision) => {
assertEnum(decision, DECISIONS, 'decision');
const client = await getTrpcClient();
await client.verify.submitDecision.mutate({ decision, resultId });
console.log(`${pc.green('✓')} Recorded ${pc.bold(decision)} on result ${pc.bold(resultId)}`);
});
}
function printResults(results: any[]): void {
printTable(
results.map((r) => [
truncate(r.checkItemTitle || r.checkItemId, 50),
statusColor(r.status),
r.verdict ?? '',
r.confidence != null ? String(r.confidence) : '',
r.required ? 'gate' : 'soft',
truncate(r.suggestion || '', 40),
]),
['CHECK', 'STATUS', 'VERDICT', 'CONF', 'BLOCK', 'SUGGESTION'],
);
}
function statusColor(status: string): string {
if (status === 'passed') return pc.green(status);
if (status === 'failed') return pc.red(status);
if (status === 'running') return pc.yellow(status);
return pc.dim(status);
}
-40
View File
@@ -1,40 +0,0 @@
import os from 'node:os';
import type { DeviceIdentity } from '@lobechat/device-identity';
import { deriveDeviceId } from '@lobechat/device-identity';
import { createLambdaClient } from '../api/client';
/**
* Resolve a stable device identity. An explicit `--device-id` wins (lets a user
* pin a VM to a fixed identity); otherwise derive from the machine id so the
* same machine + user maps to one device across reconnects. Returns undefined
* when neither an explicit id nor a userId is available.
*/
export function resolveDeviceIdentity(
userId: string | undefined,
explicitDeviceId?: string,
): DeviceIdentity | undefined {
if (explicitDeviceId) return { deviceId: explicitDeviceId, identitySource: 'fallback' };
if (userId) return deriveDeviceId(userId);
return undefined;
}
/**
* Register this device in the server registry. Shared by `lh login` (so the
* device row exists right after auth) and `lh connect` (so the row exists
* before the WS opens). Best-effort by contract: callers should wrap this in a
* try/catch and treat any failure as non-fatal.
*/
export async function registerDevice(
auth: { serverUrl: string; token: string; tokenType: 'apiKey' | 'jwt' | 'serviceToken' },
identity: DeviceIdentity,
): Promise<void> {
const trpc = createLambdaClient(auth);
await trpc.device.register.mutate({
deviceId: identity.deviceId,
hostname: os.hostname(),
identitySource: identity.identitySource,
platform: process.platform,
});
}
-4
View File
@@ -4,7 +4,6 @@ import { Command } from 'commander';
import { registerAgentCommand } from './commands/agent';
import { registerAgentGroupCommand } from './commands/agent-group';
import { registerAgentSignalCommand } from './commands/agent-signal';
import { registerBotCommand } from './commands/bot';
import { registerCompletionCommand } from './commands/completion';
import { registerConfigCommand } from './commands/config';
@@ -34,7 +33,6 @@ import { registerTaskCommand } from './commands/task';
import { registerThreadCommand } from './commands/thread';
import { registerTopicCommand } from './commands/topic';
import { registerUserCommand } from './commands/user';
import { registerVerifyCommand } from './commands/verify';
const require = createRequire(import.meta.url);
const { version } = require('../package.json');
@@ -60,7 +58,6 @@ export function createProgram() {
registerMemoryCommand(program);
registerAgentCommand(program);
registerAgentGroupCommand(program);
registerAgentSignalCommand(program);
registerBotCommand(program);
registerGenerateCommand(program);
registerFileCommand(program);
@@ -76,7 +73,6 @@ export function createProgram() {
registerProviderCommand(program);
registerPluginCommand(program);
registerUserCommand(program);
registerVerifyCommand(program);
registerConfigCommand(program);
registerEvalCommand(program);
registerMigrateCommand(program);
+22 -34
View File
@@ -3,7 +3,6 @@ import { mkdir, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { ShellProcessManager } from '@lobechat/local-file-shell';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { executeToolCall } from './index';
@@ -28,17 +27,15 @@ describe('executeToolCall', () => {
fs.rmSync(tmpDir, { force: true, recursive: true });
});
it('should dispatch readFile with formatted content and structured state', async () => {
it('should dispatch readFile', async () => {
const filePath = path.join(tmpDir, 'test.txt');
await writeFile(filePath, 'hello world');
const result = await executeToolCall('readFile', JSON.stringify({ path: filePath }));
expect(result.success).toBe(true);
// content is now the formatted prompt text, not raw JSON
expect(result.content).toContain('hello world');
// structured payload travels in `state` for client renders
expect((result.state as { content: string }).content).toContain('hello world');
const parsed = JSON.parse(result.content);
expect(parsed.content).toContain('hello world');
});
it('should dispatch writeFile', async () => {
@@ -50,7 +47,6 @@ describe('executeToolCall', () => {
);
expect(result.success).toBe(true);
expect((result.state as { path: string }).path).toBe(filePath);
expect(fs.readFileSync(filePath, 'utf8')).toBe('written');
});
@@ -61,7 +57,8 @@ describe('executeToolCall', () => {
const result = await executeToolCall('readLocalFile', JSON.stringify({ path: filePath }));
expect(result.success).toBe(true);
expect((result.state as { content: string }).content).toContain('legacy hello');
const parsed = JSON.parse(result.content);
expect(parsed.content).toContain('legacy hello');
});
it('should dispatch runCommand', async () => {
@@ -71,9 +68,8 @@ describe('executeToolCall', () => {
);
expect(result.success).toBe(true);
expect(result.content).toContain('dispatched');
const state = result.state as { output?: string; stdout?: string };
expect(state.stdout ?? state.output).toContain('dispatched');
const parsed = JSON.parse(result.content);
expect(parsed.stdout).toContain('dispatched');
});
it('should dispatch listFiles', async () => {
@@ -82,7 +78,8 @@ describe('executeToolCall', () => {
const result = await executeToolCall('listFiles', JSON.stringify({ path: tmpDir }));
expect(result.success).toBe(true);
expect((result.state as { totalCount: number }).totalCount).toBeGreaterThan(0);
const parsed = JSON.parse(result.content);
expect(parsed.totalCount).toBeGreaterThan(0);
});
it('should dispatch globFiles', async () => {
@@ -94,7 +91,8 @@ describe('executeToolCall', () => {
);
expect(result.success).toBe(true);
expect((result.state as { files: string[] }).files).toContain('test.ts');
const parsed = JSON.parse(result.content);
expect(parsed.files).toContain('test.ts');
});
it('should dispatch editFile', async () => {
@@ -111,7 +109,6 @@ describe('executeToolCall', () => {
);
expect(result.success).toBe(true);
expect((result.state as { replacements: number }).replacements).toBeGreaterThan(0);
expect(fs.readFileSync(filePath, 'utf8')).toBe('new content');
});
@@ -122,15 +119,19 @@ describe('executeToolCall', () => {
expect(result.error).toContain('Unknown tool API');
});
it('should carry structured state on file reads', async () => {
it('should handle tool that returns a string result', async () => {
// runCommand returns an object, but we test the string branch by mocking
// Actually, none of the tools return plain strings, so the JSON.stringify branch
// is always taken. The string check is for future-proofing.
// Let's verify the JSON output path
const filePath = path.join(tmpDir, 'str.txt');
await writeFile(filePath, 'content');
const result = await executeToolCall('readFile', JSON.stringify({ path: filePath }));
expect(result.success).toBe(true);
expect(result.state).toBeDefined();
expect(typeof result.content).toBe('string');
// Result should be valid JSON
expect(() => JSON.parse(result.content)).not.toThrow();
});
it('should return error for invalid JSON arguments', async () => {
@@ -149,7 +150,6 @@ describe('executeToolCall', () => {
);
expect(result.success).toBe(true);
expect(result.state).toBeDefined();
});
it('should dispatch searchFiles', async () => {
@@ -161,7 +161,6 @@ describe('executeToolCall', () => {
);
expect(result.success).toBe(true);
expect(result.state).toBeDefined();
});
it('should dispatch getCommandOutput', async () => {
@@ -170,21 +169,9 @@ describe('executeToolCall', () => {
JSON.stringify({ shell_id: 'nonexistent' }),
);
// The runtime envelopes a failed lookup as success:true with the failure in state
expect(result.success).toBe(true);
expect((result.state as { success: boolean }).success).toBe(false);
});
it('should forward the gateway timeout to getCommandOutput polling', async () => {
const spy = vi
.spyOn(ShellProcessManager.prototype, 'getOutput')
.mockResolvedValue({ exit_code: 0, output: '', stderr: '', stdout: '', success: true });
// 3rd arg is the gateway per-call timeout; executeToolCall injects it into args
await executeToolCall('getCommandOutput', JSON.stringify({ shell_id: 'sid' }), 5000);
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ shell_id: 'sid', timeout: 5000 }));
spy.mockRestore();
const parsed = JSON.parse(result.content);
expect(parsed.success).toBe(false);
});
it('should dispatch killCommand', async () => {
@@ -194,6 +181,7 @@ describe('executeToolCall', () => {
);
expect(result.success).toBe(true);
expect((result.state as { success: boolean }).success).toBe(false);
const parsed = JSON.parse(result.content);
expect(parsed.success).toBe(false);
});
});
+36 -39
View File
@@ -1,19 +1,41 @@
import { log } from '../utils/logger';
import { checkPlatformCapability } from './checkPlatformCapability';
import {
editLocalFile,
globLocalFiles,
grepContent,
listLocalFiles,
readLocalFile,
searchLocalFiles,
writeLocalFile,
} from './file';
import { getAgentProfile } from './getAgentProfile';
import { cancelHeteroTask, runHeteroTask } from './heteroTask';
import { runLocalSystemTool } from './localSystemRuntime';
import { getCommandOutput, killCommand, runCommand } from './shell';
/**
* CLI-only tools (platform agents). File/shell tools are handled separately by
* {@link runLocalSystemTool}, which routes them through
* `LocalSystemExecutionRuntime` so the result carries structured `state`.
*/
const methodMap: Record<string, (args: any) => Promise<unknown>> = {
cancelHeteroTask,
checkPlatformCapability,
getAgentProfile,
editFile: editLocalFile,
getCommandOutput,
globFiles: globLocalFiles,
grepContent,
killCommand,
listFiles: listLocalFiles,
readFile: readLocalFile,
runCommand,
runHeteroTask,
searchFiles: searchLocalFiles,
writeFile: writeLocalFile,
// Legacy aliases — older Gateway versions may still send the long form
editLocalFile,
globLocalFiles,
listLocalFiles,
readLocalFile,
searchLocalFiles,
writeLocalFile,
};
export async function executeToolCall(
@@ -23,44 +45,19 @@ export async function executeToolCall(
): Promise<{
content: string;
error?: string;
state?: unknown;
success: boolean;
}> {
let args: Record<string, any>;
try {
args = JSON.parse(argsStr);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
log.error(`Tool call failed: ${apiName} - ${errorMsg}`);
return { content: '', error: errorMsg, success: false };
const handler = methodMap[apiName];
if (!handler) {
return { content: '', error: `Unknown tool API: ${apiName}`, success: false };
}
const finalArgs =
typeof timeout === 'number' && Number.isFinite(timeout) && !('timeout' in args)
? { ...args, timeout }
: args;
try {
// File/shell tools route through LocalSystemExecutionRuntime so `content` is
// the formatted prompt text and `state` carries the structured payload for
// client renders — matching the desktop gateway path (PR #15114).
const localResult = await runLocalSystemTool(apiName, finalArgs);
if (localResult) {
const { error } = localResult;
return {
content: localResult.content,
error:
error instanceof Error ? error.message : typeof error === 'string' ? error : undefined,
state: localResult.state,
success: localResult.success,
};
}
// CLI-only tools return raw domain payloads, serialized into `content`.
const handler = methodMap[apiName];
if (!handler) {
return { content: '', error: `Unknown tool API: ${apiName}`, success: false };
}
const args = JSON.parse(argsStr);
const finalArgs =
typeof timeout === 'number' && Number.isFinite(timeout) && !('timeout' in args)
? { ...args, timeout }
: args;
const result = await handler(finalArgs);
const content = typeof result === 'string' ? result : JSON.stringify(result);
-197
View File
@@ -1,197 +0,0 @@
import path from 'node:path';
import type {
EditFileParams,
GetCommandOutputParams,
GlobFilesParams,
GrepContentParams,
KillCommandParams,
ListFilesParams,
ReadFileParams,
RunCommandParams,
SearchFilesParams,
WriteFileParams,
} from '@lobechat/local-file-shell';
import { type ILocalSystemService, LocalSystemExecutionRuntime } from '@lobechat/tool-runtime';
import {
editLocalFile,
globLocalFiles,
grepContent,
listLocalFiles,
readLocalFile,
searchLocalFiles,
writeLocalFile,
} from './file';
import { getCommandOutput, killCommand, runCommand } from './shell';
/**
* Output envelope produced by {@link runLocalSystemTool}. Mirrors
* `@lobechat/types`' `BuiltinServerRuntimeOutput`: `content` is the formatted
* prompt text fed to the LLM, while `state` carries the structured payload that
* client renders consume as `pluginState`.
*/
export interface LocalSystemToolOutput {
content: string;
error?: unknown;
state?: unknown;
success: boolean;
}
/**
* Stub for `ILocalSystemService` methods the CLI does not expose (batch read,
* move, rename). These are never routed by {@link runLocalSystemTool}; the
* interface just requires them, so we fail loudly if one is ever reached.
*/
const unsupported = (method: string) => (): Promise<never> =>
Promise.reject(new Error(`${method} is not supported by the LobeHub CLI`));
/**
* Adapter wiring the CLI's `@lobechat/local-file-shell` functions (file ops) and
* shell wrappers (with the shared `ShellProcessManager`) into the shape the
* runtime expects. The runtime denormalizes its camelCase params back to the
* snake_case IPC shapes these functions consume — see `LocalSystemExecutionRuntime`.
*/
const localSystemService: ILocalSystemService = {
editLocalFile,
getCommandOutput,
globFiles: globLocalFiles,
grepContent,
killCommand,
listLocalFiles,
moveLocalFiles: unsupported('moveLocalFiles'),
readLocalFile,
readLocalFiles: unsupported('readLocalFiles'),
renameLocalFile: unsupported('renameLocalFile'),
runCommand,
searchLocalFiles,
writeFile: writeLocalFile,
};
const runtime = new LocalSystemExecutionRuntime(localSystemService);
/**
* Legacy API name aliases used by older gateway versions. Normalized to the
* current tool names before dispatch.
*/
const LEGACY_API_ALIASES: Record<string, string> = {
editLocalFile: 'editFile',
globLocalFiles: 'globFiles',
listLocalFiles: 'listFiles',
readLocalFile: 'readFile',
searchLocalFiles: 'searchFiles',
writeLocalFile: 'writeFile',
};
/**
* Resolve a relative path against a scope (CWD). Mirrors the desktop gateway's
* inline copy of the renderer-side `resolveArgsWithScope` helper so the CLI and
* desktop produce identical scoping for search/grep tools.
*/
const resolveArgsWithScope = <T extends { scope?: string }>(args: T, pathField: string): T => {
const scope = args.scope;
const bag = args as Record<PropertyKey, unknown>;
const currentPath = typeof bag[pathField] === 'string' ? (bag[pathField] as string) : undefined;
if (!scope) return args;
if (!currentPath) return { ...args, [pathField]: scope };
if (path.isAbsolute(currentPath)) return args;
return { ...args, [pathField]: path.join(scope, currentPath) };
};
/**
* Route file/shell tool calls through `LocalSystemExecutionRuntime` so the
* result carries structured `state` (for client renders) and `content` is the
* formatted prompt text — matching the desktop gateway path (PR #15114).
*
* Returns `null` when `apiName` is not a local-system tool, so the caller can
* fall back to CLI-only tools (platform agents).
*/
export async function runLocalSystemTool(
apiName: string,
args: Record<string, any>,
): Promise<LocalSystemToolOutput | null> {
const normalized = LEGACY_API_ALIASES[apiName] ?? apiName;
switch (normalized) {
case 'listFiles': {
const p = args as ListFilesParams;
return runtime.listFiles({
directoryPath: p.path,
limit: p.limit,
sortBy: p.sortBy,
sortOrder: p.sortOrder,
} as never);
}
case 'readFile': {
const p = args as ReadFileParams;
return runtime.readFile({
endLine: p.loc?.[1],
path: p.path,
startLine: p.loc?.[0],
});
}
case 'writeFile': {
return runtime.writeFile(args as WriteFileParams);
}
case 'editFile': {
const p = args as EditFileParams;
return runtime.editFile({
all: p.replace_all,
path: p.file_path,
replace: p.new_string,
search: p.old_string,
});
}
case 'searchFiles': {
const resolved = resolveArgsWithScope(
args as SearchFilesParams & { scope?: string },
'directory',
);
return runtime.searchFiles({ ...resolved, directory: resolved.directory || '' } as never);
}
case 'grepContent': {
const resolved = resolveArgsWithScope(args as GrepContentParams, 'path');
return runtime.grepContent(resolved as never);
}
case 'globFiles': {
const p = args as GlobFilesParams;
// Honor both `scope` (current manifest) and the `cwd` legacy alias.
return runtime.globFiles({ directory: p.scope ?? p.cwd, pattern: p.pattern });
}
case 'runCommand': {
// ComputerRuntime's RunCommandState reads `args.background`; the manifest
// exposes `run_in_background`. Without this normalize the state would
// always show foreground even for background commands.
const p = args as RunCommandParams;
return runtime.runCommand({ ...p, background: p.run_in_background } as never);
}
case 'getCommandOutput': {
// Forward `timeout` (gateway per-call budget, injected into args by
// executeToolCall) so polling a running command honors it instead of the
// service's default wait. The runtime carries it through to getOutput.
const p = args as GetCommandOutputParams;
return runtime.getCommandOutput({
commandId: p.shell_id,
filter: p.filter,
timeout: p.timeout,
} as never);
}
case 'killCommand': {
const p = args as KillCommandParams;
return runtime.killCommand({ commandId: p.shell_id });
}
default: {
return null;
}
}
}
+1 -5
View File
@@ -296,11 +296,7 @@ export async function streamAgentEventsViaWebSocket(
console.log(JSON.stringify(jsonEvents, null, 2));
}
isSettled = true;
// Surface the close code + reason — `String(event)` is just "[object CloseEvent]".
const reason = event.reason ? `: ${event.reason}` : '';
reject(
new Error(`Agent gateway WebSocket closed before completion (code ${event.code}${reason})`),
);
reject(new Error(`Agent gateway WebSocket closed before completion: ${String(event)}`));
};
});
}
-1
View File
@@ -15,7 +15,6 @@
"paths": {
"@lobechat/device-gateway-client": ["../../packages/device-gateway-client/src"],
"@lobechat/local-file-shell": ["../../packages/local-file-shell/src"],
"@lobechat/tool-runtime": ["../../packages/tool-runtime/src"],
"@/*": ["../../src/*"]
}
},
-4
View File
@@ -17,10 +17,6 @@ export default defineConfig({
find: '@lobechat/file-loaders',
replacement: path.resolve(__dirname, '../../packages/file-loaders/src/index.ts'),
},
{
find: '@lobechat/tool-runtime',
replacement: path.resolve(__dirname, '../../packages/tool-runtime/src/index.ts'),
},
],
},
test: {
-17
View File
@@ -223,22 +223,5 @@ export default defineConfig({
dedupe: ['react', 'react-dom'],
tsconfigPaths: true,
},
// In dev the BrowserWindow loads `app://renderer/` and the Electron main process
// proxies non-backend requests to this Vite dev server via `net.fetch`. The HMR
// WebSocket still connects directly (browser → ws://localhost:<port>) — so the
// port MUST be deterministic. `strictPort` fails fast on conflict instead of
// silently sliding, and `clientPort` baked into the HMR injection has to match.
server: {
hmr: {
clientPort: 5173,
host: '127.0.0.1',
protocol: 'ws',
},
// Force IPv4 so main-process `fetch` skips happy-eyeballs dual-stack
// attempts that surface as ETIMEDOUT under cold-start request bursts.
host: '127.0.0.1',
port: 5173,
strictPort: true,
},
},
});
+2 -9
View File
@@ -68,16 +68,9 @@
if (resolvedTheme === 'dark' || resolvedTheme === 'light') {
document.documentElement.setAttribute('data-theme', resolvedTheme);
}
// Renderer-side reloads (Cmd+R / webContents.reload) don't go through
// the main process's `?lng=` injection, so prefer the i18next cache —
// the actual user setting persisted by the language switcher — before
// falling back to the URL param or navigator detection.
// Check URL query parameter for locale (set by Electron main process from stored settings)
var urlParams = new URLSearchParams(window.location.search);
var locale;
try {
locale = localStorage.getItem('i18nextLng');
} catch (_) {}
if (!locale) locale = urlParams.get('lng') || navigator.language || 'en-US';
var locale = urlParams.get('lng') || navigator.language || 'en-US';
document.documentElement.lang = locale;
var rtl = ['ar', 'arc', 'dv', 'fa', 'ha', 'he', 'khw', 'ks', 'ku', 'ps', 'ur', 'yi'];
document.documentElement.dir =
+3 -6
View File
@@ -63,7 +63,6 @@
"@lobechat/file-loaders": "workspace:*",
"@lobechat/heterogeneous-agents": "workspace:*",
"@lobechat/local-file-shell": "workspace:*",
"@lobechat/tool-runtime": "workspace:*",
"@lobehub/i18n-cli": "^1.25.1",
"@modelcontextprotocol/sdk": "^1.24.3",
"@t3-oss/env-core": "^0.13.8",
@@ -78,7 +77,7 @@
"cross-env": "^10.1.0",
"diff": "^8.0.4",
"electron": "41.3.0",
"electron-builder": "26.14.0",
"electron-builder": "^26.8.1",
"electron-devtools-installer": "4.0.0",
"electron-is": "^3.0.0",
"electron-store": "^8.2.0",
@@ -111,7 +110,7 @@
"undici": "^7.16.0",
"uuid": "^14.0.0",
"vite": "8.0.14",
"vitest": "3.2.4",
"vitest": "^3.2.4",
"zod": "^3.25.76"
},
"optionalDependencies": {
@@ -125,10 +124,8 @@
"node-mac-permissions"
],
"overrides": {
"node-gyp": "^12.4.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"vitest": "3.2.4"
"react-dom": "19.2.4"
}
}
}
-2
View File
@@ -11,8 +11,6 @@ packages:
- '../../packages/device-gateway-client'
- '../../packages/device-identity'
- '../../packages/local-file-shell'
- '../../packages/tool-runtime'
- '../../packages/prompts'
- './stubs/business-const'
- './stubs/types'
- '.'
+2 -14
View File
@@ -1,16 +1,4 @@
export const ELECTRON_BE_PROTOCOL_SCHEME = 'lobe-backend';
export const LOCAL_FILE_PROTOCOL_SCHEME = 'localfile';
export const LOCAL_FILE_PROTOCOL_HOST = 'file';
/**
* Renderer pathnames that must be proxied to the remote LobeHub backend
* instead of being served as static assets. Covers tRPC, webapi, NextAuth,
* and the marketplace REST + OIDC token/userinfo/handoff endpoints.
*
* `/lobehub-oidc/*` is intentionally NOT here — those URLs are handed to
* `shell.openExternal` as fully-qualified web URLs and never reach renderer
* `fetch`.
*/
export const BACKEND_PATH_PREFIXES = ['/trpc', '/webapi', '/api/auth', '/market'];
export const isBackendPath = (pathname: string) =>
BACKEND_PATH_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
@@ -3,41 +3,17 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type {
AgentRunRequestMessage,
GatewayMcpStdioParams,
} from '@lobechat/device-gateway-client';
import type {
EditLocalFileParams,
GatewayConnectionStatus,
GetCommandOutputParams,
GlobFilesParams,
GrepContentParams,
InitWorkspaceParams,
KillCommandParams,
ListLocalFileParams,
ListProjectSkillsParams,
LocalReadFileParams,
LocalReadFilesParams,
LocalSearchFilesParams,
MoveLocalFilesParams,
RenameLocalFileParams,
RunCommandParams,
WriteLocalFileParams,
} from '@lobechat/electron-client-ipc';
import { type ILocalSystemService, LocalSystemExecutionRuntime } from '@lobechat/tool-runtime';
import type { AgentRunRequestMessage } from '@lobechat/device-gateway-client';
import type { GatewayConnectionStatus } from '@lobechat/electron-client-ipc';
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
import ImessageBridgeService from '@/services/imessageBridgeSrv';
import GitCtr from './GitCtr';
import HeterogeneousAgentCtr from './HeterogeneousAgentCtr';
import { ControllerModule, IpcMethod } from './index';
import LocalFileCtr from './LocalFileCtr';
import McpCtr from './McpCtr';
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
import ShellCommandCtr from './ShellCommandCtr';
import WorkspaceCtr from './WorkspaceCtr';
/**
* Inject the lh-notify protocol into the first turn of a new hetero-agent session.
@@ -79,62 +55,8 @@ interface PlatformTaskEntry {
topicId: string;
}
/**
* Local mirror of `@lobechat/types`' `BuiltinServerRuntimeOutput`. Inlined
* because the desktop tsconfig doesn't expose `@lobechat/types`, and the shape
* is tiny + stable.
*/
interface BuiltinServerRuntimeOutput {
content: string;
error?: unknown;
state?: unknown;
success: boolean;
}
/**
* Legacy API name aliases used by older gateway versions. Normalized to the
* current `LocalSystemApiEnum` names before dispatch. `renameLocalFile` is
* intentionally absent — it has no equivalent on the new surface and is
* handled by a dedicated branch below.
*/
const LEGACY_API_ALIASES: Record<string, string> = {
editLocalFile: 'editFile',
globLocalFiles: 'globFiles',
listLocalFiles: 'listFiles',
moveLocalFiles: 'moveFiles',
readLocalFile: 'readFile',
searchLocalFiles: 'searchFiles',
writeLocalFile: 'writeFile',
};
/**
* Parse a JSON string, returning `undefined` on failure. Used to surface the
* structured shape of platform-agent tool results (which return pre-stringified
* JSON) as `state` for the renderer, without crashing on malformed input.
*/
const safeJsonParse = (input: string): unknown => {
try {
return JSON.parse(input);
} catch {
return undefined;
}
};
/**
* Resolve a relative path against a scope (CWD). Mirrors the renderer-side
* `resolveArgsWithScope` helper in `@lobechat/builtin-tool-local-system` — kept
* here as a small inline copy to avoid pulling the renderer-side `./client`
* subpath (which transitively requires React + antd) into the main process.
*/
const resolveArgsWithScope = <T extends { scope?: string }>(args: T, pathField: string): T => {
const scope = args.scope;
const bag = args as Record<PropertyKey, unknown>;
const currentPath = typeof bag[pathField] === 'string' ? (bag[pathField] as string) : undefined;
if (!scope) return args;
if (!currentPath) return { ...args, [pathField]: scope };
if (path.isAbsolute(currentPath)) return args;
return { ...args, [pathField]: path.join(scope, currentPath) };
};
type ToolCallHandler = () => Promise<unknown>;
type ToolCallHandlerMap = Record<string, ToolCallHandler>;
/**
* GatewayConnectionCtr
@@ -150,8 +72,6 @@ export default class GatewayConnectionCtr extends ControllerModule {
/** Maps topicId → hermes session_id for multi-turn conversation continuity. */
private readonly hermesSessionMap = new Map<string, string>();
private localSystemRuntime: LocalSystemExecutionRuntime | null = null;
// ─── Service Accessor ───
private get service() {
@@ -166,14 +86,6 @@ export default class GatewayConnectionCtr extends ControllerModule {
return this.app.getController(LocalFileCtr);
}
private get workspaceCtr() {
return this.app.getController(WorkspaceCtr);
}
private get gitCtr() {
return this.app.getController(GitCtr);
}
private get shellCommandCtr() {
return this.app.getController(ShellCommandCtr);
}
@@ -186,10 +98,6 @@ export default class GatewayConnectionCtr extends ControllerModule {
return this.app.getController(HeterogeneousAgentCtr);
}
private get mcpCtr() {
return this.app.getController(McpCtr);
}
// ─── Lifecycle ───
afterAppReady() {
@@ -204,9 +112,6 @@ export default class GatewayConnectionCtr extends ControllerModule {
// Wire up tool call handler
srv.setToolCallHandler((apiName, args) => this.executeToolCall(apiName, args));
// Wire up MCP call handler (tunneled stdio MCP calls from the cloud server)
srv.setMcpCallHandler((mcpCall) => this.executeMcpCall(mcpCall));
// Wire up message API handler
srv.setMessageApiHandler((platform, apiName, payload) =>
this.executeMessageApi(platform, apiName, payload),
@@ -215,10 +120,6 @@ export default class GatewayConnectionCtr extends ControllerModule {
// Wire up agent run handler
srv.setAgentRunHandler((request) => this.executeAgentRun(request));
// Wire up generic device RPC handler (server-internal method forwarding,
// e.g. workspace-init scans — never surfaced to the agent)
srv.setRpcHandler((method, params) => this.executeDeviceRpc(method, params));
// Wire up device registrar (persists this device to the server registry)
srv.setDeviceRegistrar((info) => this.registerDevice(info));
@@ -318,315 +219,21 @@ export default class GatewayConnectionCtr extends ControllerModule {
// ─── Tool Call Routing ───
/**
* Lazy-construct the LocalSystemExecutionRuntime backed by a thin service
* adapter over the existing controllers. The runtime is the same one the
* renderer uses, so remote tool calls produce identical
* `{ content, state, success }` envelopes — `content` is the LLM-facing
* prompt text, `state` is the structured payload, both flow downstream
* intact (the gateway / DeviceGateway / RuntimeExecutors paths preserve them
* and write `state` to the tool message's `pluginState`).
*/
private getLocalSystemRuntime(): LocalSystemExecutionRuntime {
if (!this.localSystemRuntime) {
const local = this.localFileCtr;
const shell = this.shellCommandCtr;
const service: ILocalSystemService = {
editLocalFile: (p) => local.handleEditFile(p),
getCommandOutput: (p) => shell.handleGetCommandOutput(p),
globFiles: (p) => local.handleGlobFiles(p),
grepContent: (p) => local.handleGrepContent(p),
killCommand: (p) => shell.handleKillCommand(p),
listLocalFiles: (p) => local.listLocalFiles(p),
moveLocalFiles: (p) => local.handleMoveFiles(p),
readLocalFile: (p) => local.readFile(p),
readLocalFiles: (p) => local.readFiles(p),
renameLocalFile: (p) => local.handleRenameFile(p),
runCommand: (p) => shell.handleRunCommand(p),
searchLocalFiles: (p) => local.handleLocalFilesSearch(p),
writeFile: (p) => local.handleWriteFile(p),
};
this.localSystemRuntime = new LocalSystemExecutionRuntime(service);
private async executeToolCall(apiName: string, args: any): Promise<unknown> {
const methodMap = {
...this.getLocalFileToolHandlers(args),
...this.getShellCommandToolHandlers(args),
...this.getPlatformAgentToolHandlers(args),
} satisfies ToolCallHandlerMap;
const handler = methodMap[apiName];
if (!handler) {
throw new Error(
`Tool "${apiName}" is not available on this device. It may not be supported in the current desktop version. Please skip this tool and try alternative approaches.`,
);
}
return this.localSystemRuntime;
}
/**
* Dispatch a generic server-internal device RPC (not an agent tool call) by
* method name. Currently only `initWorkspace` (scan the bound project root for
* skills + AGENTS.md); add new server-only device methods here.
*/
private async executeDeviceRpc(method: string, params: unknown): Promise<unknown> {
switch (method) {
case 'initWorkspace': {
return this.workspaceCtr.initWorkspace(params as InitWorkspaceParams);
}
case 'getGitBranch': {
return this.gitCtr.getGitBranch((params as { path: string }).path);
}
case 'getLinkedPullRequest': {
return this.gitCtr.getLinkedPullRequest(params as { branch: string; path: string });
}
case 'getGitWorkingTreeStatus': {
return this.gitCtr.getGitWorkingTreeStatus((params as { path: string }).path);
}
case 'getGitAheadBehind': {
return this.gitCtr.getGitAheadBehind((params as { path: string }).path);
}
case 'listGitBranches': {
return this.gitCtr.listGitBranches((params as { path: string }).path);
}
case 'checkoutGitBranch': {
return this.gitCtr.checkoutGitBranch(
params as { branch: string; create?: boolean; path: string },
);
}
case 'pullGitBranch': {
return this.gitCtr.pullGitBranch(params as { path: string });
}
case 'pushGitBranch': {
return this.gitCtr.pushGitBranch(params as { path: string });
}
case 'getGitWorkingTreePatches': {
return this.gitCtr.getGitWorkingTreePatches((params as { path: string }).path);
}
case 'getGitWorkingTreeFiles': {
return this.gitCtr.getGitWorkingTreeFiles((params as { path: string }).path);
}
case 'getProjectFileIndex': {
return this.localFileCtr.getProjectFileIndex(params as { scope?: string });
}
case 'listProjectSkills': {
return this.workspaceCtr.listProjectSkills(params as ListProjectSkillsParams);
}
case 'getGitBranchDiff': {
return this.gitCtr.getGitBranchDiff(params as { baseRef?: string; path: string });
}
case 'listGitRemoteBranches': {
return this.gitCtr.listGitRemoteBranches((params as { path: string }).path);
}
case 'revertGitFile': {
return this.gitCtr.revertGitFile(params as { filePath: string; path: string });
}
case 'statPath': {
return this.workspaceCtr.statPath(params as { path: string });
}
default: {
throw new Error(`Unknown device RPC method: ${method}`);
}
}
}
private async executeToolCall(
apiName: string,
args: unknown,
): Promise<BuiltinServerRuntimeOutput> {
const runtime = this.getLocalSystemRuntime();
const normalized = LEGACY_API_ALIASES[apiName] ?? apiName;
// Each case narrows `args` to its IPC param type — the manifest guarantees
// the gateway sends params matching the apiName. The `as never` casts on
// runtime calls are legitimate widenings: the runtime's typed signatures
// (e.g. `ListFilesParams`) are narrower than what the IPC layer accepts
// (`limit`, `run_in_background`, etc.), and the same casts exist in the
// renderer-side `LocalSystemExecutor`.
switch (normalized) {
case 'listFiles': {
const p = args as ListLocalFileParams;
return runtime.listFiles({
directoryPath: p.path,
limit: p.limit,
sortBy: p.sortBy,
sortOrder: p.sortOrder,
} as never);
}
case 'readFile': {
const p = args as LocalReadFileParams;
return runtime.readFile({
endLine: p.loc?.[1],
path: p.path,
startLine: p.loc?.[0],
});
}
case 'readFiles': {
return runtime.readFiles(args as LocalReadFilesParams);
}
case 'searchFiles': {
const resolved = resolveArgsWithScope(args as LocalSearchFilesParams, 'directory');
return runtime.searchFiles({
...resolved,
directory: resolved.directory || '',
});
}
case 'moveFiles': {
const p = args as MoveLocalFilesParams;
return runtime.moveFiles({
operations: p.items?.map((item) => ({
destination: item.newPath,
source: item.oldPath,
})),
});
}
case 'writeFile': {
return runtime.writeFile(args as WriteLocalFileParams);
}
case 'editFile': {
const p = args as EditLocalFileParams;
return runtime.editFile({
all: p.replace_all,
path: p.file_path,
replace: p.new_string,
search: p.old_string,
});
}
case 'runCommand': {
// ComputerRuntime's RunCommandState reads `args.background`; the manifest
// exposes `run_in_background`. Without this normalize the state would
// always show foreground even for background commands.
const p = args as RunCommandParams;
return runtime.runCommand({
...p,
background: p.run_in_background,
} as never);
}
case 'getCommandOutput': {
const p = args as GetCommandOutputParams;
return runtime.getCommandOutput({
commandId: p.shell_id,
filter: p.filter,
} as never);
}
case 'killCommand': {
const p = args as KillCommandParams;
return runtime.killCommand({
commandId: p.shell_id,
});
}
case 'grepContent': {
const resolved = resolveArgsWithScope(args as GrepContentParams, 'path');
return runtime.grepContent(resolved as never);
}
case 'globFiles': {
const p = args as GlobFilesParams;
return runtime.globFiles({
directory: p.scope,
pattern: p.pattern,
});
}
case 'renameLocalFile': {
// ComputerRuntime has no public rename method — new surface uses
// `moveFiles`. Legacy gateway versions may still emit this name, so we
// call the IPC handler directly and wrap the raw result into the
// BuiltinServerRuntimeOutput shape so `state` still flows downstream.
const raw = await this.localFileCtr.handleRenameFile(args as RenameLocalFileParams);
return {
content: raw.success
? `Renamed to ${raw.newPath}`
: `Rename failed: ${raw.error ?? 'unknown error'}`,
state: raw,
success: raw.success,
};
}
// ─── Platform agent tools (openclaw / hermes) ───
// These don't go through LocalSystemExecutionRuntime — they return raw
// domain payloads that we envelope into BuiltinServerRuntimeOutput here.
// `content` is the JSON-serialized payload (what the LLM reads); `state`
// carries the parsed object so the renderer can render structured UI.
case 'checkPlatformCapability': {
const result = await this.checkPlatformCapability(args as { platform: string });
return { content: JSON.stringify(result), state: result, success: true };
}
case 'getAgentProfile': {
const result = await this.getAgentProfile(args as { agentId?: string; platform: string });
return { content: JSON.stringify(result), state: result, success: true };
}
case 'runHeteroTask': {
// runHeteroTask returns a pre-stringified JSON payload — pass it through
// as `content` and surface the parsed shape as `state`.
const json = await this.runHeteroTask(
args as {
agentId?: string;
agentType: string;
cwd?: string;
operationId: string;
prompt: string;
taskId: string;
topicId: string;
},
);
return { content: json, state: safeJsonParse(json), success: true };
}
case 'cancelHeteroTask': {
const json = await this.cancelHeteroTask(args as { signal?: string; taskId: string });
return { content: json, state: safeJsonParse(json), success: true };
}
default: {
throw new Error(
`Tool "${apiName}" is not available on this device. It may not be supported in the current desktop version. Please skip this tool and try alternative approaches.`,
);
}
}
}
/**
* Execute a stdio MCP tool call tunneled from the cloud server. The server
* can't spawn the user's local MCP binary, so it forwards the connection
* params (command/args/env); we run the call through the local MCP client,
* which spawns the stdio server on this machine.
*/
private async executeMcpCall(mcpCall: {
apiName: string;
arguments: string;
identifier: string;
params: GatewayMcpStdioParams;
}): Promise<BuiltinServerRuntimeOutput> {
const { apiName, arguments: args, params: stdioParams } = mcpCall;
return this.mcpCtr.runStdioMcpTool({
args,
env: stdioParams.env,
params: {
args: stdioParams.args,
command: stdioParams.command,
name: stdioParams.name,
},
toolName: apiName,
});
return handler();
}
private async executeMessageApi(
@@ -643,6 +250,59 @@ export default class GatewayConnectionCtr extends ControllerModule {
);
}
private getLocalFileToolHandlers(args: any): ToolCallHandlerMap {
const editFile = () => this.localFileCtr.handleEditFile(args);
const globFiles = () => this.localFileCtr.handleGlobFiles(args);
const listFiles = () => this.localFileCtr.listLocalFiles(args);
const moveFiles = () => this.localFileCtr.handleMoveFiles(args);
const readFile = () => this.localFileCtr.readFile(args);
const searchFiles = () => this.localFileCtr.handleLocalFilesSearch(args);
const writeFile = () => this.localFileCtr.handleWriteFile(args);
return {
editFile,
globFiles,
grepContent: () => this.localFileCtr.handleGrepContent(args),
listFiles,
moveFiles,
readFile,
searchFiles,
writeFile,
// Legacy aliases — keep these so older Gateway versions sending the long
// names continue to route correctly. `renameLocalFile` is also kept even
// though the new surface drops rename (it's now handled by `moveFiles`).
editLocalFile: editFile,
globLocalFiles: globFiles,
listLocalFiles: listFiles,
moveLocalFiles: moveFiles,
readLocalFile: readFile,
renameLocalFile: () => this.localFileCtr.handleRenameFile(args),
searchLocalFiles: searchFiles,
writeLocalFile: writeFile,
};
}
private getShellCommandToolHandlers(args: any): ToolCallHandlerMap {
return {
getCommandOutput: () => this.shellCommandCtr.handleGetCommandOutput(args),
killCommand: () => this.shellCommandCtr.handleKillCommand(args),
runCommand: () => this.shellCommandCtr.handleRunCommand(args),
};
}
private getPlatformAgentToolHandlers(args: any): ToolCallHandlerMap {
return {
// Platform agent capability probing
checkPlatformCapability: () => this.checkPlatformCapability(args),
getAgentProfile: () => this.getAgentProfile(args),
// Platform agent task execution (openclaw / hermes)
cancelHeteroTask: () => this.cancelHeteroTask(args),
runHeteroTask: () => this.runHeteroTask(args),
};
}
// ─── Platform Capability Probing ───
private async checkPlatformCapability(args: {
+167 -24
View File
@@ -22,16 +22,8 @@ import type {
GitWorkingTreeStatus,
SubmoduleWorkingTreePatches,
} from '@lobechat/electron-client-ipc';
import {
type DeviceGitInfo,
getGitAheadBehind as computeGitAheadBehind,
getGitBranch as computeGitBranch,
getGitWorkingTreeStatus as computeGitWorkingTreeStatus,
getLinkedPullRequest as computeLinkedPullRequest,
gitInfo as computeGitInfo,
} from '@lobechat/local-file-shell';
import { detectRepoType } from '@/utils/git';
import { detectRepoType, resolveGitDir } from '@/utils/git';
import { createLogger } from '@/utils/logger';
import { ControllerModule, IpcMethod } from './index';
@@ -458,17 +450,23 @@ export default class GitController extends ControllerModule {
*/
@IpcMethod()
async getGitBranch(dirPath: string): Promise<GitBranchInfo> {
return computeGitBranch(dirPath);
}
try {
const gitDir = await resolveGitDir(dirPath);
if (!gitDir) return {};
/**
* Aggregate git status (branch + linked PR + working tree + ahead/behind) for a
* directory. The single entry point shared by the local desktop display, the
* device `gitInfo` RPC, and the CLI implemented in `@lobechat/local-file-shell`.
*/
@IpcMethod()
async gitInfo(params: { isGithub?: boolean; scope: string }): Promise<DeviceGitInfo> {
return computeGitInfo(params);
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 {};
}
}
/**
@@ -481,7 +479,58 @@ export default class GitController extends ControllerModule {
branch: string;
path: string;
}): Promise<GitLinkedPullRequestResult> {
return computeLinkedPullRequest(payload);
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' };
}
}
/**
@@ -586,7 +635,42 @@ export default class GitController extends ControllerModule {
*/
@IpcMethod()
async getGitWorkingTreeStatus(dirPath: string): Promise<GitWorkingTreeStatus> {
return computeGitWorkingTreeStatus(dirPath);
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 };
}
}
/**
@@ -605,7 +689,7 @@ export default class GitController extends ControllerModule {
const modified: string[] = [];
const deleted: string[] = [];
try {
const { stdout } = await execFileAsync('git', ['status', '--porcelain', '-u', '-z'], {
const { stdout } = await execFileAsync('git', ['status', '--porcelain', '-z'], {
cwd: dirPath,
timeout: 5000,
});
@@ -746,7 +830,7 @@ export default class GitController extends ControllerModule {
const entries: Entry[] = [];
const submoduleDirtyEntries: Entry[] = [];
try {
const { stdout } = await execFileAsync('git', ['status', '--porcelain', '-u', '-z'], {
const { stdout } = await execFileAsync('git', ['status', '--porcelain', '-z'], {
cwd: dirPath,
timeout: 5000,
});
@@ -1049,7 +1133,66 @@ export default class GitController extends ControllerModule {
*/
@IpcMethod()
async getGitAheadBehind(dirPath: string): Promise<GitAheadBehind> {
return computeGitAheadBehind(dirPath);
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 };
}
}
/**
@@ -1,5 +1,5 @@
import { constants } from 'node:fs';
import { access, mkdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
import { access, mkdir, readdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import {
@@ -12,6 +12,8 @@ import {
type GrepContentParams,
type GrepContentResult,
type ListLocalFileParams,
type ListProjectSkillsParams,
type ListProjectSkillsResult,
type LocalFilePreviewUrlParams,
type LocalFilePreviewUrlResult,
type LocalMoveFilesResultItem,
@@ -121,6 +123,62 @@ const collectProjectDirectories = (files: string[], root: string): ProjectFileIn
return [...directories].map((directory) => createProjectFileEntry(root, directory, true));
};
const SKILL_FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
// Cap recursion to guard against pathological directory trees.
const MAX_SKILL_FILE_COUNT = 1000;
const listSkillFilesRecursive = async (dir: string): Promise<string[]> => {
const results: string[] = [];
const stack: string[] = [dir];
while (stack.length > 0 && results.length < MAX_SKILL_FILE_COUNT) {
const current = stack.pop()!;
let entries;
try {
entries = await readdir(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(full);
} else if (entry.isFile()) {
results.push(toPosixRelativePath(path.relative(dir, full)));
if (results.length >= MAX_SKILL_FILE_COUNT) break;
}
}
}
return results.sort();
};
// Parse a minimal YAML frontmatter block for SKILL.md files.
// Only handles `key: value` lines; multi-line block scalars fall back to the first line.
const parseSkillFrontmatter = (raw: string): Record<string, string> => {
const match = raw.match(SKILL_FRONTMATTER_RE);
if (!match) return {};
const fields: Record<string, string> = {};
for (const line of match[1].split(/\r?\n/)) {
const colonIdx = line.indexOf(':');
if (colonIdx === -1) continue;
const key = line.slice(0, colonIdx).trim();
if (!key || key.startsWith('#')) continue;
let value = line.slice(colonIdx + 1).trim();
if (value.startsWith('|') || value.startsWith('>')) continue;
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
fields[key] = value;
}
return fields;
};
const createDetectedProjectFileEntry = async (
root: string,
absolutePath: string,
@@ -603,6 +661,61 @@ export default class LocalFileCtr extends ControllerModule {
};
}
/**
* Scan agent skill directories under the project root and return parsed
* frontmatter for each SKILL.md. Used by the hetero agent's working sidebar
* to surface skills available in the current project.
*/
@IpcMethod()
async listProjectSkills(params: ListProjectSkillsParams): Promise<ListProjectSkillsResult> {
const root = params.scope;
const sources = ['.agents/skills', '.claude/skills'] as const;
for (const source of sources) {
const dir = path.join(root, source);
try {
const entries = await readdir(dir, { withFileTypes: true });
const skills = (
await Promise.all(
entries
.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
.map(async (entry) => {
const skillDir = path.join(dir, entry.name);
const skillFile = path.join(skillDir, 'SKILL.md');
try {
const raw = await readFile(skillFile, 'utf8');
const fields = parseSkillFrontmatter(raw);
const files = await listSkillFilesRecursive(skillDir);
return {
description: fields.description || undefined,
fileCount: files.length,
files,
name: fields.name || entry.name,
path: skillFile,
skillDir,
source,
};
} catch {
return null;
}
}),
)
)
.filter((skill): skill is NonNullable<typeof skill> => skill !== null)
.sort((a, b) => a.name.localeCompare(b.name));
if (skills.length > 0) {
await this.approveProjectRootForPreview(root);
return { root, skills, source };
}
} catch {
// Directory does not exist or is not readable; try the next candidate.
}
}
return { root, skills: [], source: null };
}
/**
* Handle IPC event for local file search
*/
+3 -16
View File
@@ -91,7 +91,7 @@ interface GetStreamableMcpServerManifestInput {
url: string;
}
export interface CallToolInput {
interface CallToolInput {
args: any;
env: any;
params: GetStdioMcpServerManifestInput;
@@ -324,19 +324,6 @@ export default class McpCtr extends ControllerModule {
@IpcMethod()
async callTool(payload: SuperJSONSerialized<CallToolInput>) {
const input = deserializePayload<CallToolInput>(payload);
return serializePayload(await this.runStdioMcpTool(input));
}
/**
* Core stdio MCP tool execution, shared by the renderer IPC path
* ({@link callTool}) and the device-gateway tunnel (GatewayConnectionCtr,
* which runs MCP calls forwarded from the cloud server). Returns the plain
* result envelope; callers serialize as needed. Throws on failure so each
* caller can shape its own error response.
*/
async runStdioMcpTool(
input: CallToolInput,
): Promise<{ content: string; state: unknown; success: boolean }> {
const params: MCPClientParams = {
args: input.params.args || [],
command: input.params.command,
@@ -355,11 +342,11 @@ export default class McpCtr extends ControllerModule {
const content = await toMarkdown(processed, (key) => this.fileService.getFileHTTPURL(key));
return {
return serializePayload({
content,
state: { ...raw, content: processed },
success: true,
};
});
} catch (error) {
// If it's an MCPConnectionError with stderr logs, enhance the error message
if (error instanceof MCPConnectionError && error.stderrLogs.length > 0) {
@@ -1,251 +0,0 @@
import { readdir, readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import {
type InitWorkspaceParams,
type InitWorkspaceResult,
type ListProjectSkillsParams,
type ListProjectSkillsResult,
type ProjectSkillItem,
} from '@lobechat/electron-client-ipc';
import { detectRepoType } from '@/utils/git';
import { createLogger } from '@/utils/logger';
import { ControllerModule, IpcMethod } from './index';
const logger = createLogger('controllers:WorkspaceCtr');
const SKILL_FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
// Cap recursion to guard against pathological directory trees.
const MAX_SKILL_FILE_COUNT = 1000;
const toPosixRelativePath = (filePath: string) => filePath.split(path.sep).join('/');
const listSkillFilesRecursive = async (dir: string): Promise<string[]> => {
const results: string[] = [];
const stack: string[] = [dir];
while (stack.length > 0 && results.length < MAX_SKILL_FILE_COUNT) {
const current = stack.pop()!;
let entries;
try {
entries = await readdir(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(full);
} else if (entry.isFile()) {
results.push(toPosixRelativePath(path.relative(dir, full)));
if (results.length >= MAX_SKILL_FILE_COUNT) break;
}
}
}
return results.sort();
};
// Parse a minimal YAML frontmatter block for SKILL.md files.
// Only handles `key: value` lines; multi-line block scalars fall back to the first line.
const parseSkillFrontmatter = (raw: string): Record<string, string> => {
const match = raw.match(SKILL_FRONTMATTER_RE);
if (!match) return {};
const fields: Record<string, string> = {};
for (const line of match[1].split(/\r?\n/)) {
const colonIdx = line.indexOf(':');
if (colonIdx === -1) continue;
const key = line.slice(0, colonIdx).trim();
if (!key || key.startsWith('#')) continue;
let value = line.slice(colonIdx + 1).trim();
if (value.startsWith('|') || value.startsWith('>')) continue;
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
fields[key] = value;
}
return fields;
};
/**
* WorkspaceCtr
*
* Owns "project workspace" scanning: discovering agent skills (`.agents/skills`
* / `.claude/skills`) and project-root instructions (`AGENTS.md` / `CLAUDE.md`)
* under a bound project directory. Split out of LocalFileCtr so the
* workspace/agent-config concern is distinct from generic local file ops.
*/
export default class WorkspaceCtr extends ControllerModule {
static override readonly groupName = 'workspace';
/**
* Scan one skill source directory (e.g. `.agents/skills`) under `root` and
* return parsed frontmatter for each `SKILL.md`. Returns `[]` when the source
* directory is absent or unreadable. Unsorted callers sort/merge.
*/
private async scanSkillsInSource(
root: string,
source: ProjectSkillItem['source'],
): Promise<ProjectSkillItem[]> {
const dir = path.join(root, source);
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
// Directory does not exist or is not readable.
return [];
}
const skills = await Promise.all(
entries
.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
.map(async (entry) => {
const skillDir = path.join(dir, entry.name);
const skillFile = path.join(skillDir, 'SKILL.md');
try {
const raw = await readFile(skillFile, 'utf8');
const fields = parseSkillFrontmatter(raw);
const files = await listSkillFilesRecursive(skillDir);
return {
description: fields.description || undefined,
fileCount: files.length,
files,
name: fields.name || entry.name,
path: skillFile,
skillDir,
source,
};
} catch {
return null;
}
}),
);
return skills.filter((skill): skill is ProjectSkillItem => skill !== null);
}
/**
* Scan agent skill directories under the project root and return parsed
* frontmatter for each SKILL.md. Used by the hetero agent's working sidebar
* to surface skills available in the current project. Returns the first
* source directory that yields any skills (`.agents/skills` wins).
*/
@IpcMethod()
async listProjectSkills(params: ListProjectSkillsParams): Promise<ListProjectSkillsResult> {
const root = params.scope;
const sources = ['.agents/skills', '.claude/skills'] as const;
for (const source of sources) {
const skills = (await this.scanSkillsInSource(root, source)).sort((a, b) =>
a.name.localeCompare(b.name),
);
if (skills.length > 0) {
await this.approveProjectRootForPreview(root);
return { root, skills, source };
}
}
return { root, skills: [], source: null };
}
/**
* One-call "workspace init" scan of a bound project directory: merge the
* project skills from BOTH `.agents/skills` and `.claude/skills` (deduped by
* name, `.agents/skills` winning) and read the project-root agent
* instructions file (`AGENTS.md`, else `CLAUDE.md`). Driven server-side at run
* start via the generic device RPC (not an LLM-visible tool) and cached onto
* `devices.workingDirs[].workspace`.
*
* Approves the root for the `lobe-file://` preview protocol (same as
* `listProjectSkills`) so the user can later click through to the scanned
* skills / instructions in the UI.
*/
@IpcMethod()
async initWorkspace(params: InitWorkspaceParams): Promise<InitWorkspaceResult> {
const root = params.scope;
const sources = ['.agents/skills', '.claude/skills'] as const;
const seen = new Set<string>();
const skills: ProjectSkillItem[] = [];
for (const source of sources) {
for (const skill of await this.scanSkillsInSource(root, source)) {
if (seen.has(skill.name)) continue;
seen.add(skill.name);
skills.push(skill);
}
}
skills.sort((a, b) => a.name.localeCompare(b.name));
const instructions = await this.readWorkspaceInstructions(root);
// Approve regardless of what was found — the run is now bound to this root,
// so any later click-through to it should resolve through the preview
// protocol even if the project carries neither skills nor instructions.
await this.approveProjectRootForPreview(root);
return { instructions, root, skills };
}
/**
* Check whether a path exists on this device and is a directory, plus its git
* repo type (`git` / `github` / none). Used to validate a manually-entered
* working directory from a web / remote client (which can't browse this
* device's filesystem) before binding it, and to render the right dir icon.
*/
@IpcMethod()
async statPath(params: {
path: string;
}): Promise<{ exists: boolean; isDirectory: boolean; repoType?: 'git' | 'github' }> {
try {
const stats = await stat(params.path);
if (!stats.isDirectory()) return { exists: true, isDirectory: false };
const repoType = await detectRepoType(params.path);
return { exists: true, isDirectory: true, repoType };
} catch {
return { exists: false, isDirectory: false };
}
}
/**
* Read the project-root agent instructions files. Collects every present
* candidate (`AGENTS.md`, then `CLAUDE.md`) rather than first-match, since both
* can coexist. Each body is capped so a pathologically large file can't bloat
* the cached `workingDirs` payload or the injected system role.
*/
private async readWorkspaceInstructions(
root: string,
): Promise<InitWorkspaceResult['instructions']> {
const MAX_INSTRUCTIONS_BYTES = 64 * 1024;
const candidates = ['AGENTS.md', 'CLAUDE.md'] as const;
const instructions: InitWorkspaceResult['instructions'] = [];
for (const source of candidates) {
try {
const raw = await readFile(path.join(root, source), 'utf8');
const content =
raw.length > MAX_INSTRUCTIONS_BYTES ? raw.slice(0, MAX_INSTRUCTIONS_BYTES) : raw;
instructions.push({ content, source });
} catch {
// File absent or unreadable; skip it.
}
}
return instructions;
}
private async approveProjectRootForPreview(root: string) {
try {
await this.app.localFileProtocolManager.approveIndexedProjectRoot(root);
} catch (error) {
logger.error(`Failed to approve project preview root ${root}:`, error);
}
}
}
@@ -9,7 +9,6 @@ import ImessageBridgeService from '@/services/imessageBridgeSrv';
import GatewayConnectionCtr from '../GatewayConnectionCtr';
import HeterogeneousAgentCtr from '../HeterogeneousAgentCtr';
import LocalFileCtr from '../LocalFileCtr';
import McpCtr from '../McpCtr';
import RemoteServerConfigCtr from '../RemoteServerConfigCtr';
import ShellCommandCtr from '../ShellCommandCtr';
@@ -70,26 +69,6 @@ const { ipcMainHandleMock, MockGatewayClient } = vi.hoisted(() => {
});
}
simulateMcpCallRequest(
apiName: string,
args: object,
params: object,
requestId = 'mcp-req-1',
identifier = 'kimi-datasource',
) {
this.emit('tool_call_request', {
requestId,
toolCall: {
apiName,
arguments: JSON.stringify(args),
identifier,
params,
type: 'mcp',
},
type: 'tool_call_request',
});
}
simulateMessageApiRequest(
platform: string,
apiName: string,
@@ -144,7 +123,6 @@ const { ipcMainHandleMock, MockGatewayClient } = vi.hoisted(() => {
vi.mock('electron', () => ({
app: {
getAppPath: vi.fn(() => '/mock/app'),
getPath: vi.fn((name: string) => `/mock/${name}`),
},
ipcMain: { handle: ipcMainHandleMock },
@@ -251,10 +229,6 @@ const mockImessageBridgeSrv = {
handleGatewayMessageApi: vi.fn().mockResolvedValue({ ok: true }),
} as unknown as ImessageBridgeService;
const mockMcpCtr = {
runStdioMcpTool: vi.fn().mockResolvedValue({ content: 'mcp result', state: {}, success: true }),
} as unknown as McpCtr;
const mockRemoteServerConfigCtr = {
getAccessToken: vi.fn().mockResolvedValue('mock-access-token'),
getRemoteServerUrl: vi.fn().mockResolvedValue('https://server.example.com'),
@@ -273,7 +247,6 @@ const mockApp = {
if (Cls === LocalFileCtr) return mockLocalFileCtr;
if (Cls === ShellCommandCtr) return mockShellCommandCtr;
if (Cls === HeterogeneousAgentCtr) return mockHeterogeneousAgentCtr;
if (Cls === McpCtr) return mockMcpCtr;
return null;
}),
getService: vi.fn((Cls) => {
@@ -553,18 +526,15 @@ describe('GatewayConnectionCtr', () => {
['renameLocalFile', 'handleRenameFile', mockLocalFileCtr],
] as const)('should route %s to %s', async (apiName, methodName, controller) => {
const client = await connectAndOpen();
const args = { test: 'arg' };
// Each tool's args are domain-shaped (path, file_path, items, etc.).
// The runtime denormalizes them before calling the controller, so this
// test only asserts that the *right* controller method runs — see the
// envelope-shape test below for end-to-end content/state coverage.
client.simulateToolCallRequest(apiName, { test: 'arg' });
client.simulateToolCallRequest(apiName, args);
await vi.advanceTimersByTimeAsync(0);
expect((controller as any)[methodName]).toHaveBeenCalled();
expect((controller as any)[methodName]).toHaveBeenCalledWith(args);
});
it('should send tool_call_response with content + state envelope on success', async () => {
it('should send tool_call_response with success result', async () => {
vi.mocked(mockLocalFileCtr.readFile).mockResolvedValueOnce({
charCount: 5,
content: 'hello',
@@ -582,20 +552,23 @@ describe('GatewayConnectionCtr', () => {
client.simulateToolCallRequest('readFile', { path: '/a.txt' }, 'req-42');
await vi.advanceTimersByTimeAsync(0);
// The runtime produces a formatted prompt string for `content` and a
// structured snapshot for `state`. We only assert envelope shape here
// — the exact prompt format is owned by the runtime/prompts packages.
expect(client.sendToolCallResponse).toHaveBeenCalledTimes(1);
const response = client.sendToolCallResponse.mock.calls[0][0];
expect(response.requestId).toBe('req-42');
expect(response.result.success).toBe(true);
expect(typeof response.result.content).toBe('string');
expect(response.result.content.length).toBeGreaterThan(0);
expect(response.result.content).toContain('hello');
expect(response.result.state).toMatchObject({
content: 'hello',
filename: 'a.txt',
path: '/a.txt',
expect(client.sendToolCallResponse).toHaveBeenCalledWith({
requestId: 'req-42',
result: {
content: JSON.stringify({
charCount: 5,
content: 'hello',
createdTime: new Date('2024-01-01'),
filename: 'a.txt',
fileType: '.txt',
lineCount: 1,
loc: [1, 1],
modifiedTime: new Date('2024-01-01'),
totalCharCount: 5,
totalLineCount: 1,
}),
success: true,
},
});
});
@@ -633,89 +606,6 @@ describe('GatewayConnectionCtr', () => {
},
});
});
it('should route tunneled stdio MCP calls to McpCtr.runStdioMcpTool', async () => {
const client = await connectAndOpen();
client.simulateMcpCallRequest(
'getStock',
{ symbol: 'AAPL' },
{ args: ['stock-mcp'], command: 'npx', env: { TOKEN: 'secret' }, name: 'kimi-datasource' },
);
await vi.advanceTimersByTimeAsync(0);
// The builtin local-system switch is keyed on apiName and would reject
// 'getStock'; the `type: 'mcp'` discriminator routes to the MCP client.
expect(mockMcpCtr.runStdioMcpTool).toHaveBeenCalledWith({
args: '{"symbol":"AAPL"}',
env: { TOKEN: 'secret' },
params: { args: ['stock-mcp'], command: 'npx', name: 'kimi-datasource' },
toolName: 'getStock',
});
});
it('should NOT route to MCP when params are present but type is not mcp', async () => {
// Regression: routing must follow the explicit `type` discriminator, not
// the mere presence of `params`. A builtin call that happens to carry a
// `params` field must still go to the builtin switch.
const client = await connectAndOpen();
client.emit('tool_call_request', {
requestId: 'tool-with-params',
toolCall: {
apiName: 'readFile',
arguments: JSON.stringify({ path: '/a.txt' }),
identifier: 'lobe-local-system',
params: { args: [], command: 'npx', name: 'x' },
type: 'tool',
},
type: 'tool_call_request',
});
await vi.advanceTimersByTimeAsync(0);
expect(mockMcpCtr.runStdioMcpTool).not.toHaveBeenCalled();
expect(mockLocalFileCtr.readFile).toHaveBeenCalled();
});
it('should send tool_call_response envelope for a successful MCP call', async () => {
vi.mocked(mockMcpCtr.runStdioMcpTool).mockResolvedValueOnce({
content: 'stock: 100',
state: { rows: 1 },
success: true,
});
const client = await connectAndOpen();
client.simulateMcpCallRequest(
'getStock',
{},
{ args: [], command: 'npx', name: 'kimi-datasource' },
'mcp-ok',
);
await vi.advanceTimersByTimeAsync(0);
expect(client.sendToolCallResponse).toHaveBeenCalledWith({
requestId: 'mcp-ok',
result: { content: 'stock: 100', state: { rows: 1 }, success: true },
});
});
it('should send error response when the MCP call throws', async () => {
vi.mocked(mockMcpCtr.runStdioMcpTool).mockRejectedValueOnce(new Error('spawn ENOENT'));
const client = await connectAndOpen();
client.simulateMcpCallRequest(
'getStock',
{},
{ args: [], command: 'missing-bin', name: 'kimi-datasource' },
'mcp-err',
);
await vi.advanceTimersByTimeAsync(0);
expect(client.sendToolCallResponse).toHaveBeenCalledWith({
requestId: 'mcp-err',
result: { content: 'spawn ENOENT', error: 'spawn ENOENT', success: false },
});
});
});
describe('message API routing', () => {
@@ -1086,7 +976,6 @@ describe('GatewayConnectionCtr', () => {
requestId: 'req-cap',
result: {
content: JSON.stringify({ available: true, version: 'openclaw 1.2.3' }),
state: { available: true, version: 'openclaw 1.2.3' },
success: true,
},
});
@@ -1111,7 +1000,6 @@ describe('GatewayConnectionCtr', () => {
requestId: 'req-cap-nover',
result: {
content: JSON.stringify({ available: true }),
state: { available: true },
success: true,
},
});
@@ -1137,10 +1025,6 @@ describe('GatewayConnectionCtr', () => {
available: false,
reason: 'openclaw is not installed on this device',
}),
state: {
available: false,
reason: 'openclaw is not installed on this device',
},
success: true,
},
});
@@ -1159,7 +1043,6 @@ describe('GatewayConnectionCtr', () => {
requestId: 'req-unknown-plat',
result: {
content: JSON.stringify({ available: false, reason: 'Unknown platform: unknownBot' }),
state: { available: false, reason: 'Unknown platform: unknownBot' },
success: true,
},
});
@@ -1174,7 +1057,6 @@ describe('GatewayConnectionCtr', () => {
requestId: 'req-profile',
result: {
content: JSON.stringify({}),
state: {},
success: true,
},
});
@@ -440,14 +440,8 @@ describe('HeterogeneousAgentCtr', () => {
expect(command).toBe('codex');
expect(cliArgs).not.toContain(prompt);
expect(cliArgs).toEqual(
expect.arrayContaining([
'exec',
'--json',
'--skip-git-repo-check',
'--dangerously-bypass-approvals-and-sandbox',
]),
expect.arrayContaining(['exec', '--json', '--skip-git-repo-check', '--full-auto']),
);
expect(cliArgs).not.toContain('--full-auto');
expect(cliArgs).not.toContain('-');
expect(writes).toEqual([prompt]);
});
@@ -26,7 +26,6 @@ vi.mock('@/utils/logger', () => ({
// Mock child_process for the shared package
vi.mock('node:child_process', () => ({
execFile: vi.fn(),
spawn: vi.fn(),
}));
@@ -1,161 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { type App } from '@/core/App';
import WorkspaceCtr from '../WorkspaceCtr';
const { ipcMainHandleMock } = vi.hoisted(() => ({
ipcMainHandleMock: vi.fn(),
}));
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
}),
}));
vi.mock('electron', () => ({
ipcMain: {
handle: ipcMainHandleMock,
},
}));
vi.mock('node:fs/promises', () => ({
readFile: vi.fn(),
readdir: vi.fn(),
}));
const mockLocalFileProtocolManager = {
approveIndexedProjectRoot: vi.fn(),
};
const mockApp = {
localFileProtocolManager: mockLocalFileProtocolManager,
} as unknown as App;
describe('WorkspaceCtr', () => {
let workspaceCtr: WorkspaceCtr;
let mockFsPromises: any;
beforeEach(async () => {
vi.clearAllMocks();
mockFsPromises = await import('node:fs/promises');
workspaceCtr = new WorkspaceCtr(mockApp);
});
const dirent = (name: string, kind: 'dir' | 'file') => ({
isDirectory: () => kind === 'dir',
isFile: () => kind === 'file',
isSymbolicLink: () => false,
name,
});
const frontmatter = (name: string, description: string) =>
`---\nname: ${name}\ndescription: ${description}\n---\nbody`;
describe('initWorkspace', () => {
it('merges skills from both sources and reads instruction files', async () => {
vi.mocked(mockFsPromises.readdir).mockImplementation(async (dir: string) => {
if (dir === '/proj/.agents/skills') return [dirent('spa-routes', 'dir')];
if (dir === '/proj/.agents/skills/spa-routes') return [dirent('SKILL.md', 'file')];
if (dir === '/proj/.claude/skills') return [dirent('reviewer', 'dir')];
if (dir === '/proj/.claude/skills/reviewer') return [dirent('SKILL.md', 'file')];
throw new Error('ENOENT');
});
vi.mocked(mockFsPromises.readFile).mockImplementation(async (file: string) => {
if (file === '/proj/.agents/skills/spa-routes/SKILL.md')
return frontmatter('spa-routes', 'SPA routing');
if (file === '/proj/.claude/skills/reviewer/SKILL.md')
return frontmatter('reviewer', 'Code review');
if (file === '/proj/AGENTS.md') return '# Agents';
if (file === '/proj/CLAUDE.md') return '# Claude';
throw new Error('ENOENT');
});
const result = await workspaceCtr.initWorkspace({ scope: '/proj' });
expect(result.skills.map((s) => s.name)).toEqual(['reviewer', 'spa-routes']);
expect(result.instructions).toEqual([
{ content: '# Agents', source: 'AGENTS.md' },
{ content: '# Claude', source: 'CLAUDE.md' },
]);
// Approves the scanned root for the lobe-file:// preview protocol.
expect(mockLocalFileProtocolManager.approveIndexedProjectRoot).toHaveBeenCalledWith('/proj');
});
it('dedupes skills by name with .agents/skills winning', async () => {
vi.mocked(mockFsPromises.readdir).mockImplementation(async (dir: string) => {
if (dir === '/proj/.agents/skills') return [dirent('shared', 'dir')];
if (dir === '/proj/.claude/skills') return [dirent('shared', 'dir')];
if (dir.endsWith('/shared')) return [dirent('SKILL.md', 'file')];
throw new Error('ENOENT');
});
vi.mocked(mockFsPromises.readFile).mockImplementation(async (file: string) => {
if (file === '/proj/.agents/skills/shared/SKILL.md')
return frontmatter('shared', 'from agents');
if (file === '/proj/.claude/skills/shared/SKILL.md')
return frontmatter('shared', 'from claude');
throw new Error('ENOENT');
});
const result = await workspaceCtr.initWorkspace({ scope: '/proj' });
expect(result.skills).toHaveLength(1);
expect(result.skills[0]).toMatchObject({
description: 'from agents',
path: '/proj/.agents/skills/shared/SKILL.md',
});
});
it('caps instruction file content', async () => {
vi.mocked(mockFsPromises.readdir).mockRejectedValue(new Error('ENOENT'));
const huge = 'x'.repeat(100 * 1024);
vi.mocked(mockFsPromises.readFile).mockImplementation(async (file: string) => {
if (file === '/proj/AGENTS.md') return huge;
throw new Error('ENOENT');
});
const result = await workspaceCtr.initWorkspace({ scope: '/proj' });
expect(result.skills).toEqual([]);
expect(result.instructions).toHaveLength(1);
expect(result.instructions[0].content.length).toBe(64 * 1024);
});
it('returns empty skills and instructions when nothing is present', async () => {
vi.mocked(mockFsPromises.readdir).mockRejectedValue(new Error('ENOENT'));
vi.mocked(mockFsPromises.readFile).mockRejectedValue(new Error('ENOENT'));
const result = await workspaceCtr.initWorkspace({ scope: '/proj' });
expect(result).toEqual({ instructions: [], root: '/proj', skills: [] });
});
});
describe('listProjectSkills', () => {
it('returns the first source with skills (.agents/skills wins) and ignores .claude', async () => {
vi.mocked(mockFsPromises.readdir).mockImplementation(async (dir: string) => {
if (dir === '/proj/.agents/skills') return [dirent('alpha', 'dir')];
if (dir === '/proj/.agents/skills/alpha') return [dirent('SKILL.md', 'file')];
throw new Error('ENOENT');
});
vi.mocked(mockFsPromises.readFile).mockResolvedValue(frontmatter('alpha', 'A'));
const result = await workspaceCtr.listProjectSkills({ scope: '/proj' });
expect(result.source).toBe('.agents/skills');
expect(result.skills.map((s) => s.name)).toEqual(['alpha']);
});
it('returns empty + null source when no skills exist', async () => {
vi.mocked(mockFsPromises.readdir).mockRejectedValue(new Error('ENOENT'));
const result = await workspaceCtr.listProjectSkills({ scope: '/proj' });
expect(result).toEqual({ root: '/proj', skills: [], source: null });
});
});
});
@@ -24,7 +24,6 @@ import SystemController from './SystemCtr';
import ToolDetectorCtr from './ToolDetectorCtr';
import TrayMenuCtr from './TrayMenuCtr';
import UpdaterCtr from './UpdaterCtr';
import WorkspaceCtr from './WorkspaceCtr';
export const controllerIpcConstructors = [
HeterogeneousAgentCtr,
@@ -51,7 +50,6 @@ export const controllerIpcConstructors = [
ToolDetectorCtr,
TrayMenuCtr,
UpdaterCtr,
WorkspaceCtr,
] as const satisfies readonly IpcServiceConstructor[];
type DesktopControllerIpcConstructors = typeof controllerIpcConstructors;
+12 -7
View File
@@ -10,6 +10,7 @@ import * as electronIs from 'electron-is';
import { name } from '@/../../package.json';
import { binDir, buildDir } from '@/const/dir';
import { isDev } from '@/const/env';
import { ELECTRON_BE_PROTOCOL_SCHEME } from '@/const/protocol';
import type { IControlModule } from '@/controllers';
import AuthCtr from '@/controllers/AuthCtr';
import { generateCliWrapper, getCliWrapperDir } from '@/modules/cliEmbedding';
@@ -28,7 +29,6 @@ import type { IServiceModule } from '@/services';
import { createLogger } from '@/utils/logger';
import { BrowserManager } from './browser/BrowserManager';
import { backendProxyProtocolManager } from './infrastructure/BackendProxyProtocolManager';
import { I18nManager } from './infrastructure/I18nManager';
import { IoCContainer } from './infrastructure/IoCContainer';
import { LocalFileProtocolManager } from './infrastructure/LocalFileProtocolManager';
@@ -104,17 +104,21 @@ export class App {
this.storeManager = new StoreManager(this);
this.rendererUrlManager = new RendererUrlManager();
// Wire the backend reverse-proxy as an `app://` interceptor: keeps
// RendererUrlManager ignorant of "what counts as a backend path" while
// letting BackendProxyProtocolManager own that knowledge.
this.rendererUrlManager.addRequestInterceptor(
backendProxyProtocolManager.createAppRequestInterceptor(),
);
this.localFileProtocolManager = new LocalFileProtocolManager();
void this.localFileProtocolManager.approveWorkspaceRoots(
this.storeManager.get('localFileWorkspaceRoots', []),
);
protocol.registerSchemesAsPrivileged([
{
privileges: {
allowServiceWorkers: true,
corsEnabled: true,
secure: true,
standard: true,
supportFetchAPI: true,
},
scheme: ELECTRON_BE_PROTOCOL_SCHEME,
},
this.rendererUrlManager.protocolScheme,
this.localFileProtocolManager.protocolScheme,
]);
@@ -427,6 +431,7 @@ export class App {
if (!isDev) return;
logger.debug('Setting up dev branding');
app.setName('lobehub-desktop-dev');
if (electronIs.macOS()) {
app.dock!.setIcon(path.join(buildDir, 'icon-dev.png'));
}
@@ -8,6 +8,7 @@ import { app, BrowserWindow, ipcMain, screen, session as electronSession, shell
import { preloadDir, resourcesDir } from '@/const/dir';
import { isMac } from '@/const/env';
import { ELECTRON_BE_PROTOCOL_SCHEME } from '@/const/protocol';
import RemoteServerConfigCtr from '@/controllers/RemoteServerConfigCtr';
import { backendProxyProtocolManager } from '@/core/infrastructure/BackendProxyProtocolManager';
import { appendVercelCookie, setResponseHeader } from '@/utils/http-headers';
@@ -560,10 +561,7 @@ export default class Browser {
}
/**
* Bind this window's session to the backend proxy. The `app://` request
* interceptor (wired in `App.ts`) consumes this context to route
* `/trpc`, `/webapi`, `/api/auth`, and `/market` requests to the remote
* LobeHub server.
* Rewrite tRPC requests to remote server and inject OIDC token
*/
private setupRemoteServerRequestHook(browserWindow: BrowserWindow): void {
const session = browserWindow.webContents.session;
@@ -579,6 +577,7 @@ export default class Browser {
const remoteServerUrl = await remoteServerConfigCtr.getRemoteServerUrl(config);
return remoteServerUrl || null;
},
scheme: ELECTRON_BE_PROTOCOL_SCHEME,
source: this.identifier,
});
}
@@ -1,36 +1,44 @@
import { AUTH_REQUIRED_HEADER } from '@lobechat/desktop-bridge';
import { BrowserWindow, type Session, session as electronSession } from 'electron';
import { BrowserWindow, type Session } from 'electron';
import { isDev } from '@/const/env';
import { isBackendPath } from '@/const/protocol';
import { appendVercelCookie } from '@/utils/http-headers';
import { createLogger } from '@/utils/logger';
import { netFetch } from '@/utils/net-fetch';
import type { RendererRequestInterceptor } from './RendererProtocolManager';
interface BackendProxyContext {
interface BackendProxyProtocolManagerOptions {
getAccessToken: () => Promise<string | undefined | null>;
rewriteUrl: (rawUrl: string) => Promise<string | null>;
scheme: string;
/**
* Used for log prefixes. e.g. window identifier
*/
source?: string;
}
interface BackendProxyRemoteBaseOptions {
interface BackendProxyProtocolManagerRemoteBaseOptions {
getAccessToken: () => Promise<string | undefined | null>;
getRemoteBaseUrl: () => Promise<string | undefined | null>;
scheme: string;
/**
* Used for log prefixes. e.g. window identifier
*/
source?: string;
}
/**
* Holds per-session proxy context for routing renderer-originated backend
* requests (`/trpc`, `/webapi`, `/api/auth`, `/market`) to the remote LobeHub
* server. The context is consumed by `createAppRequestInterceptor`, which the
* `app://` protocol manager invokes before its static / Vite fallback.
* Manage `lobe-backend://` (or any custom scheme) transparent proxy handler registration.
* Keeps a WeakSet per session to avoid duplicate handler registration.
*/
export class BackendProxyProtocolManager {
private readonly contexts = new WeakMap<Session, BackendProxyContext>();
private readonly handledSessions = new WeakSet<Session>();
private readonly logger = createLogger('core:BackendProxyProtocolManager');
/**
* Debounce timer for authorization required notifications.
* Prevents multiple rapid 401 responses from triggering duplicate notifications.
*/
private authRequiredDebounceTimer: NodeJS.Timeout | null = null;
private static readonly AUTH_REQUIRED_DEBOUNCE_MS = 1000;
@@ -53,12 +61,10 @@ export class BackendProxyProtocolManager {
}, BackendProxyProtocolManager.AUTH_REQUIRED_DEBOUNCE_MS);
}
/**
* Bind a session's proxy context using a remote-base-URL provider. Backend
* paths get rewritten onto the remote base; same-origin requests pass through
* (returns null so the `app://` handler falls back to its static / Vite path).
*/
registerWithRemoteBaseUrl(session: Session, options: BackendProxyRemoteBaseOptions) {
registerWithRemoteBaseUrl(
session: Session,
options: BackendProxyProtocolManagerRemoteBaseOptions,
) {
let lastRemoteBaseUrl: string | undefined;
const rewriteUrl = async (rawUrl: string) => {
@@ -93,117 +99,90 @@ export class BackendProxyProtocolManager {
this.register(session, {
getAccessToken: options.getAccessToken,
rewriteUrl,
scheme: options.scheme,
source: options.source,
});
}
/**
* Bind a session's proxy context. Subsequent backend-path requests on this
* session will be rewritten via `rewriteUrl` and have `Oidc-Auth` injected.
*/
register(session: Session, context: BackendProxyContext) {
if (!session) return;
this.contexts.set(session, context);
}
register(session: Session, options: BackendProxyProtocolManagerOptions) {
if (!session || this.handledSessions.has(session)) return;
/**
* Build an `app://` request interceptor that diverts backend-prefixed paths
* (trpc / webapi / api/auth / market) through `proxy()` against the default
* session. Plug into `RendererProtocolManager.addRequestInterceptor` so the
* protocol manager doesn't need to know what "backend" means.
*
* Returns `null` for non-backend paths (lets the fallback run). Returns a
* 502 if the backend context isn't wired up yet for backend prefixes we
* must never fall through to the SPA HTML / Vite path.
*/
createAppRequestInterceptor(): RendererRequestInterceptor {
return async (request) => {
const url = new URL(request.url);
if (!isBackendPath(url.pathname)) return null;
const logPrefix = options.source ? `[${options.source}] BackendProxy` : '[BackendProxy]';
const session = electronSession.defaultSession;
if (!session) return new Response('Backend Proxy Unavailable', { status: 502 });
session.protocol.handle(options.scheme, async (request: Request): Promise<Response | null> => {
try {
const rewrittenUrl = await options.rewriteUrl(request.url);
if (!rewrittenUrl) return null;
const proxied = await this.proxy(request, session);
return proxied ?? new Response('Backend Proxy Unavailable', { status: 502 });
};
}
const headers = new Headers(request.headers);
const token = await options.getAccessToken();
if (token) {
headers.set('Oidc-Auth', token);
}
appendVercelCookie(headers);
/**
* Proxy a renderer-originated request through the remote LobeHub backend.
* Returns `null` if the session has no proxy context registered yet (caller
* decides how to fall back). Throws on upstream fetch failure to mirror the
* original `protocol.handle` semantics.
*/
async proxy(request: Request, session: Session): Promise<Response | null> {
const context = this.contexts.get(session);
if (!context) return null;
const requestInit: RequestInit & { duplex?: 'half' } = {
headers,
method: request.method,
};
const logPrefix = context.source ? `[${context.source}] BackendProxy` : '[BackendProxy]';
// Only forward body for non-GET/HEAD requests
if (request.method !== 'GET' && request.method !== 'HEAD') {
const body = request.body ?? undefined;
if (body) {
requestInit.body = body;
// Node.js (undici) requires `duplex` when sending a streaming body
requestInit.duplex = 'half';
}
}
const rewrittenUrl = await context.rewriteUrl(request.url);
if (!rewrittenUrl) return null;
let upstreamResponse: Response;
try {
upstreamResponse = await netFetch(rewrittenUrl, requestInit);
} catch (error) {
this.logger.error(`${logPrefix} upstream fetch failed: ${rewrittenUrl}`, error);
const headers = new Headers(request.headers);
const token = await context.getAccessToken();
if (token) {
headers.set('Oidc-Auth', token);
}
appendVercelCookie(headers);
throw error;
}
const requestInit: RequestInit & { duplex?: 'half' } = {
headers,
method: request.method,
};
const responseHeaders = new Headers(upstreamResponse.headers);
const allowOrigin = request.headers.get('Origin') || undefined;
// Only forward body for non-GET/HEAD requests
if (request.method !== 'GET' && request.method !== 'HEAD') {
const body = request.body ?? undefined;
if (body) {
requestInit.body = body;
// Node.js (undici) requires `duplex` when sending a streaming body
requestInit.duplex = 'half';
if (allowOrigin) {
responseHeaders.set('Access-Control-Allow-Origin', allowOrigin);
responseHeaders.set('Access-Control-Allow-Credentials', 'true');
}
if (isDev) {
responseHeaders.set('x-dev-oidc-auth', token);
}
responseHeaders.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
responseHeaders.set('Access-Control-Allow-Headers', '*');
responseHeaders.set('X-Src-Url', rewrittenUrl);
// Re-auth prompt: rely on X-Auth-Required (set by tRPC responseMeta for UNAUTHORIZED).
// Batched tRPC responses can use HTTP 207 when calls mix success (200) and UNAUTHORIZED (401);
// checking only status === 401 misses that case and the login modal never opens.
// Other failures keep 401 without this header (e.g., invalid API keys) and must not notify here.
const authRequired = upstreamResponse.headers.get(AUTH_REQUIRED_HEADER) === 'true';
if (authRequired) {
this.notifyAuthorizationRequired();
}
return new Response(upstreamResponse.body, {
headers: responseHeaders,
status: upstreamResponse.status,
statusText: upstreamResponse.statusText,
});
} catch (error) {
this.logger.error(`${logPrefix} protocol.handle error:`, error);
throw error;
}
}
let upstreamResponse: Response;
try {
upstreamResponse = await netFetch(rewrittenUrl, requestInit);
} catch (error) {
this.logger.error(`${logPrefix} upstream fetch failed: ${rewrittenUrl}`, error);
throw error;
}
const responseHeaders = new Headers(upstreamResponse.headers);
const allowOrigin = request.headers.get('Origin') || undefined;
if (allowOrigin) {
responseHeaders.set('Access-Control-Allow-Origin', allowOrigin);
responseHeaders.set('Access-Control-Allow-Credentials', 'true');
}
if (isDev) {
responseHeaders.set('x-dev-oidc-auth', token);
}
responseHeaders.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
responseHeaders.set('Access-Control-Allow-Headers', '*');
responseHeaders.set('X-Src-Url', rewrittenUrl);
// Re-auth prompt: rely on X-Auth-Required (set by tRPC responseMeta for UNAUTHORIZED).
// Batched tRPC responses can use HTTP 207 when calls mix success (200) and UNAUTHORIZED (401);
// checking only status === 401 misses that case and the login modal never opens.
// Other failures keep 401 without this header (e.g., invalid API keys) and must not notify here.
const authRequired = upstreamResponse.headers.get(AUTH_REQUIRED_HEADER) === 'true';
if (authRequired) {
this.notifyAuthorizationRequired();
}
return new Response(upstreamResponse.body, {
headers: responseHeaders,
status: upstreamResponse.status,
statusText: upstreamResponse.statusText,
});
this.logger.debug(`${logPrefix} protocol handler registered for ${options.scheme}`);
this.handledSessions.add(session);
}
}
@@ -10,61 +10,41 @@ import { getExportMimeType } from '../../utils/mime';
type ResolveRendererFilePath = (url: URL) => Promise<string | null>;
/**
* Request interceptor: inspects an `app://` request and either produces a Response
* (short-circuits the pipeline) or returns `null` to let the next interceptor and
* ultimately the fallback strategy try.
*/
export type RendererRequestInterceptor = (request: Request) => Promise<Response | null>;
/**
* Fallback strategy invoked when no interceptor handled the request. Static
* (production) and Vite-proxy (development) implementations live below; the
* protocol manager is agnostic to which one is plugged in.
*/
export interface RendererFallbackStrategy {
handle: (request: Request, url: URL) => Promise<Response>;
}
const RENDERER_PROTOCOL_PRIVILEGES = {
allowServiceWorkers: true,
corsEnabled: true,
secure: true,
standard: true,
stream: true,
supportFetchAPI: true,
} as const;
interface RendererProtocolManagerOptions {
fallback: RendererFallbackStrategy;
host?: string;
rendererDir: string;
resolveRendererFilePath: ResolveRendererFilePath;
scheme?: string;
}
const RENDERER_DIR = 'renderer';
export class RendererProtocolManager {
private readonly scheme: string;
private readonly host: string;
private readonly fallback: RendererFallbackStrategy;
private readonly interceptors: RendererRequestInterceptor[] = [];
private readonly rendererDir: string;
private readonly resolveRendererFilePath: ResolveRendererFilePath;
private handlerRegistered = false;
constructor(options: RendererProtocolManagerOptions) {
this.scheme = options.scheme ?? 'app';
this.host = options.host ?? RENDERER_DIR;
this.fallback = options.fallback;
const { rendererDir, resolveRendererFilePath } = options;
this.scheme = 'app';
this.host = RENDERER_DIR;
this.rendererDir = rendererDir;
this.resolveRendererFilePath = resolveRendererFilePath;
}
/**
* Register a request interceptor that runs before the fallback strategy.
* Interceptors are invoked in registration order; the first one to return a
* non-null Response short-circuits the pipeline.
* Get the full renderer URL with scheme and host
*/
addRequestInterceptor(interceptor: RendererRequestInterceptor) {
this.interceptors.push(interceptor);
}
getRendererUrl(): string {
return `${this.scheme}://${this.host}`;
}
@@ -75,30 +55,169 @@ export class RendererProtocolManager {
scheme: this.scheme,
};
}
registerHandler() {
if (this.handlerRegistered) return;
if (!pathExistsSync(this.rendererDir)) {
createLogger('core:RendererProtocolManager').warn(
`Renderer directory not found, skip static handler: ${this.rendererDir}`,
);
return;
}
const logger = createLogger('core:RendererProtocolManager');
logger.debug(`Registering ${this.scheme}:// handler for host ${this.host}`);
logger.debug(
`Registering renderer ${this.scheme}:// handler for production export at host ${this.host}`,
);
const register = () => {
if (this.handlerRegistered) return;
protocol.handle(this.scheme, async (request) => {
const url = new URL(request.url);
const hostname = url.hostname;
const pathname = url.pathname;
const isAssetRequest = this.isAssetRequest(pathname);
const isExplicit404HtmlRequest = pathname.endsWith('/404.html');
if (url.hostname !== this.host) {
if (hostname !== this.host) {
return new Response('Not Found', { status: 404 });
}
// Pipeline: first interceptor to return a Response wins; null = pass through.
for (const interceptor of this.interceptors) {
const response = await interceptor(request);
if (response) return response;
const buildFileResponse = async (targetPath: string) => {
const fileStat = await stat(targetPath);
const totalSize = fileStat.size;
const buffer = await readFile(targetPath);
const headers = new Headers();
const mimeType = getExportMimeType(targetPath);
if (mimeType) headers.set('Content-Type', mimeType);
// Chromium media pipeline relies on byte ranges for video/audio.
headers.set('Accept-Ranges', 'bytes');
const method = request.method?.toUpperCase?.() || 'GET';
const rangeHeader = request.headers.get('range') || request.headers.get('Range');
// HEAD (no range): return only headers
if (method === 'HEAD' && !rangeHeader) {
headers.set('Content-Length', String(totalSize));
return new Response(null, { headers, status: 200 });
}
// No Range: return entire file
if (!rangeHeader) {
headers.set('Content-Length', String(buffer.byteLength));
return new Response(buffer, { headers, status: 200 });
}
// Range: bytes=start-end | bytes=-suffixLength
const match = /^bytes=(\d*)-(\d*)$/i.exec(rangeHeader.trim());
if (!match) {
headers.set('Content-Range', `bytes */${totalSize}`);
return new Response(null, {
headers,
status: 416,
statusText: 'Range Not Satisfiable',
});
}
const [, startRaw, endRaw] = match;
let start = startRaw ? Number(startRaw) : NaN;
let end = endRaw ? Number(endRaw) : NaN;
// Suffix range: bytes=-N (last N bytes)
if (!startRaw && endRaw) {
const suffixLength = Number(endRaw);
if (!Number.isFinite(suffixLength) || suffixLength <= 0) {
headers.set('Content-Range', `bytes */${totalSize}`);
return new Response(null, {
headers,
status: 416,
statusText: 'Range Not Satisfiable',
});
}
start = Math.max(totalSize - suffixLength, 0);
end = totalSize - 1;
} else {
if (!Number.isFinite(start)) start = 0;
if (!Number.isFinite(end)) end = totalSize - 1;
}
if (start < 0 || end < 0 || start > end || start >= totalSize) {
headers.set('Content-Range', `bytes */${totalSize}`);
return new Response(null, {
headers,
status: 416,
statusText: 'Range Not Satisfiable',
});
}
end = Math.min(end, totalSize - 1);
const sliced = buffer.subarray(start, end + 1);
headers.set('Content-Range', `bytes ${start}-${end}/${totalSize}`);
headers.set('Content-Length', String(sliced.byteLength));
if (method === 'HEAD') {
return new Response(null, { headers, status: 206, statusText: 'Partial Content' });
}
return new Response(sliced, { headers, status: 206, statusText: 'Partial Content' });
};
const resolveEntryFilePath = () =>
this.resolveRendererFilePath(new URL(`${this.scheme}://${this.host}/`));
let filePath = await this.resolveRendererFilePath(url);
// If the resolved file is the export 404 page, treat it as missing so we can
// fall back to the entry HTML for SPA routing (unless explicitly requested).
if (filePath && this.is404Html(filePath) && !isExplicit404HtmlRequest) {
filePath = null;
}
return this.fallback.handle(request, url);
if (!filePath) {
if (isAssetRequest) {
return new Response('File Not Found', { status: 404 });
}
// Fallback to entry HTML for unknown routes (SPA-like behavior)
filePath = await resolveEntryFilePath();
if (!filePath || this.is404Html(filePath)) {
return new Response('Render file Not Found', { status: 404 });
}
}
try {
return await buildFileResponse(filePath);
} catch (error) {
const code = (error as any).code;
if (code === 'ENOENT') {
logger.warn(`Export asset missing on disk ${filePath}, falling back`, error);
if (isAssetRequest) {
return new Response('File Not Found', { status: 404 });
}
const fallbackPath = await resolveEntryFilePath();
if (!fallbackPath || this.is404Html(fallbackPath)) {
return new Response('Render file Not Found', { status: 404 });
}
try {
return await buildFileResponse(fallbackPath);
} catch (fallbackError) {
logger.error(`Failed to serve fallback entry ${fallbackPath}:`, fallbackError);
return new Response('Internal Server Error', { status: 500 });
}
}
logger.error(`Failed to serve export asset ${filePath}:`, error);
return new Response('Internal Server Error', { status: 500 });
}
});
this.handlerRegistered = true;
@@ -108,165 +227,10 @@ export class RendererProtocolManager {
register();
} else {
// protocol.handle needs the default session, which is only available after ready
app.whenReady().then(register);
}
}
}
/**
* Production fallback: serve the renderer's static export from disk. Resolves
* the file via `resolveRendererFilePath`, falls back to the SPA entry HTML for
* unknown routes, and supports HTTP `Range` requests for media playback.
*/
export class StaticRendererFallback implements RendererFallbackStrategy {
private readonly rendererDir: string;
private readonly resolveRendererFilePath: ResolveRendererFilePath;
private readonly logger = createLogger('core:StaticRendererFallback');
constructor(rendererDir: string, resolveRendererFilePath: ResolveRendererFilePath) {
this.rendererDir = rendererDir;
this.resolveRendererFilePath = resolveRendererFilePath;
if (!pathExistsSync(this.rendererDir)) {
this.logger.warn(`Renderer directory not found: ${this.rendererDir}`);
}
}
async handle(request: Request, url: URL): Promise<Response> {
const pathname = url.pathname;
const isAssetRequest = this.isAssetRequest(pathname);
const isExplicit404HtmlRequest = pathname.endsWith('/404.html');
let filePath = await this.resolveRendererFilePath(url);
if (filePath && this.is404Html(filePath) && !isExplicit404HtmlRequest) {
filePath = null;
}
if (!filePath) {
if (isAssetRequest) {
return new Response('File Not Found', { status: 404 });
}
filePath = await this.resolveEntryFilePath(url);
if (!filePath || this.is404Html(filePath)) {
return new Response('Render file Not Found', { status: 404 });
}
}
try {
return await this.buildFileResponse(request, filePath);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
this.logger.warn(`Export asset missing on disk ${filePath}, falling back`, error);
if (isAssetRequest) {
return new Response('File Not Found', { status: 404 });
}
const fallbackPath = await this.resolveEntryFilePath(url);
if (!fallbackPath || this.is404Html(fallbackPath)) {
return new Response('Render file Not Found', { status: 404 });
}
try {
return await this.buildFileResponse(request, fallbackPath);
} catch (fallbackError) {
this.logger.error(`Failed to serve fallback entry ${fallbackPath}:`, fallbackError);
return new Response('Internal Server Error', { status: 500 });
}
}
this.logger.error(`Failed to serve export asset ${filePath}:`, error);
return new Response('Internal Server Error', { status: 500 });
}
}
private resolveEntryFilePath(url: URL) {
return this.resolveRendererFilePath(new URL(`${url.protocol}//${url.host}/`));
}
private async buildFileResponse(request: Request, targetPath: string): Promise<Response> {
const fileStat = await stat(targetPath);
const totalSize = fileStat.size;
const buffer = await readFile(targetPath);
const headers = new Headers();
const mimeType = getExportMimeType(targetPath);
if (mimeType) headers.set('Content-Type', mimeType);
// Chromium media pipeline relies on byte ranges for video/audio.
headers.set('Accept-Ranges', 'bytes');
const method = request.method?.toUpperCase?.() || 'GET';
const rangeHeader = request.headers.get('range') || request.headers.get('Range');
if (method === 'HEAD' && !rangeHeader) {
headers.set('Content-Length', String(totalSize));
return new Response(null, { headers, status: 200 });
}
if (!rangeHeader) {
headers.set('Content-Length', String(buffer.byteLength));
return new Response(buffer, { headers, status: 200 });
}
const match = /^bytes=(\d*)-(\d*)$/i.exec(rangeHeader.trim());
if (!match) {
headers.set('Content-Range', `bytes */${totalSize}`);
return new Response(null, {
headers,
status: 416,
statusText: 'Range Not Satisfiable',
});
}
const [, startRaw, endRaw] = match;
let start = startRaw ? Number(startRaw) : Number.NaN;
let end = endRaw ? Number(endRaw) : Number.NaN;
// Suffix range: bytes=-N (last N bytes)
if (!startRaw && endRaw) {
const suffixLength = Number(endRaw);
if (!Number.isFinite(suffixLength) || suffixLength <= 0) {
headers.set('Content-Range', `bytes */${totalSize}`);
return new Response(null, {
headers,
status: 416,
statusText: 'Range Not Satisfiable',
});
}
start = Math.max(totalSize - suffixLength, 0);
end = totalSize - 1;
} else {
if (!Number.isFinite(start)) start = 0;
if (!Number.isFinite(end)) end = totalSize - 1;
}
if (start < 0 || end < 0 || start > end || start >= totalSize) {
headers.set('Content-Range', `bytes */${totalSize}`);
return new Response(null, {
headers,
status: 416,
statusText: 'Range Not Satisfiable',
});
}
end = Math.min(end, totalSize - 1);
const sliced = buffer.subarray(start, end + 1);
headers.set('Content-Range', `bytes ${start}-${end}/${totalSize}`);
headers.set('Content-Length', String(sliced.byteLength));
if (method === 'HEAD') {
return new Response(null, { headers, status: 206, statusText: 'Partial Content' });
}
return new Response(sliced, { headers, status: 206, statusText: 'Partial Content' });
}
private isAssetRequest(pathname: string) {
const normalizedPathname = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
@@ -285,82 +249,3 @@ export class StaticRendererFallback implements RendererFallbackStrategy {
return path.basename(filePath) === '404.html';
}
}
class Semaphore {
private active = 0;
private readonly waiters: Array<() => void> = [];
constructor(private readonly max: number) {}
async acquire(): Promise<() => void> {
if (this.active >= this.max) {
await new Promise<void>((resolve) => this.waiters.push(resolve));
}
this.active += 1;
let released = false;
return () => {
if (released) return;
released = true;
this.active -= 1;
this.waiters.shift()?.();
};
}
}
const VITE_FETCH_CONCURRENCY = 64;
export class ViteRendererFallback implements RendererFallbackStrategy {
private readonly viteOrigin: string;
private readonly logger = createLogger('core:ViteRendererFallback');
private readonly gate = new Semaphore(VITE_FETCH_CONCURRENCY);
constructor(viteOrigin: string) {
this.viteOrigin = viteOrigin.replace(/\/+$/, '');
}
async handle(request: Request, url: URL): Promise<Response> {
const target = `${this.viteOrigin}${url.pathname}${url.search}`;
// Strip Host so fetch derives it from the target URL (otherwise Vite
// sees `Host: renderer` and middleware that keys off Host can misbehave).
const headers = new Headers(request.headers);
headers.delete('host');
const init: RequestInit & { duplex?: 'half' } = {
headers,
method: request.method,
};
if (request.method !== 'GET' && request.method !== 'HEAD' && request.body) {
init.body = request.body;
init.duplex = 'half';
}
const release = await this.gate.acquire();
try {
const response = await fetch(target, init);
return this.releaseOnBodyDone(response, release);
} catch (error) {
release();
this.logger.error(`Vite dev server fetch failed: ${target}`, error);
return new Response('Vite Dev Server Unavailable', { status: 502 });
}
}
private releaseOnBodyDone(response: Response, release: () => void): Response {
if (!response.body) {
release();
return response;
}
const passthrough = new TransformStream();
void response.body.pipeTo(passthrough.writable).then(release, release);
return new Response(passthrough.readable, {
headers: response.headers,
status: response.status,
statusText: response.statusText,
});
}
}
@@ -7,12 +7,7 @@ import { isDev } from '@/const/env';
import { getDesktopEnv } from '@/env';
import { createLogger } from '@/utils/logger';
import {
RendererProtocolManager,
type RendererRequestInterceptor,
StaticRendererFallback,
ViteRendererFallback,
} from './RendererProtocolManager';
import { RendererProtocolManager } from './RendererProtocolManager';
const logger = createLogger('core:RendererUrlManager');
@@ -25,11 +20,12 @@ const POPUP_ENTRY_HTML = path.join(rendererDir, 'apps', 'desktop', 'popup.html')
export class RendererUrlManager {
private readonly rendererProtocolManager: RendererProtocolManager;
private readonly rendererStaticOverride = getDesktopEnv().DESKTOP_RENDERER_STATIC;
private readonly rendererLoadedUrl: string;
private rendererLoadedUrl: string;
constructor() {
this.rendererProtocolManager = new RendererProtocolManager({
fallback: this.pickFallback(),
rendererDir,
resolveRendererFilePath: this.resolveRendererFilePath,
});
this.rendererLoadedUrl = this.rendererProtocolManager.getRendererUrl();
@@ -39,22 +35,31 @@ export class RendererUrlManager {
return this.rendererProtocolManager.protocolScheme;
}
addRequestInterceptor(interceptor: RendererRequestInterceptor) {
this.rendererProtocolManager.addRequestInterceptor(interceptor);
}
/**
* Register the `app://` protocol handler. Idempotent safe to call after
* interceptors are wired.
* Configure renderer loading strategy for dev/prod
*/
configureRendererLoader() {
this.rendererProtocolManager.registerHandler();
const electronRendererUrl = process.env['ELECTRON_RENDERER_URL'];
if (isDev && !this.rendererStaticOverride && electronRendererUrl) {
this.rendererLoadedUrl = electronRendererUrl;
this.setupDevRenderer();
return;
}
if (isDev && !this.rendererStaticOverride && !electronRendererUrl) {
logger.warn('Dev mode: ELECTRON_RENDERER_URL not set, falling back to protocol handler');
}
if (isDev && this.rendererStaticOverride) {
logger.warn('Dev mode: DESKTOP_RENDERER_STATIC enabled, using static renderer handler');
}
this.setupProdRenderer();
}
/**
* Build a renderer URL. Always uses `app://renderer` so dev and prod share
* the same origin (cookies, storage, service-workers). Dev requests are
* proxied to the Vite dev server inside the `app://` handler.
* Build renderer URL for dev/prod.
*/
buildRendererUrl(path: string): string {
const cleanPath = path.startsWith('/') ? path : `/${path}`;
@@ -64,10 +69,7 @@ export class RendererUrlManager {
}
/**
* Resolve a renderer file path against the static export. Used by the
* production fallback; left on the manager so the desktop-specific entry
* HTML mappings stay in one place.
*
* 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).
*/
@@ -94,26 +96,20 @@ export class RendererUrlManager {
return SPA_ENTRY_HTML;
};
private pickFallback() {
const electronRendererUrl = process.env['ELECTRON_RENDERER_URL'];
/**
* Development: use electron-vite renderer dev server
*/
private setupDevRenderer() {
logger.info(
`Development mode: renderer served from electron-vite dev server at ${this.rendererLoadedUrl}`,
);
}
if (isDev && !this.rendererStaticOverride && electronRendererUrl) {
logger.info(
`Development mode: app:// requests proxied to Vite dev server at ${electronRendererUrl}`,
);
return new ViteRendererFallback(electronRendererUrl);
}
if (isDev && !this.rendererStaticOverride && !electronRendererUrl) {
logger.warn(
'Dev mode: ELECTRON_RENDERER_URL not set, falling back to static renderer handler',
);
}
if (isDev && this.rendererStaticOverride) {
logger.warn('Dev mode: DESKTOP_RENDERER_STATIC enabled, using static renderer handler');
}
return new StaticRendererFallback(rendererDir, this.resolveRendererFilePath);
/**
* Production: serve static renderer assets via protocol handler
*/
private setupProdRenderer() {
this.rendererLoadedUrl = this.rendererProtocolManager.getRendererUrl();
this.rendererProtocolManager.registerHandler();
}
}
@@ -1,5 +1,5 @@
import { AUTH_REQUIRED_HEADER } from '@lobechat/desktop-bridge';
import { BrowserWindow, session as electronSession } from 'electron';
import { BrowserWindow } from 'electron';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { BackendProxyProtocolManager } from '../BackendProxyProtocolManager';
@@ -10,6 +10,19 @@ interface RequestInitWithDuplex extends RequestInit {
type FetchMock = (input: RequestInfo | URL, init?: RequestInitWithDuplex) => Promise<Response>;
const { mockProtocol, protocolHandlerRef } = vi.hoisted(() => {
const protocolHandlerRef = { current: null as any };
return {
mockProtocol: {
handle: vi.fn((_scheme: string, handler: any) => {
protocolHandlerRef.current = handler;
}),
},
protocolHandlerRef,
};
});
vi.mock('electron-is', () => ({
dev: vi.fn(() => false),
macOS: vi.fn(() => false),
@@ -35,23 +48,21 @@ vi.mock('electron', () => ({
global.fetch(input as any, init as any),
),
},
session: {
defaultSession: {},
},
}));
describe('BackendProxyProtocolManager', () => {
beforeEach(() => {
vi.clearAllMocks();
protocolHandlerRef.current = null;
});
afterEach(() => {
vi.useRealTimers();
});
it('rewrites url to remote base and injects Oidc-Auth via proxy()', async () => {
it('should rewrite url to remote base and inject Oidc-Auth token', async () => {
const manager = new BackendProxyProtocolManager();
const session = {} as any;
const session = { protocol: mockProtocol } as any;
const fetchMock = vi.fn<FetchMock>(async () => {
return new Response('ok', {
@@ -65,19 +76,19 @@ describe('BackendProxyProtocolManager', () => {
manager.registerWithRemoteBaseUrl(session, {
getAccessToken: async () => 'token-123',
getRemoteBaseUrl: async () => 'https://remote.example.com',
scheme: 'lobe-backend',
source: 'main',
});
const response = await manager.proxy(
{
headers: new Headers({ 'Origin': 'app://renderer', 'X-Test': '1' }),
method: 'GET',
url: 'app://renderer/trpc/hello?batch=1',
} as any,
session,
);
const handler = protocolHandlerRef.current;
expect(mockProtocol.handle).toHaveBeenCalledWith('lobe-backend', expect.any(Function));
const response = await handler({
headers: new Headers({ 'Origin': 'app://desktop', 'X-Test': '1' }),
method: 'GET',
url: 'lobe-backend://app/trpc/hello?batch=1',
} as any);
expect(response).not.toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(1);
const [calledUrl, init] = fetchMock.mock.calls[0]!;
expect(calledUrl).toBe('https://remote.example.com/trpc/hello?batch=1');
@@ -89,18 +100,16 @@ describe('BackendProxyProtocolManager', () => {
expect(headers.get('Oidc-Auth')).toBe('token-123');
expect(headers.get('X-Test')).toBe('1');
expect(response!.status).toBe(200);
expect(response!.headers.get('X-Src-Url')).toBe(
'https://remote.example.com/trpc/hello?batch=1',
);
expect(response!.headers.get('Access-Control-Allow-Origin')).toBe('app://renderer');
expect(response!.headers.get('Access-Control-Allow-Credentials')).toBe('true');
expect(await response!.text()).toBe('ok');
expect(response.status).toBe(200);
expect(response.headers.get('X-Src-Url')).toBe('https://remote.example.com/trpc/hello?batch=1');
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('app://desktop');
expect(response.headers.get('Access-Control-Allow-Credentials')).toBe('true');
expect(await response.text()).toBe('ok');
});
it('forwards body and sets duplex for non-GET requests', async () => {
it('should forward body and set duplex for non-GET requests', async () => {
const manager = new BackendProxyProtocolManager();
const session = {} as any;
const session = { protocol: mockProtocol } as any;
const fetchMock = vi.fn<FetchMock>(async () => new Response('ok', { status: 200 }));
vi.stubGlobal('fetch', fetchMock as any);
@@ -108,18 +117,18 @@ describe('BackendProxyProtocolManager', () => {
manager.registerWithRemoteBaseUrl(session, {
getAccessToken: async () => null,
getRemoteBaseUrl: async () => 'https://remote.example.com',
scheme: 'lobe-backend',
});
await manager.proxy(
{
headers: new Headers(),
method: 'POST',
// body doesn't have to be a real stream for this unit test; manager only checks truthiness
body: 'payload' as any,
url: 'app://renderer/api/upload',
} as any,
session,
);
const handler = protocolHandlerRef.current;
await handler({
headers: new Headers(),
method: 'POST',
// body doesn't have to be a real stream for this unit test; manager only checks truthiness
body: 'payload' as any,
url: 'lobe-backend://app/api/upload',
} as any);
const [, init] = fetchMock.mock.calls[0]!;
expect(init).toBeDefined();
@@ -130,9 +139,9 @@ describe('BackendProxyProtocolManager', () => {
expect(init.duplex).toBe('half');
});
it('returns null when remote base url is missing', async () => {
it('should return null when remote base url is missing', async () => {
const manager = new BackendProxyProtocolManager();
const session = {} as any;
const session = { protocol: mockProtocol } as any;
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock as any);
@@ -140,20 +149,19 @@ describe('BackendProxyProtocolManager', () => {
manager.registerWithRemoteBaseUrl(session, {
getAccessToken: async () => 'token',
getRemoteBaseUrl: async () => null,
scheme: 'lobe-backend',
});
const res = await manager.proxy(
{ method: 'GET', headers: new Headers(), url: 'app://renderer/trpc' } as any,
session,
);
const handler = protocolHandlerRef.current;
const res = await handler({ method: 'GET', url: 'lobe-backend://app/trpc' } as any);
expect(res).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it('returns null when request url is already the remote origin', async () => {
it('should return null when request url is already the remote origin', async () => {
const manager = new BackendProxyProtocolManager();
const session = {} as any;
const session = { protocol: mockProtocol } as any;
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock as any);
@@ -161,24 +169,22 @@ describe('BackendProxyProtocolManager', () => {
manager.registerWithRemoteBaseUrl(session, {
getAccessToken: async () => null,
getRemoteBaseUrl: async () => 'https://remote.example.com',
scheme: 'lobe-backend',
});
const res = await manager.proxy(
{
method: 'GET',
headers: new Headers(),
url: 'https://remote.example.com/trpc/hello?x=1',
} as any,
session,
);
const handler = protocolHandlerRef.current;
const res = await handler({
method: 'GET',
url: 'https://remote.example.com/trpc/hello?x=1',
} as any);
expect(res).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it('returns null when rewrite fails (invalid remote base url)', async () => {
it('should return null when rewrite fails (invalid remote base url)', async () => {
const manager = new BackendProxyProtocolManager();
const session = {} as any;
const session = { protocol: mockProtocol } as any;
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock as any);
@@ -186,20 +192,19 @@ describe('BackendProxyProtocolManager', () => {
manager.registerWithRemoteBaseUrl(session, {
getAccessToken: async () => null,
getRemoteBaseUrl: async () => 'not-a-url',
scheme: 'lobe-backend',
});
const res = await manager.proxy(
{ method: 'GET', headers: new Headers(), url: 'app://renderer/trpc' } as any,
session,
);
const handler = protocolHandlerRef.current;
const res = await handler({ method: 'GET', url: 'lobe-backend://app/trpc' } as any);
expect(res).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it('throws when upstream fetch throws', async () => {
it('should throw when upstream fetch throws', async () => {
const manager = new BackendProxyProtocolManager();
const session = {} as any;
const session = { protocol: mockProtocol } as any;
const fetchMock = vi.fn(async () => {
throw new Error('network down');
@@ -209,21 +214,20 @@ describe('BackendProxyProtocolManager', () => {
manager.registerWithRemoteBaseUrl(session, {
getAccessToken: async () => null,
getRemoteBaseUrl: async () => 'https://remote.example.com',
scheme: 'lobe-backend',
});
const handler = protocolHandlerRef.current;
await expect(
manager.proxy(
{
headers: new Headers(),
method: 'GET',
url: 'app://renderer/trpc/hello',
} as any,
session,
),
handler({
headers: new Headers(),
method: 'GET',
url: 'lobe-backend://app/trpc/hello',
} as any),
).rejects.toThrow('network down');
});
it('broadcasts authorizationRequired when X-Auth-Required is set on HTTP 207 (batched tRPC)', async () => {
it('should broadcast authorizationRequired when X-Auth-Required is set on HTTP 207 (batched tRPC)', async () => {
vi.useFakeTimers();
const send = vi.fn();
vi.mocked(BrowserWindow.getAllWindows).mockReturnValue([
@@ -231,7 +235,7 @@ describe('BackendProxyProtocolManager', () => {
] as any);
const manager = new BackendProxyProtocolManager();
const session = {} as any;
const session = { protocol: mockProtocol } as any;
const headers = new Headers({
[AUTH_REQUIRED_HEADER]: 'true',
@@ -245,77 +249,18 @@ describe('BackendProxyProtocolManager', () => {
manager.registerWithRemoteBaseUrl(session, {
getAccessToken: async () => null,
getRemoteBaseUrl: async () => 'https://remote.example.com',
scheme: 'lobe-backend',
});
await manager.proxy(
{
headers: new Headers(),
method: 'GET',
url: 'app://renderer/trpc/lambda/batch?batch=1',
} as any,
session,
);
const handler = protocolHandlerRef.current;
await handler({
headers: new Headers(),
method: 'GET',
url: 'lobe-backend://app/trpc/lambda/batch?batch=1',
} as any);
expect(send).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1000);
expect(send).toHaveBeenCalledWith('authorizationRequired');
});
describe('createAppRequestInterceptor', () => {
it('returns null for non-backend paths', async () => {
const manager = new BackendProxyProtocolManager();
const interceptor = manager.createAppRequestInterceptor();
const res = await interceptor({
headers: new Headers(),
method: 'GET',
url: 'app://renderer/settings',
} as any);
expect(res).toBeNull();
});
it('returns 502 for backend paths when default session has no context', async () => {
// electronSession.defaultSession is the empty {} mock; no register() was called.
void electronSession.defaultSession;
const manager = new BackendProxyProtocolManager();
const interceptor = manager.createAppRequestInterceptor();
const res = await interceptor({
headers: new Headers(),
method: 'GET',
url: 'app://renderer/trpc/hello',
} as any);
expect(res).not.toBeNull();
expect(res!.status).toBe(502);
});
it('proxies backend paths through the registered default-session context', async () => {
const fetchMock = vi.fn<FetchMock>(async () => new Response('proxied', { status: 200 }));
vi.stubGlobal('fetch', fetchMock as any);
const manager = new BackendProxyProtocolManager();
manager.registerWithRemoteBaseUrl(electronSession.defaultSession as any, {
getAccessToken: async () => null,
getRemoteBaseUrl: async () => 'https://remote.example.com',
});
const interceptor = manager.createAppRequestInterceptor();
const res = await interceptor({
headers: new Headers(),
method: 'GET',
url: 'app://renderer/trpc/hello?batch=1',
} as any);
expect(res).not.toBeNull();
expect(res!.status).toBe(200);
expect(await res!.text()).toBe('proxied');
expect(fetchMock).toHaveBeenCalledWith(
'https://remote.example.com/trpc/hello?batch=1',
expect.objectContaining({ method: 'GET' }),
);
});
});
});
@@ -1,41 +1,27 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
RendererProtocolManager,
StaticRendererFallback,
ViteRendererFallback,
} from '../RendererProtocolManager';
import { RendererProtocolManager } from '../RendererProtocolManager';
const {
mockApp,
mockFetch,
mockPathExistsSync,
mockProtocol,
mockReadFile,
mockStat,
protocolHandlerRef,
} = vi.hoisted(() => {
const protocolHandlerRef = { current: null as any };
const { mockApp, mockPathExistsSync, mockProtocol, mockReadFile, mockStat, protocolHandlerRef } =
vi.hoisted(() => {
const protocolHandlerRef = { current: null as any };
return {
mockApp: {
isReady: vi.fn().mockReturnValue(true),
whenReady: vi.fn().mockResolvedValue(undefined),
},
mockFetch: vi.fn(),
mockPathExistsSync: vi.fn().mockReturnValue(true),
mockProtocol: {
handle: vi.fn((_scheme: string, handler: any) => {
protocolHandlerRef.current = handler;
}),
},
mockReadFile: vi.fn(),
mockStat: vi.fn(),
protocolHandlerRef,
};
});
vi.stubGlobal('fetch', mockFetch);
return {
mockApp: {
isReady: vi.fn().mockReturnValue(true),
whenReady: vi.fn().mockResolvedValue(undefined),
},
mockPathExistsSync: vi.fn().mockReturnValue(true),
mockProtocol: {
handle: vi.fn((_scheme: string, handler: any) => {
protocolHandlerRef.current = handler;
}),
},
mockReadFile: vi.fn(),
mockStat: vi.fn(),
protocolHandlerRef,
};
});
vi.mock('electron', () => ({
app: mockApp,
@@ -60,7 +46,7 @@ vi.mock('@/utils/logger', () => ({
}),
}));
describe('RendererProtocolManager + StaticRendererFallback', () => {
describe('RendererProtocolManager', () => {
beforeEach(() => {
vi.clearAllMocks();
protocolHandlerRef.current = null;
@@ -73,14 +59,7 @@ describe('RendererProtocolManager + StaticRendererFallback', () => {
protocolHandlerRef.current = null;
});
const buildStaticManager = (resolve: (url: URL) => Promise<string | null>) => {
const fallback = new StaticRendererFallback('/export', resolve);
const manager = new RendererProtocolManager({ fallback });
manager.registerHandler();
return manager;
};
it('falls back to entry HTML when resolve returns 404.html for non-asset routes', async () => {
it('should fall back to entry HTML when resolve returns 404.html for non-asset routes', async () => {
const resolveRendererFilePath = vi.fn(async (url: URL) => {
if (url.pathname === '/missing') return '/export/404.html';
if (url.pathname === '/') return '/export/index.html';
@@ -88,7 +67,12 @@ describe('RendererProtocolManager + StaticRendererFallback', () => {
});
mockReadFile.mockImplementation(async (path: string) => Buffer.from(`content:${path}`));
buildStaticManager(resolveRendererFilePath);
const manager = new RendererProtocolManager({
rendererDir: '/export',
resolveRendererFilePath,
});
manager.registerHandler();
expect(mockProtocol.handle).toHaveBeenCalled();
const handler = protocolHandlerRef.current;
@@ -108,7 +92,7 @@ describe('RendererProtocolManager + StaticRendererFallback', () => {
expect(response.status).toBe(200);
});
it('serves 404.html when explicitly requested', async () => {
it('should serve 404.html when explicitly requested', async () => {
const resolveRendererFilePath = vi.fn(async (url: URL) => {
if (url.pathname === '/404.html') return '/export/404.html';
if (url.pathname === '/') return '/export/index.html';
@@ -116,7 +100,12 @@ describe('RendererProtocolManager + StaticRendererFallback', () => {
});
mockReadFile.mockImplementation(async (path: string) => Buffer.from(`content:${path}`));
buildStaticManager(resolveRendererFilePath);
const manager = new RendererProtocolManager({
rendererDir: '/export',
resolveRendererFilePath,
});
manager.registerHandler();
const handler = protocolHandlerRef.current;
const response = await handler({
@@ -130,30 +119,36 @@ describe('RendererProtocolManager + StaticRendererFallback', () => {
expect(response.status).toBe(200);
});
it('returns 404 for missing asset requests without fallback', async () => {
it('should return 404 for missing asset requests without fallback', async () => {
const resolveRendererFilePath = vi.fn(async (_url: URL) => null);
buildStaticManager(resolveRendererFilePath);
const manager = new RendererProtocolManager({
rendererDir: '/export',
resolveRendererFilePath,
});
manager.registerHandler();
const handler = protocolHandlerRef.current;
const response = await handler({
headers: new Headers(),
method: 'GET',
url: 'app://renderer/logo.png',
} as any);
const response = await handler({ url: 'app://renderer/logo.png' } as any);
expect(resolveRendererFilePath).toHaveBeenCalledTimes(1);
expect(response.status).toBe(404);
});
it('supports Range requests for media assets', async () => {
it('should support Range requests for media assets', async () => {
const resolveRendererFilePath = vi.fn(async (_url: URL) => '/export/intro-video.mp4');
const payload = Buffer.from('0123456789');
mockStat.mockImplementation(async () => ({ size: payload.length }));
mockReadFile.mockImplementation(async () => payload);
buildStaticManager(resolveRendererFilePath);
const manager = new RendererProtocolManager({
rendererDir: '/export',
resolveRendererFilePath,
});
manager.registerHandler();
const handler = protocolHandlerRef.current;
const response = await handler({
@@ -171,126 +166,4 @@ describe('RendererProtocolManager + StaticRendererFallback', () => {
const buf = Buffer.from(await response.arrayBuffer());
expect(buf.toString()).toBe('01');
});
it('runs interceptors before the fallback and short-circuits on first non-null Response', async () => {
const resolveRendererFilePath = vi.fn(async () => '/export/index.html');
mockReadFile.mockImplementation(async () => Buffer.from('static'));
const fallback = new StaticRendererFallback('/export', resolveRendererFilePath);
const manager = new RendererProtocolManager({ fallback });
manager.addRequestInterceptor(async () => null);
manager.addRequestInterceptor(async (request) =>
new URL(request.url).pathname === '/trpc/hello'
? new Response('intercepted', { status: 200 })
: null,
);
manager.registerHandler();
const handler = protocolHandlerRef.current;
const intercepted = await handler({
headers: new Headers(),
method: 'GET',
url: 'app://renderer/trpc/hello',
} as any);
expect(intercepted.status).toBe(200);
expect(await intercepted.text()).toBe('intercepted');
expect(resolveRendererFilePath).not.toHaveBeenCalled();
const fallthrough = await handler({
headers: new Headers(),
method: 'GET',
url: 'app://renderer/anything',
} as any);
expect(fallthrough.status).toBe(200);
expect(await fallthrough.text()).toBe('static');
});
it('returns 404 for cross-host requests', async () => {
const resolveRendererFilePath = vi.fn(async () => '/export/index.html');
buildStaticManager(resolveRendererFilePath);
const handler = protocolHandlerRef.current;
const response = await handler({
headers: new Headers(),
method: 'GET',
url: 'app://elsewhere/index.html',
} as any);
expect(response.status).toBe(404);
expect(resolveRendererFilePath).not.toHaveBeenCalled();
});
});
describe('ViteRendererFallback', () => {
beforeEach(() => {
vi.clearAllMocks();
protocolHandlerRef.current = null;
mockApp.isReady.mockReturnValue(true);
});
it('forwards GET requests to the Vite origin preserving pathname + search', async () => {
mockFetch.mockResolvedValue(new Response('vite-served', { status: 200 }));
const fallback = new ViteRendererFallback('http://localhost:5173');
const manager = new RendererProtocolManager({ fallback });
manager.registerHandler();
const handler = protocolHandlerRef.current;
const response = await handler({
headers: new Headers({ Accept: 'text/html' }),
method: 'GET',
url: 'app://renderer/src/main.tsx?t=12345',
} as any);
expect(mockFetch).toHaveBeenCalledTimes(1);
const [target, init] = mockFetch.mock.calls[0]!;
expect(target).toBe('http://localhost:5173/src/main.tsx?t=12345');
expect((init as RequestInit).method).toBe('GET');
const headers = (init as RequestInit).headers as Headers;
expect(headers.get('Accept')).toBe('text/html');
expect(headers.get('Host')).toBeNull();
expect(response.status).toBe(200);
expect(await response.text()).toBe('vite-served');
});
it('forwards body and sets duplex for non-GET requests', async () => {
mockFetch.mockResolvedValue(new Response('ok', { status: 200 }));
const fallback = new ViteRendererFallback('http://localhost:5173/');
const manager = new RendererProtocolManager({ fallback });
manager.registerHandler();
const handler = protocolHandlerRef.current;
await handler({
headers: new Headers(),
method: 'POST',
body: 'payload' as any,
url: 'app://renderer/__hmr',
} as any);
const [target, init] = mockFetch.mock.calls[0]!;
expect(target).toBe('http://localhost:5173/__hmr');
expect((init as RequestInit & { duplex?: string }).duplex).toBe('half');
expect((init as any).body).toBe('payload');
});
it('returns 502 when fetch throws', async () => {
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
const fallback = new ViteRendererFallback('http://localhost:5173');
const manager = new RendererProtocolManager({ fallback });
manager.registerHandler();
const handler = protocolHandlerRef.current;
const response = await handler({
headers: new Headers(),
method: 'GET',
url: 'app://renderer/@vite/client',
} as any);
expect(response.status).toBe(502);
});
});
@@ -1,18 +1,14 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mockPathExistsSync = vi.fn();
const mockProtocolHandle = vi.fn();
vi.mock('electron', () => ({
app: {
isReady: vi.fn(() => true),
whenReady: vi.fn(() => Promise.resolve()),
},
net: {
fetch: vi.fn(),
},
protocol: {
handle: mockProtocolHandle,
handle: vi.fn(),
},
}));
@@ -49,7 +45,6 @@ describe('RendererUrlManager', () => {
beforeEach(() => {
vi.clearAllMocks();
mockPathExistsSync.mockReset();
mockProtocolHandle.mockReset();
mockIsDev = false;
delete process.env['ELECTRON_RENDERER_URL'];
});
@@ -84,39 +79,8 @@ describe('RendererUrlManager', () => {
});
});
describe('buildRendererUrl', () => {
it('always returns app://renderer regardless of dev/prod', async () => {
const { RendererUrlManager } = await import('../RendererUrlManager');
const prodManager = new RendererUrlManager();
expect(prodManager.buildRendererUrl('/')).toBe('app://renderer/');
expect(prodManager.buildRendererUrl('/settings')).toBe('app://renderer/settings');
mockIsDev = true;
process.env['ELECTRON_RENDERER_URL'] = 'http://localhost:5173';
const devManager = new RendererUrlManager();
expect(devManager.buildRendererUrl('/')).toBe('app://renderer/');
expect(devManager.buildRendererUrl('/settings')).toBe('app://renderer/settings');
});
it('prefixes a slash when the input lacks one', async () => {
const { RendererUrlManager } = await import('../RendererUrlManager');
const manager = new RendererUrlManager();
expect(manager.buildRendererUrl('settings')).toBe('app://renderer/settings');
});
});
describe('configureRendererLoader', () => {
it('registers the app:// protocol handler in prod', async () => {
mockIsDev = false;
const { RendererUrlManager } = await import('../RendererUrlManager');
const manager = new RendererUrlManager();
manager.configureRendererLoader();
expect(mockProtocolHandle).toHaveBeenCalledTimes(1);
expect(mockProtocolHandle.mock.calls[0][0]).toBe('app');
});
it('registers the app:// protocol handler in dev (Vite fallback)', async () => {
describe('configureRendererLoader (dev mode)', () => {
it('should use ELECTRON_RENDERER_URL when available in dev mode', async () => {
mockIsDev = true;
process.env['ELECTRON_RENDERER_URL'] = 'http://localhost:5173';
@@ -124,20 +88,34 @@ describe('RendererUrlManager', () => {
const manager = new RendererUrlManager();
manager.configureRendererLoader();
expect(mockProtocolHandle).toHaveBeenCalledTimes(1);
expect(mockProtocolHandle.mock.calls[0][0]).toBe('app');
expect(manager.buildRendererUrl('/')).toBe('http://localhost:5173/');
expect(manager.buildRendererUrl('/settings')).toBe('http://localhost:5173/settings');
});
it('still registers in dev when ELECTRON_RENDERER_URL is missing (static fallback)', async () => {
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(mockProtocolHandle).toHaveBeenCalledTimes(1);
expect(manager.buildRendererUrl('/')).toBe('http://localhost:5173/');
expect(manager.buildRendererUrl('/overlay')).toBe('http://localhost:5173/overlay');
});
it('uses static fallback when DESKTOP_RENDERER_STATIC overrides ELECTRON_RENDERER_URL', async () => {
it('should fall back to protocol handler when ELECTRON_RENDERER_URL is not set', async () => {
mockIsDev = true;
const { RendererUrlManager } = await import('../RendererUrlManager');
const manager = new RendererUrlManager();
mockPathExistsSync.mockReturnValue(true);
manager.configureRendererLoader();
expect(manager.buildRendererUrl('/')).toBe('app://renderer/');
});
it('should use protocol handler when DESKTOP_RENDERER_STATIC is enabled regardless of ELECTRON_RENDERER_URL', async () => {
mockIsDev = true;
process.env['ELECTRON_RENDERER_URL'] = 'http://localhost:5173';
@@ -146,10 +124,10 @@ describe('RendererUrlManager', () => {
const { RendererUrlManager } = await import('../RendererUrlManager');
const manager = new RendererUrlManager();
mockPathExistsSync.mockReturnValue(true);
manager.configureRendererLoader();
expect(manager.buildRendererUrl('/')).toBe('app://renderer/');
expect(mockProtocolHandle).toHaveBeenCalledTimes(1);
});
});
});
-5
View File
@@ -70,11 +70,6 @@ export const getDesktopEnv = memoize(() =>
// escape hatch: allow testing static renderer in dev via env
DESKTOP_RENDERER_STATIC: envBoolean(false),
// device gateway url override (dev: point at a local `wrangler dev` instance,
// e.g. http://localhost:8787). Falls back to the stored value, then the
// production gateway.
DEVICE_GATEWAY_URL: z.string().url().optional(),
// Force use dev-app-update.yml even in packaged app (for testing updates)
FORCE_DEV_UPDATE_CONFIG: envBoolean(false),
-2
View File
@@ -1,5 +1,3 @@
import './pre-app-init';
import fixPath from 'fix-path';
import { App } from './core/App';
@@ -1,11 +1,13 @@
import {
CODEX_DEFAULT_EXECUTION_ARGS,
CODEX_EXECUTION_MODE_FLAGS,
CODEX_REQUIRED_ARGS,
} from '@lobechat/heterogeneous-agents/spawn';
import type { HeterogeneousAgentBuildPlanParams, HeterogeneousAgentDriver } from '../types';
const CODEX_REQUIRED_ARGS = ['--json', '--skip-git-repo-check'] as const;
const CODEX_AUTO_EXECUTION_FLAGS = [
'--full-auto',
'--dangerously-bypass-approvals-and-sandbox',
'--sandbox',
'-s',
] as const;
const hasAnyFlag = (args: string[], flags: readonly string[]) =>
args.some((arg) => flags.includes(arg as (typeof flags)[number]));
@@ -16,11 +18,9 @@ const buildCodexOptionArgs = async ({
}: Pick<HeterogeneousAgentBuildPlanParams, 'args' | 'helpers' | 'imageList'>) => {
const imagePaths = await helpers.resolveCliImagePaths(imageList);
const imageArgs = imagePaths.flatMap((filePath) => ['--image', filePath]);
const executionModeArgs = hasAnyFlag(args, CODEX_EXECUTION_MODE_FLAGS)
? []
: [...CODEX_DEFAULT_EXECUTION_ARGS];
const autoExecutionArgs = hasAnyFlag(args, CODEX_AUTO_EXECUTION_FLAGS) ? [] : ['--full-auto'];
return [...CODEX_REQUIRED_ARGS, ...executionModeArgs, ...args, ...imageArgs];
return [...CODEX_REQUIRED_ARGS, ...autoExecutionArgs, ...args, ...imageArgs];
};
export const codexDriver: HeterogeneousAgentDriver = {
@@ -181,46 +181,5 @@ describe('cliAgentDetectors', () => {
expect(execMock).not.toHaveBeenCalled();
expect(execFileMock).toHaveBeenCalledTimes(2);
});
it('falls back to the login shell PATH for tools installed by shell setup', async () => {
const originalPath = process.env.PATH;
const originalShell = process.env.SHELL;
process.env.PATH = '/usr/bin:/bin';
process.env.SHELL = '/bin/zsh';
try {
callExecFileError(new Error('not found'));
callExecFile('/opt/homebrew/bin:/Users/Hanam/.local/share/mise/shims:/usr/bin:/bin');
callExecFile('/Users/Hanam/.local/share/mise/shims/gemini\n');
callExecFile('gemini 0.2.0');
const { geminiCliDetector } = await import('../cliAgentDetectors');
const status = await geminiCliDetector.detect();
expect(status.available).toBe(true);
expect(status.path).toBe('/Users/Hanam/.local/share/mise/shims/gemini');
expect(status.version).toBe('gemini 0.2.0');
expect(execFileMock).toHaveBeenCalledTimes(4);
expect(execFileMock.mock.calls[0]![0]).toBe('which');
expect(execFileMock.mock.calls[1]![0]).toBe('/bin/zsh');
expect(execFileMock.mock.calls[1]![1]).toEqual(['-ilc', 'printf "%s" "$PATH"']);
expect(execFileMock.mock.calls[2]![0]).toBe('which');
expect(execFileMock.mock.calls[2]![2]).toMatchObject({
env: {
PATH: '/opt/homebrew/bin:/Users/Hanam/.local/share/mise/shims:/usr/bin:/bin',
},
});
expect(execFileMock.mock.calls[3]![0]).toBe('/Users/Hanam/.local/share/mise/shims/gemini');
expect(execFileMock.mock.calls[3]![2]).toMatchObject({
env: {
PATH: '/opt/homebrew/bin:/Users/Hanam/.local/share/mise/shims:/usr/bin:/bin',
},
});
} finally {
process.env.PATH = originalPath;
process.env.SHELL = originalShell;
}
});
});
});
@@ -19,13 +19,7 @@ interface ValidatedDetectorOptions {
validateKeywords: string[];
}
interface ResolvedCommand {
env?: NodeJS.ProcessEnv;
path: string;
}
const isWindows = () => platform() === 'win32';
let shellPathPromise: Promise<string | undefined> | undefined;
// Reject anything that could break out of the `cmd /c "<path>" --version`
// shell line we build for Windows .cmd shims (see `detectValidatedCommand`).
@@ -46,107 +40,34 @@ const pickWindowsRunnable = (lines: string[]): string | undefined => {
return undefined;
};
const getLoginShellPath = async (): Promise<string | undefined> => {
if (isWindows()) return undefined;
const shell = process.env.SHELL;
if (!shell || !path.isAbsolute(shell)) return undefined;
try {
const { stdout } = await execFilePromise(shell, ['-ilc', 'printf "%s" "$PATH"'], {
timeout: 3000,
windowsHide: true,
});
return stdout
.split(/\r?\n/)
.map((line) => line.trim())
.reverse()
.find((line) => line.includes(path.delimiter));
} catch {
return undefined;
}
};
const getCachedLoginShellPath = async (): Promise<string | undefined> => {
shellPathPromise ??= getLoginShellPath();
return shellPathPromise;
};
const mergePathValues = (...values: Array<string | undefined>): string | undefined => {
const seen = new Set<string>();
const segments = values
.flatMap((value) => value?.split(path.delimiter) ?? [])
.map((segment) => segment.trim())
.filter((segment) => {
if (!segment || seen.has(segment)) return false;
seen.add(segment);
return true;
});
return segments.length > 0 ? segments.join(path.delimiter) : undefined;
};
const getCommandPathLines = async (
whichCommand: 'where' | 'which',
command: string,
env?: NodeJS.ProcessEnv,
): Promise<string[] | undefined> => {
try {
const { stdout } = await execFilePromise(whichCommand, [command], {
env,
timeout: 3000,
windowsHide: true,
});
const lines = stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
return lines.length > 0 ? lines : undefined;
} catch {
return undefined;
}
};
const resolveCommandPath = async (command: string): Promise<ResolvedCommand | undefined> => {
const resolveCommandPath = async (command: string): Promise<string | undefined> => {
const trimmedCommand = command.trim();
if (!trimmedCommand) return;
if (path.isAbsolute(trimmedCommand) || trimmedCommand.includes(path.sep)) {
return { path: trimmedCommand };
return trimmedCommand;
}
const whichCommand = isWindows() ? 'where' : 'which';
let lines = await getCommandPathLines(whichCommand, trimmedCommand);
let lookupEnv: NodeJS.ProcessEnv | undefined;
if (!lines && !isWindows()) {
const shellPath = await getCachedLoginShellPath();
const lookupPath = mergePathValues(shellPath, process.env.PATH);
try {
const { stdout } = await execFilePromise(whichCommand, [trimmedCommand], { timeout: 3000 });
const lines = stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
if (lines.length === 0) return undefined;
if (lookupPath && lookupPath !== process.env.PATH) {
const fallbackEnv = {
...process.env,
PATH: lookupPath,
};
lines = await getCommandPathLines(whichCommand, trimmedCommand, fallbackEnv);
if (lines) lookupEnv = fallbackEnv;
}
// Windows `where` lists every PATHEXT match (e.g. for `codex` npm ships
// a Unix shell wrapper alongside `codex.cmd` and `codex.ps1`). Picking
// the first line can land us on something we can't execute, so prefer a
// runnable extension and bail otherwise.
if (isWindows()) return pickWindowsRunnable(lines);
return lines[0];
} catch {
return undefined;
}
if (!lines) return undefined;
// Windows `where` lists every PATHEXT match (e.g. for `codex` npm ships
// a Unix shell wrapper alongside `codex.cmd` and `codex.ps1`). Picking
// the first line can land us on something we can't execute, so prefer a
// runnable extension and bail otherwise.
if (isWindows()) {
const runnablePath = pickWindowsRunnable(lines);
return runnablePath ? { path: runnablePath } : undefined;
}
return { env: lookupEnv, path: lines[0] };
};
const detectValidatedCommand = async (
@@ -162,21 +83,17 @@ const detectValidatedCommand = async (
// Resolve via where/which BEFORE invoking. On Windows this is what discovers
// npm-installed shims like `claude.cmd` under %APPDATA%\npm — `execFile`
// alone won't apply PATHEXT and can't run .cmd files directly.
const resolvedCommand = await resolveCommandPath(trimmedCommand);
if (!resolvedCommand) return { available: false };
const { env, path: resolvedPath } = resolvedCommand;
const resolvedPath = await resolveCommandPath(trimmedCommand);
if (!resolvedPath) return { available: false };
try {
const needsShell = isWindows() && /\.(?:cmd|bat)$/i.test(resolvedPath);
const { stderr, stdout } = needsShell
? await execPromise(`"${resolvedPath}" ${validateFlag}`, {
env,
timeout: 5000,
windowsHide: true,
})
: await execFilePromise(resolvedPath, [validateFlag], {
env,
timeout: 5000,
windowsHide: true,
});
-15
View File
@@ -1,15 +0,0 @@
import path from 'node:path';
import { app } from 'electron';
import * as electronIs from 'electron-is';
// Must run BEFORE any module captures `app.getPath('userData')` (e.g. `@/const/dir`
// reads it at top level). Once a path is read, `setName` / `setPath` no-op for it.
//
// Dev now uses the same `app://renderer/` origin as prod, so localStorage / cookies /
// IndexedDB would collide if both shared the packaged-app's userData dir. Pin dev to
// a sibling directory so prod sessions stay clean.
if (electronIs.dev()) {
app.setName('lobehub-desktop-dev');
app.setPath('userData', path.join(app.getPath('appData'), 'lobehub-desktop-dev'));
}
@@ -3,12 +3,9 @@ import os from 'node:os';
import type {
AgentRunRequestMessage,
GatewayMcpStdioParams,
MessageApiRequestMessage,
RpcRequestMessage,
SystemInfoRequestMessage,
ToolCallRequestMessage,
ToolCallResponseMessage,
} from '@lobechat/device-gateway-client';
import { GatewayClient } from '@lobechat/device-gateway-client';
import type { IdentitySource } from '@lobechat/device-identity';
@@ -17,7 +14,6 @@ import type { GatewayConnectionStatus } from '@lobechat/electron-client-ipc';
import { app, powerSaveBlocker } from 'electron';
import { isDev } from '@/const/env';
import { getDesktopEnv } from '@/env';
import { createLogger } from '@/utils/logger';
import { ServiceModule } from './index';
@@ -26,74 +22,18 @@ const logger = createLogger('services:GatewayConnectionSrv');
const DEFAULT_GATEWAY_URL = 'https://device-gateway.lobehub.com';
/**
* Result envelope a tool-call handler must return. Mirrors
* `BuiltinServerRuntimeOutput` so the renderer-side and remote-device paths
* stay symmetric: `content` is the LLM-facing prompt text; `state` carries the
* structured payload that downstream persists into `pluginState`.
*/
interface ToolCallResult {
content: string;
error?: unknown;
state?: unknown;
success: boolean;
interface ToolCallHandler {
(apiName: string, args: any): Promise<unknown>;
}
interface MessageApiHandler {
(platform: string, apiName: string, payload: Record<string, unknown>): Promise<unknown>;
}
interface ToolCallHandler {
(apiName: string, args: unknown): Promise<ToolCallResult>;
}
/**
* Handler for tunneled stdio MCP calls. Unlike {@link ToolCallHandler} (which
* keys on `apiName` for builtin local-system tools), this carries the MCP
* server identity + connection params so the device can spawn the local stdio
* server and invoke the tool on it.
*/
interface McpCallHandler {
(mcpCall: {
apiName: string;
arguments: string;
identifier: string;
params: GatewayMcpStdioParams;
}): Promise<ToolCallResult>;
}
/**
* Coerce a runtime error (which may be an Error, string, or `{ message }`
* object) into the string shape the wire protocol expects. Returns undefined
* when there's no error to transmit.
*/
const serializeWireError = (err: unknown): string | undefined => {
if (err === undefined || err === null) return undefined;
if (typeof err === 'string') return err;
if (err instanceof Error) return err.message;
if (typeof err === 'object' && 'message' in err && typeof err.message === 'string') {
return err.message;
}
try {
return JSON.stringify(err);
} catch {
return String(err);
}
};
interface AgentRunHandler {
(request: AgentRunRequestMessage): Promise<{ reason?: string; status: 'accepted' | 'rejected' }>;
}
/**
* Handler for generic server-internal device RPCs (e.g. workspace-init scans).
* Dispatches by `method` name and returns the JSON-serializable result. Distinct
* from {@link ToolCallHandler} RPCs are never exposed to the agent.
*/
interface RpcHandler {
(method: string, params: unknown): Promise<unknown>;
}
interface DeviceRegistrar {
(info: {
deviceId: string;
@@ -120,10 +60,8 @@ export default class GatewayConnectionService extends ServiceModule {
private tokenProvider: (() => Promise<string | null>) | null = null;
private tokenRefresher: (() => Promise<{ error?: string; success: boolean }>) | null = null;
private toolCallHandler: ToolCallHandler | null = null;
private mcpCallHandler: McpCallHandler | null = null;
private messageApiHandler: MessageApiHandler | null = null;
private agentRunHandler: AgentRunHandler | null = null;
private rpcHandler: RpcHandler | null = null;
private deviceRegistrar: DeviceRegistrar | null = null;
// ─── Configuration ───
@@ -149,27 +87,10 @@ export default class GatewayConnectionService extends ServiceModule {
this.toolCallHandler = handler;
}
/**
* Set the MCP call handler (routes tunneled stdio MCP calls to McpCtr, which
* spawns the local stdio server). Distinct from the builtin tool-call handler.
*/
setMcpCallHandler(handler: McpCallHandler) {
this.mcpCallHandler = handler;
}
setMessageApiHandler(handler: MessageApiHandler) {
this.messageApiHandler = handler;
}
/**
* Set the generic device-RPC handler (routes server-internal method calls such
* as workspace-init to the relevant controller). Distinct from the tool-call
* handler these are never surfaced to the agent.
*/
setRpcHandler(handler: RpcHandler) {
this.rpcHandler = handler;
}
setAgentRunHandler(handler: AgentRunHandler) {
this.agentRunHandler = handler;
}
@@ -358,10 +279,6 @@ export default class GatewayConnectionService extends ServiceModule {
this.handleSystemInfoRequest(client, request);
});
client.on('rpc_request', (request) => {
this.handleRpcRequest(client, request);
});
client.on('agent_run_request', (request) => {
this.handleAgentRunRequest(client, request);
});
@@ -427,32 +344,6 @@ export default class GatewayConnectionService extends ServiceModule {
});
}
// ─── Generic Device RPC ───
private async handleRpcRequest(client: GatewayClient, request: RpcRequestMessage) {
const { method, params, requestId } = request;
logger.info(`Received rpc_request: method=${method}, requestId=${requestId}`);
if (!this.rpcHandler) {
client.sendRpcResponse({
requestId,
result: { error: 'No RPC handler registered', success: false },
});
return;
}
try {
const data = await this.rpcHandler(method, params);
client.sendRpcResponse({ requestId, result: { data, success: true } });
} catch (error) {
logger.error(`rpc_request method=${method} failed:`, serializeWireError(error));
client.sendRpcResponse({
requestId,
result: { error: serializeWireError(error), success: false },
});
}
}
// ─── Agent Run ───
private handleAgentRunRequest = async (
@@ -484,50 +375,25 @@ export default class GatewayConnectionService extends ServiceModule {
client: GatewayClient,
) => {
const { requestId, toolCall } = request;
const { apiName, arguments: argsStr, identifier, params, type } = toolCall;
const { apiName, arguments: argsStr } = toolCall;
logger.info(
`Received tool call: apiName=${apiName}, requestId=${requestId}, type=${type ?? 'tool'}`,
);
logger.info(`Received tool call: apiName=${apiName}, requestId=${requestId}`);
try {
let result: ToolCallResult;
if (type === 'mcp') {
// Tunneled stdio MCP call: route to the local MCP client (spawns the
// stdio server). Routing is driven by the explicit `type` discriminator,
// not by sniffing the payload — the builtin local-system tool switch
// keys on `apiName` and has no MCP server context.
if (!this.mcpCallHandler) {
throw new Error('No MCP call handler configured');
}
if (!params) {
throw new Error('MCP tool call missing connection params');
}
result = await this.mcpCallHandler({ apiName, arguments: argsStr, identifier, params });
} else {
if (!this.toolCallHandler) {
throw new Error('No tool call handler configured');
}
const args = JSON.parse(argsStr);
result = await this.toolCallHandler(apiName, args);
if (!this.toolCallHandler) {
throw new Error('No tool call handler configured');
}
// Forward the typed envelope unchanged. Critically, do NOT stringify the
// whole result into `content` — that would bury the structured payload
// inside a JSON blob and lose `state`. The wire protocol carries each
// field separately so downstream (`DeviceGateway` → `RuntimeExecutors`)
// can persist `state` to `pluginState`. Optional fields are only set
// when present so payloads stay minimal.
const wireResult: ToolCallResponseMessage['result'] = {
content: result.content,
success: result.success,
};
const wireError = serializeWireError(result.error);
if (wireError !== undefined) wireResult.error = wireError;
if (result.state !== undefined) wireResult.state = result.state;
const args = JSON.parse(argsStr);
const result = await this.toolCallHandler(apiName, args);
client.sendToolCallResponse({ requestId, result: wireResult });
client.sendToolCallResponse({
requestId,
result: {
content: typeof result === 'string' ? result : JSON.stringify(result),
success: true,
},
});
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
logger.error(`Tool call failed: apiName=${apiName}, error=${errorMsg}`);
@@ -629,13 +495,7 @@ export default class GatewayConnectionService extends ServiceModule {
// ─── Gateway URL ───
private getGatewayUrl(): string {
// Env override wins (dev: point at a local `wrangler dev` gateway), then the
// user-configured store value, then the production default.
return (
getDesktopEnv().DEVICE_GATEWAY_URL ||
this.app.storeManager.get('gatewayUrl') ||
DEFAULT_GATEWAY_URL
);
return this.app.storeManager.get('gatewayUrl') || DEFAULT_GATEWAY_URL;
}
// ─── Token Helpers ───
@@ -1,11 +1,15 @@
import { readdir, readFile } from 'node:fs/promises';
import { readFile } from 'node:fs/promises';
import { readdir } from 'fs-extra';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { detectRepoType } from '../git';
vi.mock('node:fs/promises', () => ({
readFile: vi.fn(),
}));
vi.mock('fs-extra', () => ({
readdir: vi.fn(),
}));
+69 -4
View File
@@ -1,6 +1,71 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { readdir } from 'fs-extra';
/**
* Git repo-type / gitdir helpers. The implementations now live in
* `@lobechat/local-file-shell` so desktop, the device RPC, and the CLI share one
* copy; re-exported here to keep existing `@/utils/git` import sites stable.
* Resolve the actual `.git` directory for a working tree.
* Supports both standard layouts and worktree pointer files (`.git` as a regular file
* containing `gitdir: <path>`).
*/
export { detectRepoType, resolveCommonGitDir, resolveGitDir } from '@lobechat/local-file-shell';
export const resolveGitDir = async (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;
};
/**
* Resolve the common git dir where shared state like `config` and
* `packed-refs` lives. For linked worktrees, `resolveGitDir` returns
* `.git/worktrees/<name>/` which has its own `HEAD` but no `config`;
* the `commondir` pointer inside it resolves to the main repo's gitdir.
*/
export const resolveCommonGitDir = async (dirPath: string): Promise<string | undefined> => {
const gitDir = await resolveGitDir(dirPath);
if (!gitDir) return undefined;
try {
const commondir = (await readFile(path.join(gitDir, 'commondir'), 'utf8')).trim();
if (!commondir) return gitDir;
return path.isAbsolute(commondir) ? commondir : path.resolve(gitDir, commondir);
} catch {
return gitDir;
}
};
// Match `github.com` only in a remote-URL host position: preceded by `@`, `/`,
// or line start (covers `git@github.com:`, `https://github.com/`,
// `ssh://git@github.com/`, etc.) and followed by `:` or `/`. Avoids matching
// look-alikes like `evilgithub.com` or `github.com.attacker.com`.
const GITHUB_REMOTE_HOST_RE = /(?:^|[@/])github\.com[:/]/m;
/**
* Classify a working tree as `git` (plain) / `github` (origin points at github.com) /
* `undefined` (not a git repo). Reads the shared gitdir's `config` so submodules and
* linked worktrees resolve the same as the main repo.
*/
export const detectRepoType = async (dirPath: string): Promise<'git' | 'github' | undefined> => {
const commonDir = await resolveCommonGitDir(dirPath);
if (!commonDir) return undefined;
try {
const config = await readFile(path.join(commonDir, 'config'), 'utf8');
if (GITHUB_REMOTE_HOST_RE.test(config)) return 'github';
return 'git';
} catch {
return undefined;
}
};
-13
View File
@@ -17,16 +17,3 @@ export interface DesktopHotkeyItem {
}
export type DesktopHotkeyConfig = Record<DesktopHotkeyId, string>;
/**
* Mirror of `@lobechat/types`' `BuiltinServerRuntimeOutput`. Reached by
* `@lobechat/tool-runtime` (the runtime the gateway controller reuses) via
* `import type`, so only the shape is needed. Keep in sync with
* `packages/types/src/tool/builtin.ts`.
*/
export interface BuiltinServerRuntimeOutput {
content: string;
error?: unknown;
state?: unknown;
success: boolean;
}
-5
View File
@@ -1,9 +1,4 @@
[
{
"children": {},
"date": "2026-05-29",
"version": "2.2.1"
},
{
"children": {},
"date": "2026-05-18",
+1 -3
View File
@@ -472,7 +472,5 @@
"https://github.com/user-attachments/assets/facdc83c-e789-4649-8060-7f7a10a1b1dd": "/blog/assets05b20e40c03ced0ec8707fed2e8e0f25.webp",
"https://github.com/user-attachments/assets/fcdfb9c5-819a-488f-b28d-0857fe861219": "/blog/assets8477415ecec1f37e38ab38ff1217d0a7.webp",
"https://github.com/user-attachments/assets/fd60ab55-ead2-4930-ad00-fdf77662f5a0": "/blog/assets276a4e8748e9bd300b30dcd9d0e24980.webp",
"https://file.rene.wang/task.png": "/blog/assets4aa1732a45832afc780600e6e329860c.webp",
"https://file.rene.wang/Platform Agent.png": "/blog/assets10cadd434aeb36bd1beb3c7b3d371fbd.webp",
"https://file.rene.wang/clipboard-1780888016983-b47fcdab831b1.png": "/blog/assets65dddd1748c3de8646c8ad56abf53390.webp"
"https://file.rene.wang/task.png": "/blog/assets4aa1732a45832afc780600e6e329860c.webp"
}
@@ -1,8 +1,7 @@
---
title: Introducing CAO — Your Chief Agent Operator
description: >-
Meet CAO: agents that review their own work, recruit teammates when they need
help, and only stop to ask you when it really matters.
Meet CAO: agents that review their own work, recruit teammates when they need help, and only stop to ask you when it really matters.
tags:
- CAO
- Agent Teams
@@ -1,49 +0,0 @@
---
title: Platform Agents & Drag-Drop Skills
description: >-
Run agents locally or on a remote device and drop skills straight into the
message box. v2.2.1 is here.
tags:
- Platform Agents
- Skills
- Models
---
# Platform Agents & Drag-Drop Skills
## Platform agents, local or remote (beta)
You can now create platform agents like **OpenClaw** and **Hermes** and choose, right from the composer, whether they run on your own machine or on a remote device. A new device switcher in the chat input lets you swap targets without leaving the conversation, and your registered devices are remembered so you can pick up where you left off.
On desktop, the recent-directories list can be reordered by drag-and-drop, and devices auto-register with a stable ID — set it once, use it everywhere.
> Platform agents are in beta. Head to **Settings → Advanced → Labs** and turn on the platform-agent flag to try them.
## Drag-and-drop skills & folders
The chat input got more direct. Drag a skill from the right panel into the message box and it becomes an action tag — no menu hunting. Typing `/` mid-sentence pops up a slash menu of every skill you have installed, from built-ins to ones from the Skill Market or your own agents.
On desktop, drag a whole folder into chat and it shows up as a `@localFile` reference instead of trying to upload every file inside it.
## Other improvements
- **Bots that send files**: Discord, Telegram, Slack, Feishu, WeChat, LINE, and QQ can now exchange images, videos, voice, and files — not just text
- **Page Agent sharing**: share an agent's working pages with one click
- **Document highlights**: non-markdown documents render as read-only highlighted code; you can open a thread chat inside the document preview
- **Tasks with attachments**: drop files and images directly into a task
- **Export an agent**: download any agent's profile as a Markdown file
- **New models**: Claude Opus 4.8, DeepSeek V4 Flash/Pro, Gemini 3.5 Flash, Qwen 3.7 Max, intern-s2-preview, step-3.7-flash
- **Chat cost estimates** shown alongside replies
- **Smoother first run**: guided agent creation, and new topics aren't created until you send your first message
- **Multi-select delete** in the agent documents explorer
- **Follow-up suggestions** in general chats, not just agent ones
## Fixes & polish
- Input drafts persist when you switch tabs.
- Action bar stays open while you hover the next message.
- Copying a user message no longer leaves escaped Markdown.
- Cmd +//0 shows a zoom HUD on desktop, and `~` paths expand correctly.
- Empty replies retry instead of silently finishing; market OAuth re-login pops the right modal when a session expires.
- Topic list pagination is preserved after creating, deleting, or moving topics.
- File preview now covers `.cjs`, `.mjs`, and extension-less text files; Bedrock structured output and Gemini diagnostics fixes also landed.
@@ -1,47 +0,0 @@
---
title: 平台智能体与拖拽即用的技能
description: 智能体可以在本地或远程设备上运行,技能拖一下就能进消息框。v2.2.1 上线。
tags:
- 平台智能体
- 技能
- 模型
---
# 平台智能体与拖拽即用的技能
## 平台智能体:本地或远程(Beta)
你现在可以创建 **OpenClaw**、**Hermes** 这类平台智能体,并直接在输入框选择它们运行在你的本机还是某台远程设备上。聊天输入区新增了执行设备切换器,无需离开会话就能切换目标;注册过的设备会被记住,下次直接接着用。
桌面端的「最近目录」支持拖拽重新排序;设备会基于稳定的机器 ID 自动注册——只设置一次,到哪都能用。
> 平台智能体目前为 Beta。前往 **设置 → 高级 → Labs** 开启对应开关后即可体验。
## 拖拽即用的技能与文件夹
聊天输入更直接了。从右侧面板把技能拖进消息框,它会变成一枚动作标签——不用再翻菜单。在句子中间输入 `/` 也会弹出包含全部已安装技能的菜单,无论是内置的、来自技能市场的,还是你自己 Agent 提供的。
桌面端可以把整个文件夹拖进聊天,它会以 `@localFile` 引用形式出现,而不是把里面的每个文件都上传一遍。
## 其他改进
- **会发文件的 Bot**Discord、Telegram、Slack、飞书、微信、LINE、QQ 现在都能收发图片、视频、语音和文件,不再仅限于文字
- **Page Agent 共享**:一键分享智能体的工作页面
- **文档高亮**:非 Markdown 文档以只读的代码高亮形式呈现,并可在文档预览中直接开启线程对话
- **任务支持附件**:图片和文件可以直接挂到任务上
- **导出智能体**:把任意智能体导出成 Markdown 文件
- **新模型**Claude Opus 4.8、DeepSeek V4 Flash / Pro、Gemini 3.5 Flash、Qwen 3.7 Max、intern-s2-preview、step-3.7-flash
- **回复旁显示费用预估**
- **更顺手的初次体验**:新建智能体有引导界面,发送第一条消息后才会真正创建话题
- 智能体文档浏览器支持**多选删除**
- **后续建议**现在也会出现在普通对话里,不再仅限于 Agent 对话
## 修复与打磨
- 切换标签页后,输入草稿仍会保留。
- 鼠标悬停到下一条消息时,操作栏不会闪退。
- 复制用户消息时不再带出转义的 Markdown 字符。
- 桌面端 Cmd +//0 显示缩放 HUD;`~` 路径会被正确展开。
- 空回复会自动重试,而不是悄悄结束;技能市场会话过期时会弹出正确的重新登录窗口。
- 创建、删除、移动话题后,话题列表的分页状态会被保留。
- 桌面端现在可预览 `.cjs`、`.mjs` 和无后缀文本文件;同时修复了 Bedrock 结构化输出与 Gemini 诊断相关问题。
-43
View File
@@ -1,43 +0,0 @@
---
title: Connectors & Connect Agents
description: >-
Govern every tool with the new Connectors system, run Connect Agents on your own device, and track spending right in the activity heatmap.
tags:
- Connectors
- Connect Agent
- Insights
- Models
---
# Connectors & Connect Agents
## Connectors: one place to govern every tool
Connectors bring all of an agent's tools — MCP servers, Skill Market skills, built-in tools, and third-party integrations — under a single permission layer. For each tool you decide whether it runs freely, pauses for your approval, or stays off, and read-only actions (like fetching or listing) are detected automatically so they aren't blocked by mistake. It's the clearest way yet to see what your agents can reach, and to keep write actions on a short leash.
## Connect Agents, running on your own machine
What you used to create as a "Platform Agent" is now a **Connect Agent** — a name that better reflects what it is: a third-party agent running on your own device, not on LobeHub. The execution-device switcher now appears for every agent, so you can point any conversation at a specific machine. Agents can call stdio MCP tools directly through your device and their results render inline in chat, and server-run agents now scan the project folder you bind them to — automatically picking up `.agents/skills`, `.claude/skills`, and your `AGENTS.md` / `CLAUDE.md`.
## See where your tokens go
The activity heatmap added a token-usage mode, so you can switch from "how often did I chat" to "how much did each day cost" without leaving the page. The topic sidebar can now group conversations by status, and one click collapses or expands every group at once.
## New model and chat-input polish
- **New model**: MiniMax M3, including its video runtime
- **Configurable model routing and starters**, for finer control over which model handles what
- The chat input's **`+` menu** was reworked with toggle switches and grouped submenus, and app-fixed tools now live in a dedicated **Pinned** section
- Command output now **renders ANSI colors**, so `RunCommand` logs read just like your terminal
- Inside a task, the comment box is now a full chat input that **kicks off a new run**
## Improvements and fixes
- Page-agent edits now run server-side, so they no longer break when you switch tabs, navigate away, or hit a network blip.
- Cleaner auto-generated topic titles, with better results on DeepSeek, and stray Markdown tokens stripped from fallback titles.
- The agent document editor renders system docs, defaults new files to `.md`, and preserves IME composition for CJK input.
- Delete confirmations were restructured for clearer titles and wording across the app.
- Desktop: macOS auto-update signing works again, the updater can quit cleanly, CLI tools resolve from your shell `PATH`, and a startup renderer crash is fixed.
- Streaming no longer duplicates after a stale reconnect, and home-screen starters load more reliably.
- The GitHub bot renders its `runCommand` result card, and agent documents load with noticeably less latency.
@@ -1,41 +0,0 @@
---
title: 连接器与接入助理
description: 用全新的「连接器」管控每个工具,让接入助理跑在你自己的设备上,并在活跃度热力图里直接追踪用量花费。
tags:
- 连接器
- 接入助理
- 用量洞察
- 模型
---
# 连接器与接入助理
## 连接器:统一管控每一个工具
连接器把助理的所有工具——MCP 服务器、技能市场的技能、内置工具,以及第三方集成——都纳入同一套权限体系。你可以为每个工具单独决定:直接放行、先暂停等你批准,还是干脆关闭;只读类操作(例如获取、列举)会被自动识别,不会被误拦。这是迄今最清晰的方式,让你看清助理能触达哪些能力,也把写入类操作牢牢攥在手里。
## 接入助理,跑在你自己的设备上
过去你创建的「平台 Agent」,现在叫 **接入助理**——这个名字更贴切:它是运行在你自己设备上的第三方助理,而非 LobeHub 托管的助理。执行设备切换器现在对所有助理可见,你可以把任意会话指向某台指定机器。助理能直接通过你的设备调用 stdio MCP 工具,结果会内嵌在聊天里呈现;在服务端运行的助理还会扫描你为它绑定的项目目录,自动读取 `.agents/skills`、`.claude/skills` 以及 `AGENTS.md` / `CLAUDE.md`。
## 看清 Token 花在哪
活跃度热力图新增了 Token 用量模式,无需离开页面,就能从「每天聊了多少次」切到「每天花了多少」。话题侧边栏现在支持按状态分组,一次点击即可折叠或展开全部分组。
## 新模型与输入框打磨
- **新模型**MiniMax M3,含视频运行时
- **可配置的模型路由与开场白**,更精细地决定由哪个模型处理什么
- 聊天输入框的 **`+` 菜单** 重做,改用开关切换并分组归类;应用固定的工具现在收进独立的 **「固定」区**
- 命令输出会**渲染 ANSI 颜色**`RunCommand` 的日志读起来和终端里一样
- 在任务里,评论框现在是完整的聊天输入框,可**直接发起一次新的运行**
## 体验优化与修复
- Page Agent 的编辑改到服务端执行,切换标签页、离开页面或网络抖动时不再中断。
- 自动生成的话题标题更干净,在 DeepSeek 上效果更好,兜底标题里残留的 Markdown 符号也会被清除。
- 助理文档编辑器可渲染系统文档,新建文件默认 `.md`,并保留中日韩输入法(IME)的组合输入。
- 各处删除确认弹窗重新梳理了标题与文案,更清晰。
- 桌面端:修复 macOS 自动更新签名、更新时能正常退出、CLI 工具可从 shell `PATH` 解析,以及启动时的渲染进程崩溃。
- 修复重连后偶发的重复流式输出,首页开场白加载更稳定。
- GitHub Bot 能正确渲染 `runCommand` 结果卡片,助理文档的加载延迟明显降低。
+41 -177
View File
@@ -2,385 +2,249 @@
"$schema": "https://github.com/lobehub/lobe-chat/blob/main/docs/changelog/schema.json",
"cloud": [],
"community": [
{
"image": "/blog/assets65dddd1748c3de8646c8ad56abf53390.webp",
"id": "2026-06-08-connectors",
"date": "2026-06-08",
"versionRange": [
"2.2.2"
]
},
{
"image": "/blog/assets10cadd434aeb36bd1beb3c7b3d371fbd.webp",
"id": "2026-05-31-drag-and-drop-skills",
"date": "2026-05-31",
"versionRange": [
"2.2.0",
"2.2.1"
]
},
{
"image": "https://hub-apac-1.lobeobjects.space/billboard/covers/1778838542538-MDEMAEav.png",
"id": "2026-05-19-chief-agent-operator",
"date": "2026-05-19",
"versionRange": [
"2.1.58",
"2.2.0"
]
"versionRange": ["2.1.58", "2.2.0"]
},
{
"image": "/blog/assets4aa1732a45832afc780600e6e329860c.webp",
"id": "2026-05-11-agent-tasks-ga",
"date": "2026-05-11",
"versionRange": [
"2.1.57"
]
"versionRange": ["2.1.57"]
},
{
"image": "/blog/assetsb2bf4ddf0a45ff887a993c18cb7ab983.webp",
"id": "2026-05-04-task-scheduler",
"date": "2026-05-04",
"versionRange": [
"2.1.54",
"2.1.56"
]
"versionRange": ["2.1.54", "2.1.56"]
},
{
"image": "/blog/assetsfa267a02f20bc5ba6f1273bcf27b7c9f.webp",
"id": "2026-04-27-heterogeneous-agent",
"date": "2026-04-27",
"versionRange": [
"2.1.53"
]
"versionRange": ["2.1.53"]
},
{
"image": "/blog/assetsdfda32866c4bc59af0526e52f31d1da2.webp",
"id": "2026-04-20-daily-brief",
"date": "2026-04-20",
"versionRange": [
"2.1.50",
"2.1.52"
]
"versionRange": ["2.1.50", "2.1.52"]
},
{
"image": "/blog/assets300abe7e259d293da6c5ed4f642a1be6.webp",
"id": "2026-04-13-gateway-sidebar",
"date": "2026-04-13",
"versionRange": [
"2.1.48",
"2.1.49"
]
"versionRange": ["2.1.48", "2.1.49"]
},
{
"image": "/blog/assets7ea204859aeb5aa9be5810a20ba1669a.webp",
"id": "2026-04-06-auto-completion",
"date": "2026-04-06",
"versionRange": [
"2.1.47"
]
"versionRange": ["2.1.47"]
},
{
"id": "2026-03-30-agent-tasks",
"date": "2026-03-30",
"versionRange": [
"2.1.45",
"2.1.46"
]
"versionRange": ["2.1.45", "2.1.46"]
},
{
"image": "/blog/assets53e6ec9cf72554dbc1f8224fc0550a03.webp",
"id": "2026-03-23-media-memory",
"date": "2026-03-23",
"versionRange": [
"2.1.44"
]
"versionRange": ["2.1.44"]
},
{
"image": "https://hub-apac-1.lobeobjects.space/blog/assets/4a68a7644501cb513d08670b102a446e.webp",
"id": "2026-03-16-search",
"date": "2026-03-16",
"versionRange": [
"2.1.38",
"2.1.43"
]
"versionRange": ["2.1.38", "2.1.43"]
},
{
"id": "2026-02-08-runtime-auth",
"date": "2026-02-08",
"versionRange": [
"2.1.6",
"2.1.26"
]
"versionRange": ["2.1.6", "2.1.26"]
},
{
"image": "/blog/assetsa8e504275f2cd891fabecca985998de0.webp",
"id": "2026-01-27-v2",
"date": "2026-01-27",
"versionRange": [
"2.0.1",
"2.1.5"
]
"versionRange": ["2.0.1", "2.1.5"]
},
{
"image": "/blog/assets7f3b38c1d76cceb91edb29d6b1eb60db.webp",
"id": "2025-12-20-mcp",
"date": "2025-12-20",
"versionRange": [
"1.142.8",
"1.143.0"
]
"versionRange": ["1.142.8", "1.143.0"]
},
{
"image": "/blog/assets3a7f0b29839603336e39e923b423409b.webp",
"id": "2025-11-08-comfy-ui",
"date": "2025-11-08",
"versionRange": [
"1.133.5",
"1.142.8"
]
"versionRange": ["1.133.5", "1.142.8"]
},
{
"image": "/blog/assets35e6aa692b0c16009c61964279514166.webp",
"id": "2025-10-08-python",
"date": "2025-10-08",
"versionRange": [
"1.120.7",
"1.133.5"
]
"versionRange": ["1.120.7", "1.133.5"]
},
{
"image": "/blog/assetsce5d6dc93676f974be2e162e8ace03f0.webp",
"id": "2025-09-08-gemini",
"date": "2025-09-08",
"versionRange": [
"1.109.1",
"1.120.7"
]
"versionRange": ["1.109.1", "1.120.7"]
},
{
"image": "/blog/assetsdf48eed9de76b7e37c269b294285f09d.webp",
"id": "2025-08-08-image-generation",
"date": "2025-08-08",
"versionRange": [
"1.97.10",
"1.109.1"
]
"versionRange": ["1.97.10", "1.109.1"]
},
{
"image": "/blog/assets902eb746fe2042fc2ea831c71002be72.webp",
"id": "2025-07-08-mcp-market",
"date": "2025-07-08",
"versionRange": [
"1.93.3",
"1.97.10"
]
"versionRange": ["1.93.3", "1.97.10"]
},
{
"image": "/blog/assets5cc27b8cae995074da20d4ffe06a1460.webp",
"id": "2025-06-08-claude-4",
"date": "2025-06-08",
"versionRange": [
"1.84.27",
"1.93.3"
]
"versionRange": ["1.84.27", "1.93.3"]
},
{
"image": "/blog/assets2a36d86a4eed6e7938dd6e9c684701ed.webp",
"id": "2025-05-08-desktop-app",
"date": "2025-05-08",
"versionRange": [
"1.77.17",
"1.84.27"
]
"versionRange": ["1.77.17", "1.84.27"]
},
{
"image": "/blog/assetsc0efdb82443556ae3acefe00099b3f23.webp",
"id": "2025-04-06-exports",
"date": "2025-04-06",
"versionRange": [
"1.67.2",
"1.77.17"
]
"versionRange": ["1.67.2", "1.77.17"]
},
{
"image": "/blog/assetse743f0a47127390dde766a0a790476db.webp",
"id": "2025-03-02-new-models",
"date": "2025-03-02",
"versionRange": [
"1.49.13",
"1.67.2"
]
"versionRange": ["1.49.13", "1.67.2"]
},
{
"image": "/blog/assets18168d5fe64ea34905a7e52fd82d0e9d.webp",
"id": "2025-02-02-deepseek-r1",
"date": "2025-02-02",
"versionRange": [
"1.47.8",
"1.49.12"
]
"versionRange": ["1.47.8", "1.49.12"]
},
{
"image": "/blog/assetsf9ed064fe764cbeff2f46910e7099a91.webp",
"id": "2025-01-22-new-ai-provider",
"date": "2025-01-22",
"versionRange": [
"1.43.1",
"1.47.7"
]
"versionRange": ["1.43.1", "1.47.7"]
},
{
"image": "/blog/assets2d409f43b58953ad5396c6beab8a0719.webp",
"id": "2025-01-03-user-profile",
"date": "2025-01-03",
"versionRange": [
"1.34.1",
"1.43.0"
]
"versionRange": ["1.34.1", "1.43.0"]
},
{
"image": "/blog/assets/d9cbfcbef130183bc490d515d8a38aa4.webp",
"id": "2024-11-27-forkable-chat",
"date": "2024-11-27",
"versionRange": [
"1.33.1",
"1.34.0"
]
"versionRange": ["1.33.1", "1.34.0"]
},
{
"image": "/blog/assets/2d678631c55369ba7d753c3ffcb73782.webp",
"id": "2024-11-25-november-providers",
"date": "2024-11-25",
"versionRange": [
"1.30.1",
"1.33.0"
]
"versionRange": ["1.30.1", "1.33.0"]
},
{
"image": "/blog/assets/f10a4b98782e36797c38071eed785c6f.webp",
"id": "2024-11-06-share-text-json",
"date": "2024-11-06",
"versionRange": [
"1.26.1",
"1.28.0"
]
"versionRange": ["1.26.1", "1.28.0"]
},
{
"image": "/blog/assets/944c671604833cd2457445b211ebba33.webp",
"id": "2024-10-27-pin-assistant",
"date": "2024-10-27",
"versionRange": [
"1.19.1",
"1.26.0"
]
"versionRange": ["1.19.1", "1.26.0"]
},
{
"image": "/blog/assets/f6d047a345e47a52592cff916c9a64ce.webp",
"id": "2024-09-20-artifacts",
"date": "2024-09-20",
"versionRange": [
"1.17.1",
"1.19.0"
]
"versionRange": ["1.17.1", "1.19.0"]
},
{
"image": "/blog/assets/d7e57f8e69f97b76b3c2414f3441b6e4.webp",
"id": "2024-09-13-openai-o1-models",
"date": "2024-09-13",
"versionRange": [
"1.12.1",
"1.17.0"
]
"versionRange": ["1.12.1", "1.17.0"]
},
{
"image": "/blog/assets/d6129350de510a62fe87b2d2f0fb9477.webp",
"id": "2024-08-21-file-upload-and-knowledge-base",
"date": "2024-08-21",
"versionRange": [
"1.8.1",
"1.12.0"
]
"versionRange": ["1.8.1", "1.12.0"]
},
{
"image": "/blog/assets/37d85fdfccff9ed56e9c6827faee01c7.webp",
"id": "2024-08-02-lobe-chat-database-docker",
"date": "2024-08-02",
"versionRange": [
"1.6.1",
"1.8.0"
]
"versionRange": ["1.6.1", "1.8.0"]
},
{
"image": "/blog/assets/39d7890f8cbe21e77db8d3c94f7f22e4.webp",
"id": "2024-07-19-gpt-4o-mini",
"date": "2024-07-19",
"versionRange": [
"1.0.1",
"1.6.0"
]
"versionRange": ["1.0.1", "1.6.0"]
},
{
"image": "/blog/assets/eb477e62217f4d1b644eff975c7ac168.webp",
"id": "2024-06-19-lobe-chat-v1",
"date": "2024-06-19",
"versionRange": [
"0.147.0",
"1.0.0"
]
"versionRange": ["0.147.0", "1.0.0"]
},
{
"image": "/blog/assets/8a8d361b4c0cce6da350cc0de65c0ad6.webp",
"id": "2024-02-14-ollama",
"date": "2024-02-14",
"versionRange": [
"0.125.1",
"0.127.0"
]
"versionRange": ["0.125.1", "0.127.0"]
},
{
"image": "/blog/assets/9498087e85f27e692716a63cb3b58d79.webp",
"id": "2024-02-08-sso-oauth",
"date": "2024-02-08",
"versionRange": [
"0.118.1",
"0.125.0"
]
"versionRange": ["0.118.1", "0.125.0"]
},
{
"image": "/blog/assets/603fefbb944bc6761ebdab5956fc0084.webp",
"id": "2023-12-22-dalle-3",
"date": "2023-12-22",
"versionRange": [
"0.102.1",
"0.118.0"
]
"versionRange": ["0.102.1", "0.118.0"]
},
{
"image": "/blog/assets/8d4c2cc0ce8654fa8ac06cc036a7f941.webp",
"id": "2023-11-19-tts-stt",
"date": "2023-11-19",
"versionRange": [
"0.101.1",
"0.102.0"
]
"versionRange": ["0.101.1", "0.102.0"]
},
{
"image": "/blog/assets/d47654360d626f80144cdedb979a3526.webp",
"id": "2023-11-14-gpt4-vision",
"date": "2023-11-14",
"versionRange": [
"0.90.0",
"0.101.0"
]
"versionRange": ["0.90.0", "0.101.0"]
},
{
"image": "/blog/assets/50b38eac1769ae6f13aef72f3d725eec.webp",
"id": "2023-09-09-plugin-system",
"date": "2023-09-09",
"versionRange": [
"0.67.0",
"0.72.0"
]
"versionRange": ["0.67.0", "0.72.0"]
}
]
}

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