mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 17:47:27 +00:00
🚀 release: 20260327 (#13330)
# 🚀 release: 20260326 This release includes **91 commits**. Key updates are below. - **Agent can now execute background tasks** — Agents can perform long-running operations without blocking your conversation. [#13289](https://github.com/lobehub/lobe-chat/pull/13289) - **Better error messages** — Redesigned error UI across chat and image generation with clearer explanations and recovery options. [#13302](https://github.com/lobehub/lobe-chat/pull/13302) - **Smoother topic switching** — No more full page reloads when switching topics while an agent is responding. [#13309](https://github.com/lobehub/lobe-chat/pull/13309) - **Faster image uploads** — Large images are now automatically compressed to 1920px before upload, reducing wait times. [#13224](https://github.com/lobehub/lobe-chat/pull/13224) - **Improved knowledge base** — Documents are now properly parsed before chunking, improving retrieval accuracy. [#13221](https://github.com/lobehub/lobe-chat/pull/13221) ### Bot Platform - **WeChat Bot support** — You can now connect LobeChat to WeChat, in addition to Discord. [#13191](https://github.com/lobehub/lobe-chat/pull/13191) - **Richer bot responses** — Bots now support custom markdown rendering and context injection. [#13294](https://github.com/lobehub/lobe-chat/pull/13294) - **New bot commands** — Added `/new` to start fresh conversations and `/stop` to halt generation. [#13194](https://github.com/lobehub/lobe-chat/pull/13194) - **Discord stability fixes** — Fixed thread creation issues and Redis connection drops. [#13228](https://github.com/lobehub/lobe-chat/pull/13228) [#13205](https://github.com/lobehub/lobe-chat/pull/13205) ### Models & Providers - **GLM-5** is now available in the LobeHub model list. [#13189](https://github.com/lobehub/lobe-chat/pull/13189) - **Coding Plan providers** — Added support for code planning assistant providers. [#13203](https://github.com/lobehub/lobe-chat/pull/13203) - **Tencent Hunyuan 3.0 ImageGen** — New image generation model from Tencent. [#13166](https://github.com/lobehub/lobe-chat/pull/13166) - **Gemini content handling** — Better handling when Gemini blocks content due to safety filters. [#13270](https://github.com/lobehub/lobe-chat/pull/13270) - **Claude token limits fixed** — Corrected max window tokens for Anthropic Claude models. [#13206](https://github.com/lobehub/lobe-chat/pull/13206) ### Skills & Tools - **Auto credential injection** — Skills can now automatically request and use required credentials. [#13124](https://github.com/lobehub/lobe-chat/pull/13124) - **Smarter tool permissions** — Built-in tools skip confirmation for safe paths like `/tmp`. [#13232](https://github.com/lobehub/lobe-chat/pull/13232) - **Model switcher improvements** — Quick access to provider settings and visual highlight for default model. [#13220](https://github.com/lobehub/lobe-chat/pull/13220) ### Memory - **Bulk delete memories** — You can now delete all memory entries at once. [#13161](https://github.com/lobehub/lobe-chat/pull/13161) - **Per-agent memory control** — Memory injection now respects individual agent settings. [#13265](https://github.com/lobehub/lobe-chat/pull/13265) ### Desktop App - **Gateway connection** — Desktop app can now connect to LobeHub Gateway for enhanced features. [#13234](https://github.com/lobehub/lobe-chat/pull/13234) - **Connection status indicator** — See gateway connection status in the titlebar. [#13260](https://github.com/lobehub/lobe-chat/pull/13260) - **Settings persistence** — Gateway toggle state now persists across app restarts. [#13300](https://github.com/lobehub/lobe-chat/pull/13300) ### CLI - **API key authentication** — CLI now supports API key auth for programmatic access. [#13190](https://github.com/lobehub/lobe-chat/pull/13190) - **Shell completion** — Tab completion for bash/zsh/fish shells. [#13164](https://github.com/lobehub/lobe-chat/pull/13164) - **Man pages** — Built-in manual pages for CLI commands. [#13200](https://github.com/lobehub/lobe-chat/pull/13200) ### Security - **XSS protection** — Sanitized search result image titles to prevent script injection. [#13303](https://github.com/lobehub/lobe-chat/pull/13303) - **Workflow hardening** — Fixed potential shell injection in release automation. [#13319](https://github.com/lobehub/lobe-chat/pull/13319) - **Dependency update** — Updated nodemailer to address security advisory. [#13326](https://github.com/lobehub/lobe-chat/pull/13326) ### Bug Fixes - Fixed skill page not redirecting correctly after import. [#13255](https://github.com/lobehub/lobe-chat/pull/13255) [#13261](https://github.com/lobehub/lobe-chat/pull/13261) - Fixed token counting in group chats. [#13247](https://github.com/lobehub/lobe-chat/pull/13247) - Fixed editor not resetting when switching to empty pages. [#13229](https://github.com/lobehub/lobe-chat/pull/13229) - Fixed manual tool toggle not working. [#13218](https://github.com/lobehub/lobe-chat/pull/13218) - Fixed Search1API response parsing. [#13207](https://github.com/lobehub/lobe-chat/pull/13207) [#13208](https://github.com/lobehub/lobe-chat/pull/13208) - Fixed mobile topic menus rendering issues. [#12477](https://github.com/lobehub/lobe-chat/pull/12477) - Fixed history count calculation for accurate context. [#13051](https://github.com/lobehub/lobe-chat/pull/13051) - Added missing Turkish translations. [#13196](https://github.com/lobehub/lobe-chat/pull/13196) ### Credits Huge thanks to these contributors: @bakiburakogun @hardy-one @Zhouguanyang @sxjeru @hezhijie0327 @arvinxx @cy948 @CanisMinor @Innei @LiJian @lobehubbot @Neko @rdmclin2 @rivertwilight @tjx666
This commit is contained in:
commit
feb50e7007
1021 changed files with 44780 additions and 9160 deletions
|
|
@ -200,20 +200,85 @@ The base directory (`~/.lobehub/`) can be overridden with the `LOBEHUB_CLI_HOME`
|
|||
|
||||
## Development
|
||||
|
||||
### Running in Dev Mode
|
||||
|
||||
Dev mode uses `LOBEHUB_CLI_HOME=.lobehub-dev` to isolate credentials from the global `~/.lobehub/` directory, so dev and production configs never conflict.
|
||||
|
||||
```bash
|
||||
# Run directly (dev mode, uses ~/.lobehub-dev for credentials)
|
||||
# Run a command in dev mode (from apps/cli/)
|
||||
cd apps/cli && bun run dev -- <command>
|
||||
|
||||
# Build
|
||||
# This is equivalent to:
|
||||
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts <command>
|
||||
```
|
||||
|
||||
### Connecting to Local Dev Server
|
||||
|
||||
To test CLI against a local dev server (e.g. `localhost:3011`):
|
||||
|
||||
**Step 1: Start the local server**
|
||||
|
||||
```bash
|
||||
# From cloud repo root
|
||||
bun run dev
|
||||
# Server starts on http://localhost:3011 (or configured port)
|
||||
```
|
||||
|
||||
**Step 2: Login to local server via Device Code Flow**
|
||||
|
||||
```bash
|
||||
cd apps/cli && bun run dev -- login --server http://localhost:3011
|
||||
```
|
||||
|
||||
This will:
|
||||
|
||||
1. Call `POST http://localhost:3011/oidc/device/auth` to get a device code
|
||||
2. Print a URL like `http://localhost:3011/oidc/device?user_code=XXXX-YYYY`
|
||||
3. Open the URL in your browser — log in and authorize
|
||||
4. Save credentials to `apps/cli/.lobehub-dev/credentials.json`
|
||||
5. Save server URL to `apps/cli/.lobehub-dev/settings.json`
|
||||
|
||||
After login, all subsequent `bun run dev -- <command>` calls will use the local server.
|
||||
|
||||
**Step 3: Run commands against local server**
|
||||
|
||||
```bash
|
||||
cd apps/cli && bun run dev -- task list
|
||||
cd apps/cli && bun run dev -- task create -i "Test task" -n "My Task"
|
||||
cd apps/cli && bun run dev -- agent list
|
||||
```
|
||||
|
||||
**Troubleshooting:**
|
||||
|
||||
- If login returns `invalid_grant`, make sure the local OIDC provider is properly configured (check `OIDC_*` env vars in `.env`)
|
||||
- If you get `UNAUTHORIZED` on API calls, your token may have expired — run `bun run dev -- login --server http://localhost:3011` again
|
||||
- Dev credentials are stored in `apps/cli/.lobehub-dev/` (gitignored), not in `~/.lobehub/`
|
||||
|
||||
### Switching Between Local and Production
|
||||
|
||||
```bash
|
||||
# Dev mode (local server) — uses .lobehub-dev/
|
||||
cd apps/cli && bun run dev -- <command>
|
||||
|
||||
# Production (app.lobehub.com) — uses ~/.lobehub/
|
||||
lh <command>
|
||||
```
|
||||
|
||||
The two environments are completely isolated by different credential directories.
|
||||
|
||||
### Build & Test
|
||||
|
||||
```bash
|
||||
# Build CLI
|
||||
cd apps/cli && bun run build
|
||||
|
||||
# Test (unit tests)
|
||||
# Unit tests
|
||||
cd apps/cli && bun run test
|
||||
|
||||
# E2E tests (requires authenticated CLI)
|
||||
cd apps/cli && bunx vitest run e2e/kb.e2e.test.ts
|
||||
|
||||
# Link globally for testing
|
||||
# Link globally for testing (installs lh/lobe/lobehub commands)
|
||||
cd apps/cli && bun run cli:link
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@ description: 'Code review checklist for LobeHub. Use when reviewing PRs, diffs,
|
|||
- Keys added to `src/locales/default/{namespace}.ts` with `{feature}.{context}.{action|status}` naming
|
||||
- For PRs: `locales/` translations for all languages updated (`pnpm i18n`)
|
||||
|
||||
### SPA / routing
|
||||
|
||||
- **`desktopRouter` pair:** If the diff touches `src/spa/router/desktopRouter.config.tsx`, does it also update `src/spa/router/desktopRouter.config.desktop.tsx` with the same route paths and nesting? Single-file edits often cause drift and blank screens.
|
||||
|
||||
### Reuse
|
||||
|
||||
- Newly written code duplicates existing utilities in `packages/utils` or shared modules?
|
||||
|
|
|
|||
|
|
@ -101,10 +101,6 @@ DROP TABLE "old_table";
|
|||
CREATE INDEX "users_email_idx" ON "users" ("email");
|
||||
```
|
||||
|
||||
## Step 4: Regenerate Client After SQL Edits
|
||||
## Step 4: Update Journal Tag
|
||||
|
||||
After modifying the generated SQL (e.g., adding `IF NOT EXISTS`), regenerate the client:
|
||||
|
||||
```bash
|
||||
bun run db:generate:client
|
||||
```
|
||||
After renaming the migration SQL file in Step 2, update the `tag` field in `packages/database/migrations/meta/_journal.json` to match the new filename (without `.sql` extension).
|
||||
|
|
|
|||
|
|
@ -32,15 +32,28 @@ Hybrid routing: Next.js App Router (static pages) + React Router DOM (main SPA).
|
|||
| Route Type | Use Case | Implementation |
|
||||
| ------------------ | --------------------------------- | ---------------------------- |
|
||||
| Next.js App Router | Auth pages (login, signup, oauth) | `src/app/[variants]/(auth)/` |
|
||||
| React Router DOM | Main SPA (chat, settings) | `desktopRouter.config.tsx` |
|
||||
| React Router DOM | Main SPA (chat, settings) | `desktopRouter.config.tsx` + `desktopRouter.config.desktop.tsx` (must match) |
|
||||
|
||||
### Key Files
|
||||
|
||||
- Entry: `src/spa/entry.web.tsx` (web), `src/spa/entry.mobile.tsx`, `src/spa/entry.desktop.tsx`
|
||||
- Desktop router: `src/spa/router/desktopRouter.config.tsx`
|
||||
- Desktop router (pair — **always edit both** when changing routes): `src/spa/router/desktopRouter.config.tsx` (dynamic imports) and `src/spa/router/desktopRouter.config.desktop.tsx` (sync imports). Drift can cause unregistered routes / blank screen.
|
||||
- Mobile router: `src/spa/router/mobileRouter.config.tsx`
|
||||
- Router utilities: `src/utils/router.tsx`
|
||||
|
||||
### `.desktop.{ts,tsx}` File Sync Rule
|
||||
|
||||
**CRITICAL**: Some files have a `.desktop.ts(x)` variant that Electron uses instead of the base file. When editing a base file, **always check** if a `.desktop` counterpart exists and update it in sync. Drift causes blank pages or missing features in Electron.
|
||||
|
||||
Known pairs that must stay in sync:
|
||||
|
||||
| Base file (web, dynamic imports) | Desktop file (Electron, sync imports) |
|
||||
| --- | --- |
|
||||
| `src/spa/router/desktopRouter.config.tsx` | `src/spa/router/desktopRouter.config.desktop.tsx` |
|
||||
| `src/routes/(main)/settings/features/componentMap.ts` | `src/routes/(main)/settings/features/componentMap.desktop.ts` |
|
||||
|
||||
**How to check**: After editing any `.ts` / `.tsx` file, run `Glob` for `<filename>.desktop.{ts,tsx}` in the same directory. If a match exists, update it with the equivalent sync-import change.
|
||||
|
||||
### Router Utilities
|
||||
|
||||
```tsx
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
name: spa-routes
|
||||
description: SPA route and feature structure. Use when adding or modifying SPA routes in src/routes, defining new route segments, or moving route logic into src/features. Covers how to keep routes thin and how to divide files between routes and features.
|
||||
description: MUST use when editing src/routes/ segments, src/spa/router/desktopRouter.config.tsx or desktopRouter.config.desktop.tsx (always change both together), mobileRouter.config.tsx, or when moving UI/logic between routes and src/features/.
|
||||
---
|
||||
|
||||
# SPA Routes and Features Guide
|
||||
|
|
@ -13,6 +13,8 @@ SPA structure:
|
|||
|
||||
This project uses a **roots vs features** split: `src/routes/` only holds page segments; business logic and UI live in `src/features/` by domain.
|
||||
|
||||
**Agent constraint — desktop router parity:** Edits to the desktop route tree must update **both** `src/spa/router/desktopRouter.config.tsx` and `src/spa/router/desktopRouter.config.desktop.tsx` in the same change (same paths, nesting, index routes, and segment registration). Updating only one causes drift; the missing tree can fail to register routes and surface as a **blank screen** or broken navigation on the affected build.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
- Adding a new SPA route or route segment
|
||||
|
|
@ -73,8 +75,21 @@ Each feature should:
|
|||
- Layout: `export { default } from '@/features/MyFeature/MyLayout'` or compose a few feature components + `<Outlet />`.
|
||||
- Page: import from `@/features/MyFeature` (or a specific subpath) and render; no business logic in the route file.
|
||||
|
||||
5. **Register the route**
|
||||
- Add the segment to `src/spa/router/desktopRouter.config.tsx` (or the right router config) with `dynamicElement` / `dynamicLayout` pointing at the new route paths (e.g. `@/routes/(main)/my-feature`).
|
||||
5. **Register the route (desktop — two files, always)**
|
||||
- **`desktopRouter.config.tsx`:** Add the segment with `dynamicElement` / `dynamicLayout` pointing at route modules (e.g. `@/routes/(main)/my-feature`).
|
||||
- **`desktopRouter.config.desktop.tsx`:** Mirror the **same** `RouteObject` shape: identical `path` / `index` / parent-child structure. Use the static imports and elements already used in that file (see neighboring routes). Do **not** register in only one of these files.
|
||||
- **Mobile-only flows:** use `mobileRouter.config.tsx` instead (no need to duplicate into the desktop pair unless the route truly exists on both).
|
||||
|
||||
---
|
||||
|
||||
## 3a. Desktop router pair (`desktopRouter.config` × 2)
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `desktopRouter.config.tsx` | Dynamic imports via `dynamicElement` / `dynamicLayout` — code-splitting; used by `entry.web.tsx` and `entry.desktop.tsx`. |
|
||||
| `desktopRouter.config.desktop.tsx` | Same route tree with **synchronous** imports — kept for Electron / local parity and predictable bundling. |
|
||||
|
||||
Anything that changes the tree (new segment, renamed `path`, moved layout, new child route) must be reflected in **both** files in one PR or commit. Remove routes from both when deleting.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
123
.agents/skills/trpc-router/SKILL.md
Normal file
123
.agents/skills/trpc-router/SKILL.md
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
---
|
||||
name: trpc-router
|
||||
description: TRPC router development guide. Use when creating or modifying TRPC routers (src/server/routers/**), adding procedures, or working with server-side API endpoints. Triggers on TRPC router creation, procedure implementation, or API endpoint tasks.
|
||||
---
|
||||
|
||||
# TRPC Router Guide
|
||||
|
||||
## File Location
|
||||
|
||||
- Routers: `src/server/routers/lambda/<domain>.ts`
|
||||
- Helpers: `src/server/routers/lambda/_helpers/`
|
||||
- Schemas: `src/server/routers/lambda/_schema/`
|
||||
|
||||
## Router Structure
|
||||
|
||||
### Imports
|
||||
|
||||
```typescript
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { SomeModel } from '@/database/models/some';
|
||||
import { authedProcedure, router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
```
|
||||
|
||||
### Middleware: Inject Models into ctx
|
||||
|
||||
**Always use middleware to inject models into `ctx`** instead of creating `new Model(ctx.serverDB, ctx.userId)` inside every procedure.
|
||||
|
||||
```typescript
|
||||
const domainProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
|
||||
const { ctx } = opts;
|
||||
return opts.next({
|
||||
ctx: {
|
||||
fooModel: new FooModel(ctx.serverDB, ctx.userId),
|
||||
barModel: new BarModel(ctx.serverDB, ctx.userId),
|
||||
},
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Then use `ctx.fooModel` in procedures:
|
||||
|
||||
```typescript
|
||||
// Good
|
||||
const model = ctx.fooModel;
|
||||
|
||||
// Bad - don't create models inside procedures
|
||||
const model = new FooModel(ctx.serverDB, ctx.userId);
|
||||
```
|
||||
|
||||
**Exception**: When a model needs a different `userId` (e.g., watchdog iterating over multiple users' tasks), create it inline.
|
||||
|
||||
### Procedure Pattern
|
||||
|
||||
```typescript
|
||||
export const fooRouter = router({
|
||||
// Query
|
||||
find: domainProcedure.input(z.object({ id: z.string() })).query(async ({ input, ctx }) => {
|
||||
try {
|
||||
const item = await ctx.fooModel.findById(input.id);
|
||||
if (!item) throw new TRPCError({ code: 'NOT_FOUND', message: 'Not found' });
|
||||
return { data: item, success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
console.error('[foo:find]', error);
|
||||
throw new TRPCError({
|
||||
cause: error,
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Failed to find item',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
// Mutation
|
||||
create: domainProcedure.input(createSchema).mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const item = await ctx.fooModel.create(input);
|
||||
return { data: item, message: 'Created', success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
console.error('[foo:create]', error);
|
||||
throw new TRPCError({
|
||||
cause: error,
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Failed to create',
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
### Aggregated Detail Endpoint
|
||||
|
||||
For views that need multiple related data, create a single `detail` procedure that fetches everything in parallel:
|
||||
|
||||
```typescript
|
||||
detail: domainProcedure.input(idInput).query(async ({ input, ctx }) => {
|
||||
const item = await resolveOrThrow(ctx.fooModel, input.id);
|
||||
|
||||
const [children, related] = await Promise.all([
|
||||
ctx.fooModel.findChildren(item.id),
|
||||
ctx.barModel.findByFooId(item.id),
|
||||
]);
|
||||
|
||||
return {
|
||||
data: { ...item, children, related },
|
||||
success: true,
|
||||
};
|
||||
}),
|
||||
```
|
||||
|
||||
This avoids the CLI or frontend making N sequential requests.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Return shape: `{ data, success: true }` for queries, `{ data?, message, success: true }` for mutations
|
||||
- Error handling: re-throw `TRPCError`, wrap others with `console.error` + new `TRPCError`
|
||||
- Input validation: use `zod` schemas, define at file top
|
||||
- Router name: `export const fooRouter = router({ ... })`
|
||||
- Procedure names: alphabetical order within the router object
|
||||
- Log prefix: `[domain:procedure]` format, e.g. `[task:create]`
|
||||
8
.github/workflows/auto-tag-release.yml
vendored
8
.github/workflows/auto-tag-release.yml
vendored
|
|
@ -26,8 +26,9 @@ jobs:
|
|||
|
||||
- name: Detect release PR (version from title)
|
||||
id: release
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
run: |
|
||||
PR_TITLE="${{ github.event.pull_request.title }}"
|
||||
echo "PR Title: $PR_TITLE"
|
||||
|
||||
# Match "🚀 release: v{x.x.x}" format (strict semver: x.y.z with optional -prerelease or +build)
|
||||
|
|
@ -44,9 +45,10 @@ jobs:
|
|||
- name: Detect patch PR (branch first, title fallback)
|
||||
id: patch
|
||||
if: steps.release.outputs.should_tag != 'true'
|
||||
env:
|
||||
HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
run: |
|
||||
HEAD_REF="${{ github.event.pull_request.head.ref }}"
|
||||
PR_TITLE="${{ github.event.pull_request.title }}"
|
||||
echo "Head ref: $HEAD_REF"
|
||||
echo "PR Title: $PR_TITLE"
|
||||
|
||||
|
|
|
|||
8
.github/workflows/claude.yml
vendored
8
.github/workflows/claude.yml
vendored
|
|
@ -19,9 +19,9 @@ jobs:
|
|||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
|
|
@ -55,5 +55,5 @@ jobs:
|
|||
# Security: Allow only specific safe commands - no gh commands to prevent token exfiltration
|
||||
# These tools are restricted to code analysis and build operations only
|
||||
claude_args: |
|
||||
--allowedTools "Bash(bun run:*),Bash(pnpm run:*),Bash(npm run:*),Bash(npx:*),Bash(bunx:*),Bash(vitest:*),Bash(rg:*),Bash(find:*),Bash(sed:*),Bash(grep:*),Bash(awk:*),Bash(wc:*),Bash(xargs:*)"
|
||||
--allowedTools "Bash(git:*),Bash(gh:*),Bash(bun run:*),Bash(pnpm run:*),Bash(npm run:*),Bash(npx:*),Bash(bunx:*),Bash(vitest:*),Bash(rg:*),Bash(find:*),Bash(sed:*),Bash(grep:*),Bash(awk:*),Bash(wc:*),Bash(xargs:*)"
|
||||
--append-system-prompt "$(cat /tmp/claude-prompts/security-rules.md)"
|
||||
|
|
|
|||
44
apps/cli/README.md
Normal file
44
apps/cli/README.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# @lobehub/cli
|
||||
|
||||
LobeHub command-line interface.
|
||||
|
||||
## Local Development
|
||||
|
||||
| Task | Command |
|
||||
| ------------------------------------------ | -------------------------- |
|
||||
| Run in dev mode | `bun run dev -- <command>` |
|
||||
| Build the CLI | `bun run build` |
|
||||
| Link `lh`/`lobe`/`lobehub` into your shell | `bun run cli:link` |
|
||||
| Remove the global link | `bun run cli:unlink` |
|
||||
|
||||
- `bun run build` only generates `dist/index.js`.
|
||||
- To make `lh` available in your shell, run `bun run cli:link`.
|
||||
- After linking, if your shell still cannot find `lh`, run `rehash` in `zsh`.
|
||||
|
||||
## Shell Completion
|
||||
|
||||
### Install completion for a linked CLI
|
||||
|
||||
| Shell | Command |
|
||||
| ------ | ------------------------------ |
|
||||
| `zsh` | `source <(lh completion zsh)` |
|
||||
| `bash` | `source <(lh completion bash)` |
|
||||
|
||||
### Use completion during local development
|
||||
|
||||
| Shell | Command |
|
||||
| ------ | -------------------------------------------- |
|
||||
| `zsh` | `source <(bun src/index.ts completion zsh)` |
|
||||
| `bash` | `source <(bun src/index.ts completion bash)` |
|
||||
|
||||
- Completion is context-aware. For example, `lh agent <Tab>` shows agent subcommands instead of top-level commands.
|
||||
- If you update completion logic locally, re-run the corresponding `source <(...)` command to reload it in the current shell session.
|
||||
- Completion only registers shell functions. It does not install the `lh` binary by itself.
|
||||
|
||||
## Quick Check
|
||||
|
||||
```bash
|
||||
which lh
|
||||
lh --help
|
||||
lh agent <TAB>
|
||||
```
|
||||
160
apps/cli/man/man1/lh.1
Normal file
160
apps/cli/man/man1/lh.1
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
.\" 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.1\-canary.14" "User Commands"
|
||||
.SH NAME
|
||||
lh \- LobeHub CLI \- manage and connect to LobeHub services
|
||||
.SH SYNOPSIS
|
||||
.B lh
|
||||
[\fIOPTION\fR]...
|
||||
[\fICOMMAND\fR]
|
||||
.br
|
||||
.B lobe
|
||||
[\fIOPTION\fR]...
|
||||
[\fICOMMAND\fR]
|
||||
.br
|
||||
.B lobehub
|
||||
[\fIOPTION\fR]...
|
||||
[\fICOMMAND\fR]
|
||||
.SH DESCRIPTION
|
||||
lh is the command\-line interface for LobeHub. It provides authentication, device gateway connectivity, content generation, resource search, and management commands for agents, files, models, providers, plugins, knowledge bases, threads, topics, and related resources.
|
||||
.PP
|
||||
For command-specific manuals, use the built-in manual command:
|
||||
.PP
|
||||
.RS
|
||||
.B lh man
|
||||
[\fICOMMAND\fR]...
|
||||
.RE
|
||||
.SH COMMANDS
|
||||
.TP
|
||||
.B login
|
||||
Log in to LobeHub via browser (Device Code Flow) or configure API key server
|
||||
.TP
|
||||
.B logout
|
||||
Log out and remove stored credentials
|
||||
.TP
|
||||
.B completion
|
||||
Output shell completion script
|
||||
.TP
|
||||
.B man
|
||||
Show a manual page for the CLI or a subcommand
|
||||
.TP
|
||||
.B connect
|
||||
Connect to the device gateway and listen for tool calls
|
||||
.TP
|
||||
.B device
|
||||
Manage connected devices
|
||||
.TP
|
||||
.B status
|
||||
Check if gateway connection can be established
|
||||
.TP
|
||||
.B doc
|
||||
Manage documents
|
||||
.TP
|
||||
.B search
|
||||
Search across local resources or the web
|
||||
.TP
|
||||
.B kb
|
||||
Manage knowledge bases, folders, documents, and files
|
||||
.TP
|
||||
.B memory
|
||||
Manage user memories
|
||||
.TP
|
||||
.B agent
|
||||
Manage agents
|
||||
.TP
|
||||
.B agent\-group
|
||||
Manage agent groups
|
||||
.TP
|
||||
.B bot
|
||||
Manage bot integrations
|
||||
.TP
|
||||
.B cron
|
||||
Manage agent cron jobs
|
||||
.TP
|
||||
.B generate
|
||||
Generate content (text, image, video, speech) Alias: gen.
|
||||
.TP
|
||||
.B file
|
||||
Manage files
|
||||
.TP
|
||||
.B skill
|
||||
Manage agent skills
|
||||
.TP
|
||||
.B session\-group
|
||||
Manage agent session groups
|
||||
.TP
|
||||
.B thread
|
||||
Manage message threads
|
||||
.TP
|
||||
.B topic
|
||||
Manage conversation topics
|
||||
.TP
|
||||
.B message
|
||||
Manage messages
|
||||
.TP
|
||||
.B model
|
||||
Manage AI models
|
||||
.TP
|
||||
.B provider
|
||||
Manage AI providers
|
||||
.TP
|
||||
.B plugin
|
||||
Manage plugins
|
||||
.TP
|
||||
.B user
|
||||
Manage user account and settings
|
||||
.TP
|
||||
.B whoami
|
||||
Display current user information
|
||||
.TP
|
||||
.B usage
|
||||
View usage statistics
|
||||
.TP
|
||||
.B eval
|
||||
Manage evaluation workflows
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
.B \-V, \-\-version
|
||||
output the version number
|
||||
.TP
|
||||
.B \-h, \-\-help
|
||||
display help for command
|
||||
.SH FILES
|
||||
.TP
|
||||
.I ~/.lobehub/credentials.json
|
||||
Encrypted access and refresh tokens.
|
||||
.TP
|
||||
.I ~/.lobehub/settings.json
|
||||
CLI settings such as server and gateway URLs.
|
||||
.TP
|
||||
.I ~/.lobehub/daemon.pid
|
||||
Background daemon PID file.
|
||||
.TP
|
||||
.I ~/.lobehub/daemon.status
|
||||
Background daemon status metadata.
|
||||
.TP
|
||||
.I ~/.lobehub/daemon.log
|
||||
Background daemon log output.
|
||||
.PP
|
||||
The base directory can be overridden with the
|
||||
.B LOBEHUB_CLI_HOME
|
||||
environment variable.
|
||||
.SH EXAMPLES
|
||||
.TP
|
||||
.B lh login
|
||||
Start interactive login in the browser.
|
||||
.TP
|
||||
.B lh connect \-\-daemon
|
||||
Start the device gateway connection in the background.
|
||||
.TP
|
||||
.B lh search \-q "gpt\-5"
|
||||
Search local resources for a query.
|
||||
.TP
|
||||
.B lh generate text "Write release notes"
|
||||
Generate text from a prompt.
|
||||
.TP
|
||||
.B lh man generate
|
||||
Show the built\-in manual for the generate command group.
|
||||
.SH SEE ALSO
|
||||
.BR lobe (1),
|
||||
.BR lobehub (1)
|
||||
1
apps/cli/man/man1/lobe.1
Normal file
1
apps/cli/man/man1/lobe.1
Normal file
|
|
@ -0,0 +1 @@
|
|||
.so man1/lh.1
|
||||
1
apps/cli/man/man1/lobehub.1
Normal file
1
apps/cli/man/man1/lobehub.1
Normal file
|
|
@ -0,0 +1 @@
|
|||
.so man1/lh.1
|
||||
|
|
@ -1,43 +1,48 @@
|
|||
{
|
||||
"name": "@lobehub/cli",
|
||||
"version": "0.0.1-canary.12",
|
||||
"version": "0.0.1-canary.14",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"lh": "./dist/index.js",
|
||||
"lobe": "./dist/index.js",
|
||||
"lobehub": "./dist/index.js"
|
||||
},
|
||||
"man": [
|
||||
"./man/man1/lh.1",
|
||||
"./man/man1/lobe.1",
|
||||
"./man/man1/lobehub.1"
|
||||
],
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"man"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npx tsup",
|
||||
"build": "tsdown",
|
||||
"cli:link": "bun link",
|
||||
"cli:unlink": "bun unlink",
|
||||
"dev": "LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts",
|
||||
"prepublishOnly": "npm run build",
|
||||
"man:generate": "bun src/man/generate.ts",
|
||||
"prepublishOnly": "npm run build && npm run man:generate",
|
||||
"test": "bunx vitest run --config vitest.config.mts --silent='passed-only'",
|
||||
"test:coverage": "bunx vitest run --config vitest.config.mts --coverage",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"devDependencies": {
|
||||
"@lobechat/device-gateway-client": "workspace:*",
|
||||
"@lobechat/local-file-shell": "workspace:*",
|
||||
"@trpc/client": "^11.8.1",
|
||||
"@types/node": "^22.13.5",
|
||||
"@types/ws": "^8.18.1",
|
||||
"commander": "^13.1.0",
|
||||
"debug": "^4.4.0",
|
||||
"diff": "^8.0.3",
|
||||
"fast-glob": "^3.3.3",
|
||||
"picocolors": "^1.1.1",
|
||||
"superjson": "^2.2.6",
|
||||
"tsdown": "^0.21.4",
|
||||
"typescript": "^5.9.3",
|
||||
"ws": "^8.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lobechat/device-gateway-client": "workspace:*",
|
||||
"@lobechat/local-file-shell": "workspace:*",
|
||||
"@types/node": "^22.13.5",
|
||||
"@types/ws": "^8.18.1",
|
||||
"tsup": "^8.4.0",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org"
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import type { LambdaRouter } from '@/server/routers/lambda';
|
|||
import type { ToolsRouter } from '@/server/routers/tools';
|
||||
|
||||
import { getValidToken } from '../auth/refresh';
|
||||
import { OFFICIAL_SERVER_URL } from '../constants/urls';
|
||||
import { loadSettings } from '../settings';
|
||||
import { CLI_API_KEY_ENV } from '../constants/auth';
|
||||
import { resolveServerUrl } from '../settings';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
export type TrpcClient = ReturnType<typeof createTRPCClient<LambdaRouter>>;
|
||||
|
|
@ -19,31 +19,46 @@ async function getAuthAndServer() {
|
|||
// LOBEHUB_JWT + LOBEHUB_SERVER env vars (used by server-side sandbox execution)
|
||||
const envJwt = process.env.LOBEHUB_JWT;
|
||||
if (envJwt) {
|
||||
const serverUrl = process.env.LOBEHUB_SERVER || OFFICIAL_SERVER_URL;
|
||||
return { accessToken: envJwt, serverUrl: serverUrl.replace(/\/$/, '') };
|
||||
const serverUrl = resolveServerUrl();
|
||||
|
||||
return {
|
||||
headers: { 'Oidc-Auth': envJwt },
|
||||
serverUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const envApiKey = process.env[CLI_API_KEY_ENV];
|
||||
if (envApiKey) {
|
||||
const serverUrl = resolveServerUrl();
|
||||
|
||||
return {
|
||||
headers: { 'X-API-Key': envApiKey },
|
||||
serverUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await getValidToken();
|
||||
if (!result) {
|
||||
log.error("No authentication found. Run 'lh login' first.");
|
||||
log.error(`No authentication found. Run 'lh login' first, or set ${CLI_API_KEY_ENV}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const accessToken = result.credentials.accessToken;
|
||||
const serverUrl = loadSettings()?.serverUrl || OFFICIAL_SERVER_URL;
|
||||
const serverUrl = resolveServerUrl();
|
||||
|
||||
return { accessToken, serverUrl: serverUrl.replace(/\/$/, '') };
|
||||
return {
|
||||
headers: { 'Oidc-Auth': result.credentials.accessToken },
|
||||
serverUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getTrpcClient(): Promise<TrpcClient> {
|
||||
if (_client) return _client;
|
||||
|
||||
const { accessToken, serverUrl } = await getAuthAndServer();
|
||||
|
||||
const { headers, serverUrl } = await getAuthAndServer();
|
||||
_client = createTRPCClient<LambdaRouter>({
|
||||
links: [
|
||||
httpLink({
|
||||
headers: { 'Oidc-Auth': accessToken },
|
||||
headers,
|
||||
transformer: superjson,
|
||||
url: `${serverUrl}/trpc/lambda`,
|
||||
}),
|
||||
|
|
@ -56,12 +71,11 @@ export async function getTrpcClient(): Promise<TrpcClient> {
|
|||
export async function getToolsTrpcClient(): Promise<ToolsTrpcClient> {
|
||||
if (_toolsClient) return _toolsClient;
|
||||
|
||||
const { accessToken, serverUrl } = await getAuthAndServer();
|
||||
|
||||
const { headers, serverUrl } = await getAuthAndServer();
|
||||
_toolsClient = createTRPCClient<ToolsRouter>({
|
||||
links: [
|
||||
httpLink({
|
||||
headers: { 'Oidc-Auth': accessToken },
|
||||
headers,
|
||||
transformer: superjson,
|
||||
url: `${serverUrl}/trpc/tools`,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { getValidToken } from '../auth/refresh';
|
||||
import { OFFICIAL_SERVER_URL } from '../constants/urls';
|
||||
import { loadSettings } from '../settings';
|
||||
import { CLI_API_KEY_ENV } from '../constants/auth';
|
||||
import { resolveServerUrl } from '../settings';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
// Must match the server's SECRET_XOR_KEY (src/envs/auth.ts)
|
||||
|
|
@ -33,12 +33,19 @@ export interface AuthInfo {
|
|||
export async function getAuthInfo(): Promise<AuthInfo> {
|
||||
const result = await getValidToken();
|
||||
if (!result) {
|
||||
if (process.env[CLI_API_KEY_ENV]) {
|
||||
log.error(
|
||||
`API key auth from ${CLI_API_KEY_ENV} is not supported for /webapi/* routes. Run OIDC login instead.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
log.error("No authentication found. Run 'lh login' first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const accessToken = result!.credentials.accessToken;
|
||||
const serverUrl = loadSettings()?.serverUrl || OFFICIAL_SERVER_URL;
|
||||
const serverUrl = resolveServerUrl();
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
|
|
@ -47,6 +54,6 @@ export async function getAuthInfo(): Promise<AuthInfo> {
|
|||
'Oidc-Auth': accessToken,
|
||||
'X-lobe-chat-auth': obfuscatePayloadWithXOR({}),
|
||||
},
|
||||
serverUrl: serverUrl.replace(/\/$/, ''),
|
||||
serverUrl,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
41
apps/cli/src/auth/apiKey.ts
Normal file
41
apps/cli/src/auth/apiKey.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { normalizeUrl, resolveServerUrl } from '../settings';
|
||||
|
||||
interface CurrentUserResponse {
|
||||
data?: {
|
||||
id?: string;
|
||||
userId?: string;
|
||||
};
|
||||
error?: string;
|
||||
message?: string;
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
export async function getUserIdFromApiKey(apiKey: string, serverUrl?: string): Promise<string> {
|
||||
const normalizedServerUrl = normalizeUrl(serverUrl) || resolveServerUrl();
|
||||
|
||||
const response = await fetch(`${normalizedServerUrl}/api/v1/users/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
let body: CurrentUserResponse | undefined;
|
||||
try {
|
||||
body = (await response.json()) as CurrentUserResponse;
|
||||
} catch {
|
||||
throw new Error(`Failed to parse response from ${normalizedServerUrl}/api/v1/users/me.`);
|
||||
}
|
||||
|
||||
if (!response.ok || body?.success === false) {
|
||||
throw new Error(
|
||||
body?.error || body?.message || `Request failed with status ${response.status}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const userId = body?.data?.id || body?.data?.userId;
|
||||
if (!userId) {
|
||||
throw new Error('Current user response did not include a user id.');
|
||||
}
|
||||
|
||||
return userId;
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import { OFFICIAL_SERVER_URL } from '../constants/urls';
|
||||
import { loadSettings } from '../settings';
|
||||
import { resolveServerUrl } from '../settings';
|
||||
import { loadCredentials, saveCredentials, type StoredCredentials } from './credentials';
|
||||
|
||||
const CLIENT_ID = 'lobehub-cli';
|
||||
|
|
@ -20,7 +19,7 @@ export async function getValidToken(): Promise<{ credentials: StoredCredentials
|
|||
// Token expired — try refresh
|
||||
if (!credentials.refreshToken) return null;
|
||||
|
||||
const serverUrl = loadSettings()?.serverUrl || OFFICIAL_SERVER_URL;
|
||||
const serverUrl = resolveServerUrl();
|
||||
const refreshed = await refreshAccessToken(serverUrl, credentials.refreshToken);
|
||||
if (!refreshed) return null;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,21 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { getUserIdFromApiKey } from './apiKey';
|
||||
import { getValidToken } from './refresh';
|
||||
import { resolveToken } from './resolveToken';
|
||||
|
||||
vi.mock('./apiKey', () => ({
|
||||
getUserIdFromApiKey: vi.fn(),
|
||||
}));
|
||||
vi.mock('./refresh', () => ({
|
||||
getValidToken: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../settings', () => ({
|
||||
loadSettings: vi.fn().mockReturnValue({ serverUrl: 'https://app.lobehub.com' }),
|
||||
resolveServerUrl: vi.fn(() =>
|
||||
(process.env.LOBEHUB_SERVER || 'https://app.lobehub.com').replace(/\/$/, ''),
|
||||
),
|
||||
}));
|
||||
vi.mock('../utils/logger', () => ({
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
|
|
@ -25,14 +34,23 @@ function makeJwt(sub: string): string {
|
|||
|
||||
describe('resolveToken', () => {
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
const originalApiKey = process.env.LOBEHUB_CLI_API_KEY;
|
||||
const originalJwt = process.env.LOBEHUB_JWT;
|
||||
const originalServer = process.env.LOBEHUB_SERVER;
|
||||
|
||||
beforeEach(() => {
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit');
|
||||
});
|
||||
delete process.env.LOBEHUB_CLI_API_KEY;
|
||||
delete process.env.LOBEHUB_JWT;
|
||||
delete process.env.LOBEHUB_SERVER;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.LOBEHUB_CLI_API_KEY = originalApiKey;
|
||||
process.env.LOBEHUB_JWT = originalJwt;
|
||||
process.env.LOBEHUB_SERVER = originalServer;
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
|
|
@ -42,7 +60,12 @@ describe('resolveToken', () => {
|
|||
|
||||
const result = await resolveToken({ token });
|
||||
|
||||
expect(result).toEqual({ token, userId: 'user-123' });
|
||||
expect(result).toEqual({
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
token,
|
||||
tokenType: 'jwt',
|
||||
userId: 'user-123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should exit if JWT has no sub claim', async () => {
|
||||
|
|
@ -67,7 +90,12 @@ describe('resolveToken', () => {
|
|||
userId: 'user-456',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ token: 'svc-token', userId: 'user-456' });
|
||||
expect(result).toEqual({
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
token: 'svc-token',
|
||||
tokenType: 'serviceToken',
|
||||
userId: 'user-456',
|
||||
});
|
||||
});
|
||||
|
||||
it('should exit if --user-id is not provided', async () => {
|
||||
|
|
@ -76,6 +104,37 @@ describe('resolveToken', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('with environment api key', () => {
|
||||
it('should return API key from environment', async () => {
|
||||
process.env.LOBEHUB_CLI_API_KEY = 'sk-lh-test';
|
||||
vi.mocked(getUserIdFromApiKey).mockResolvedValue('user-789');
|
||||
|
||||
const result = await resolveToken({});
|
||||
|
||||
expect(getUserIdFromApiKey).toHaveBeenCalledWith('sk-lh-test', 'https://app.lobehub.com');
|
||||
expect(result).toEqual({
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
token: 'sk-lh-test',
|
||||
tokenType: 'apiKey',
|
||||
userId: 'user-789',
|
||||
});
|
||||
});
|
||||
|
||||
it('should prefer LOBEHUB_SERVER when validating the API key', async () => {
|
||||
process.env.LOBEHUB_CLI_API_KEY = 'sk-lh-test';
|
||||
process.env.LOBEHUB_SERVER = 'https://self-hosted.example.com/';
|
||||
vi.mocked(getUserIdFromApiKey).mockResolvedValue('user-789');
|
||||
|
||||
const result = await resolveToken({});
|
||||
|
||||
expect(getUserIdFromApiKey).toHaveBeenCalledWith(
|
||||
'sk-lh-test',
|
||||
'https://self-hosted.example.com',
|
||||
);
|
||||
expect(result.serverUrl).toBe('https://self-hosted.example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with stored credentials', () => {
|
||||
it('should return stored credentials token', async () => {
|
||||
const token = makeJwt('stored-user');
|
||||
|
|
@ -87,7 +146,12 @@ describe('resolveToken', () => {
|
|||
|
||||
const result = await resolveToken({});
|
||||
|
||||
expect(result).toEqual({ token, userId: 'stored-user' });
|
||||
expect(result).toEqual({
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
token,
|
||||
tokenType: 'jwt',
|
||||
userId: 'stored-user',
|
||||
});
|
||||
});
|
||||
|
||||
it('should exit if stored token has no sub', async () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { CLI_API_KEY_ENV } from '../constants/auth';
|
||||
import { resolveServerUrl } from '../settings';
|
||||
import { log } from '../utils/logger';
|
||||
import { getUserIdFromApiKey } from './apiKey';
|
||||
import { getValidToken } from './refresh';
|
||||
|
||||
interface ResolveTokenOptions {
|
||||
|
|
@ -8,7 +11,9 @@ interface ResolveTokenOptions {
|
|||
}
|
||||
|
||||
interface ResolvedAuth {
|
||||
serverUrl: string;
|
||||
token: string;
|
||||
tokenType: 'apiKey' | 'jwt' | 'serviceToken';
|
||||
userId: string;
|
||||
}
|
||||
|
||||
|
|
@ -25,20 +30,21 @@ function parseJwtSub(token: string): string | undefined {
|
|||
}
|
||||
|
||||
/**
|
||||
* Resolve an access token from explicit options or stored credentials.
|
||||
* Resolve an access token from explicit options, environment variables, or stored credentials.
|
||||
* Exits the process if no token can be resolved.
|
||||
*/
|
||||
export async function resolveToken(options: ResolveTokenOptions): Promise<ResolvedAuth> {
|
||||
// LOBEHUB_JWT env var takes highest priority (used by server-side sandbox execution)
|
||||
const envJwt = process.env.LOBEHUB_JWT;
|
||||
if (envJwt) {
|
||||
const serverUrl = resolveServerUrl();
|
||||
const userId = parseJwtSub(envJwt);
|
||||
if (!userId) {
|
||||
log.error('Could not extract userId from LOBEHUB_JWT.');
|
||||
process.exit(1);
|
||||
}
|
||||
log.debug('Using LOBEHUB_JWT from environment');
|
||||
return { token: envJwt, userId };
|
||||
return { serverUrl, token: envJwt, tokenType: 'jwt', userId };
|
||||
}
|
||||
|
||||
// Explicit token takes priority
|
||||
|
|
@ -48,7 +54,7 @@ export async function resolveToken(options: ResolveTokenOptions): Promise<Resolv
|
|||
log.error('Could not extract userId from token. Provide --user-id explicitly.');
|
||||
process.exit(1);
|
||||
}
|
||||
return { token: options.token, userId };
|
||||
return { serverUrl: resolveServerUrl(), token: options.token, tokenType: 'jwt', userId };
|
||||
}
|
||||
|
||||
if (options.serviceToken) {
|
||||
|
|
@ -56,22 +62,46 @@ export async function resolveToken(options: ResolveTokenOptions): Promise<Resolv
|
|||
log.error('--user-id is required when using --service-token');
|
||||
process.exit(1);
|
||||
}
|
||||
return { token: options.serviceToken, userId: options.userId };
|
||||
return {
|
||||
serverUrl: resolveServerUrl(),
|
||||
token: options.serviceToken,
|
||||
tokenType: 'serviceToken',
|
||||
userId: options.userId,
|
||||
};
|
||||
}
|
||||
|
||||
const envApiKey = process.env[CLI_API_KEY_ENV];
|
||||
if (envApiKey) {
|
||||
try {
|
||||
const serverUrl = resolveServerUrl();
|
||||
const userId = await getUserIdFromApiKey(envApiKey, serverUrl);
|
||||
log.debug(`Using ${CLI_API_KEY_ENV} from environment`);
|
||||
return { serverUrl, token: envApiKey, tokenType: 'apiKey', userId };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error(`Failed to validate ${CLI_API_KEY_ENV}: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Try stored credentials
|
||||
const result = await getValidToken();
|
||||
if (result) {
|
||||
log.debug('Using stored credentials');
|
||||
const token = result.credentials.accessToken;
|
||||
const userId = parseJwtSub(token);
|
||||
const { credentials } = result;
|
||||
const serverUrl = resolveServerUrl();
|
||||
|
||||
const userId = parseJwtSub(credentials.accessToken);
|
||||
if (!userId) {
|
||||
log.error("Stored token is invalid. Run 'lh login' again.");
|
||||
process.exit(1);
|
||||
}
|
||||
return { token, userId };
|
||||
|
||||
return { serverUrl, token: credentials.accessToken, tokenType: 'jwt', userId };
|
||||
}
|
||||
|
||||
log.error("No authentication found. Run 'lh login' first, or provide --token.");
|
||||
log.error(
|
||||
`No authentication found. Run 'lh login' first, or set ${CLI_API_KEY_ENV}, or provide --token.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,14 +5,15 @@ import { getTrpcClient } from '../api/client';
|
|||
import { confirm, outputJson, printTable } from '../utils/format';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
const SUPPORTED_PLATFORMS = ['discord', 'slack', 'telegram', 'lark', 'feishu'];
|
||||
const SUPPORTED_PLATFORMS = ['discord', 'slack', 'telegram', 'lark', 'feishu', 'wechat'];
|
||||
|
||||
const PLATFORM_CREDENTIAL_FIELDS: Record<string, string[]> = {
|
||||
discord: ['botToken', 'publicKey'],
|
||||
feishu: ['appId', 'appSecret'],
|
||||
lark: ['appId', 'appSecret'],
|
||||
feishu: ['appSecret'],
|
||||
lark: ['appSecret'],
|
||||
slack: ['botToken', 'signingSecret'],
|
||||
telegram: ['botToken'],
|
||||
wechat: ['botToken', 'botId'],
|
||||
};
|
||||
|
||||
function parseCredentials(
|
||||
|
|
@ -22,15 +23,11 @@ function parseCredentials(
|
|||
const creds: Record<string, string> = {};
|
||||
|
||||
if (options.botToken) creds.botToken = options.botToken;
|
||||
if (options.botId) creds.botId = options.botId;
|
||||
if (options.publicKey) creds.publicKey = options.publicKey;
|
||||
if (options.signingSecret) creds.signingSecret = options.signingSecret;
|
||||
if (options.appSecret) creds.appSecret = options.appSecret;
|
||||
|
||||
// For lark/feishu, --app-id maps to credentials.appId (distinct from --app-id as applicationId)
|
||||
if ((platform === 'lark' || platform === 'feishu') && options.appId) {
|
||||
creds.appId = options.appId;
|
||||
}
|
||||
|
||||
return creds;
|
||||
}
|
||||
|
||||
|
|
@ -130,6 +127,7 @@ export function registerBotCommand(program: Command) {
|
|||
.requiredOption('--platform <platform>', `Platform: ${SUPPORTED_PLATFORMS.join(', ')}`)
|
||||
.requiredOption('--app-id <appId>', 'Application ID for webhook routing')
|
||||
.option('--bot-token <token>', 'Bot token')
|
||||
.option('--bot-id <id>', 'Bot ID (WeChat)')
|
||||
.option('--public-key <key>', 'Public key (Discord)')
|
||||
.option('--signing-secret <secret>', 'Signing secret (Slack)')
|
||||
.option('--app-secret <secret>', 'App secret (Lark/Feishu)')
|
||||
|
|
@ -138,6 +136,7 @@ export function registerBotCommand(program: Command) {
|
|||
agent: string;
|
||||
appId: string;
|
||||
appSecret?: string;
|
||||
botId?: string;
|
||||
botToken?: string;
|
||||
platform: string;
|
||||
publicKey?: string;
|
||||
|
|
@ -180,6 +179,7 @@ export function registerBotCommand(program: Command) {
|
|||
.command('update <botId>')
|
||||
.description('Update a bot integration')
|
||||
.option('--bot-token <token>', 'New bot token')
|
||||
.option('--bot-id <id>', 'New bot ID (WeChat)')
|
||||
.option('--public-key <key>', 'New public key')
|
||||
.option('--signing-secret <secret>', 'New signing secret')
|
||||
.option('--app-secret <secret>', 'New app secret')
|
||||
|
|
@ -191,6 +191,7 @@ export function registerBotCommand(program: Command) {
|
|||
options: {
|
||||
appId?: string;
|
||||
appSecret?: string;
|
||||
botId?: string;
|
||||
botToken?: string;
|
||||
platform?: string;
|
||||
publicKey?: string;
|
||||
|
|
@ -201,6 +202,7 @@ export function registerBotCommand(program: Command) {
|
|||
|
||||
const credentials: Record<string, string> = {};
|
||||
if (options.botToken) credentials.botToken = options.botToken;
|
||||
if (options.botId) credentials.botId = options.botId;
|
||||
if (options.publicKey) credentials.publicKey = options.publicKey;
|
||||
if (options.signingSecret) credentials.signingSecret = options.signingSecret;
|
||||
if (options.appSecret) credentials.appSecret = options.appSecret;
|
||||
|
|
|
|||
211
apps/cli/src/commands/brief.ts
Normal file
211
apps/cli/src/commands/brief.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { outputJson, printTable, timeAgo, truncate } from '../utils/format';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
export function registerBriefCommand(program: Command) {
|
||||
const brief = program.command('brief').description('Manage briefs (Agent reports)');
|
||||
|
||||
// ── list ──────────────────────────────────────────────
|
||||
|
||||
brief
|
||||
.command('list')
|
||||
.description('List briefs')
|
||||
.option('--unresolved', 'Only show unresolved briefs (default)')
|
||||
.option('--all', 'Show all briefs')
|
||||
.option('--type <type>', 'Filter by type (decision/result/insight/error)')
|
||||
.option('-L, --limit <n>', 'Page size', '50')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(
|
||||
async (options: {
|
||||
all?: boolean;
|
||||
json?: string | boolean;
|
||||
limit?: string;
|
||||
type?: string;
|
||||
unresolved?: boolean;
|
||||
}) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
let items: any[];
|
||||
|
||||
if (options.all) {
|
||||
const input: Record<string, any> = {};
|
||||
if (options.type) input.type = options.type;
|
||||
if (options.limit) input.limit = Number.parseInt(options.limit, 10);
|
||||
const result = await client.brief.list.query(input as any);
|
||||
items = result.data;
|
||||
} else {
|
||||
const result = await client.brief.listUnresolved.query();
|
||||
items = result.data;
|
||||
}
|
||||
|
||||
if (options.json !== undefined) {
|
||||
outputJson(items, typeof options.json === 'string' ? options.json : undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
log.info('No briefs found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = items.map((b: any) => [
|
||||
typeBadge(b.type, b.priority),
|
||||
truncate(b.title, 40),
|
||||
truncate(b.summary, 50),
|
||||
b.taskId ? pc.dim(b.taskId) : b.cronJobId ? pc.dim(b.cronJobId) : '-',
|
||||
b.resolvedAt ? pc.green('resolved') : b.readAt ? pc.dim('read') : 'new',
|
||||
timeAgo(b.createdAt),
|
||||
]);
|
||||
|
||||
printTable(rows, ['TYPE', 'TITLE', 'SUMMARY', 'SOURCE', 'STATUS', 'CREATED']);
|
||||
},
|
||||
);
|
||||
|
||||
// ── view ──────────────────────────────────────────────
|
||||
|
||||
brief
|
||||
.command('view <id>')
|
||||
.description('View brief details (auto marks as read)')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(async (id: string, options: { json?: string | boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.brief.find.query({ id });
|
||||
const b = result.data;
|
||||
|
||||
if (options.json !== undefined) {
|
||||
outputJson(b, typeof options.json === 'string' ? options.json : undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!b) {
|
||||
log.error('Brief not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto mark as read
|
||||
if (!b.readAt) {
|
||||
await client.brief.markRead.mutate({ id });
|
||||
}
|
||||
|
||||
const resolvedLabel = b.resolvedAt
|
||||
? (() => {
|
||||
const actions = (b.actions as any[]) || [];
|
||||
const matched = actions.find((a: any) => a.key === (b as any).resolvedAction);
|
||||
return pc.green(` ${matched?.label || '✓ resolved'}`);
|
||||
})()
|
||||
: '';
|
||||
|
||||
console.log(`\n${typeBadge(b.type, b.priority)} ${pc.bold(b.title)}${resolvedLabel}`);
|
||||
console.log(`${pc.dim('Type:')} ${b.type} ${pc.dim('Created:')} ${timeAgo(b.createdAt)}`);
|
||||
if (b.agentId) console.log(`${pc.dim('Agent:')} ${b.agentId}`);
|
||||
if (b.taskId) console.log(`${pc.dim('Task:')} ${b.taskId}`);
|
||||
if (b.cronJobId) console.log(`${pc.dim('CronJob:')} ${b.cronJobId}`);
|
||||
if (b.topicId) console.log(`${pc.dim('Topic:')} ${b.topicId}`);
|
||||
console.log(`\n${b.summary}`);
|
||||
|
||||
if (b.artifacts && (b.artifacts as string[]).length > 0) {
|
||||
console.log(`\n${pc.dim('Artifacts:')}`);
|
||||
for (const a of b.artifacts as string[]) {
|
||||
console.log(` 📎 ${a}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log();
|
||||
if (!b.resolvedAt) {
|
||||
const actions = (b.actions as any[]) || [];
|
||||
if (actions.length > 0) {
|
||||
console.log('Actions:');
|
||||
for (const a of actions) {
|
||||
const cmd =
|
||||
a.type === 'comment'
|
||||
? `lh brief resolve ${b.id} --action ${a.key} -m "内容"`
|
||||
: `lh brief resolve ${b.id} --action ${a.key}`;
|
||||
console.log(` ${a.label} ${pc.dim(cmd)}`);
|
||||
}
|
||||
} else {
|
||||
console.log(pc.dim('Actions:'));
|
||||
console.log(pc.dim(` lh brief resolve ${b.id} # 确认通过`));
|
||||
console.log(pc.dim(` lh brief resolve ${b.id} --reply "修改意见" # 反馈修改`));
|
||||
}
|
||||
} else if ((b as any).resolvedComment) {
|
||||
console.log(`${pc.dim('Comment:')} ${(b as any).resolvedComment}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── resolve ──────────────────────────────────────────────
|
||||
|
||||
brief
|
||||
.command('resolve <id>')
|
||||
.description('Resolve a brief (approve, reply, or custom action)')
|
||||
.option('--action <key>', 'Execute a specific action (e.g. approve, feedback)')
|
||||
.option('--reply <text>', 'Reply with feedback')
|
||||
.option('-m, --message <text>', 'Message for comment-type actions')
|
||||
.action(async (id: string, options: { action?: string; message?: string; reply?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const actionKey = options.action || (options.reply ? 'feedback' : 'approve');
|
||||
const actionMessage = options.message || options.reply;
|
||||
|
||||
const briefResult = await client.brief.find.query({ id });
|
||||
const b = briefResult.data;
|
||||
|
||||
// For comment-type actions, add comment to task
|
||||
if (actionMessage && b?.taskId) {
|
||||
await client.task.addComment.mutate({
|
||||
briefId: id,
|
||||
content: actionMessage,
|
||||
id: b.taskId,
|
||||
});
|
||||
}
|
||||
|
||||
await client.brief.resolve.mutate({
|
||||
action: actionKey,
|
||||
comment: actionMessage,
|
||||
id,
|
||||
});
|
||||
|
||||
const actions = (b?.actions as any[]) || [];
|
||||
const matchedAction = actions.find((a: any) => a.key === actionKey);
|
||||
const label = matchedAction?.label || actionKey;
|
||||
|
||||
log.info(`${label} — Brief ${pc.dim(id)} resolved.`);
|
||||
});
|
||||
|
||||
// ── delete ──────────────────────────────────────────────
|
||||
|
||||
brief
|
||||
.command('delete <id>')
|
||||
.description('Delete a brief')
|
||||
.action(async (id: string) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.brief.delete.mutate({ id });
|
||||
log.info(`Brief ${pc.dim(id)} deleted.`);
|
||||
});
|
||||
}
|
||||
|
||||
function typeBadge(type: string, priority?: string): string {
|
||||
if (priority === 'urgent') {
|
||||
return pc.red('🔴');
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'decision': {
|
||||
return pc.yellow('🟡');
|
||||
}
|
||||
case 'result': {
|
||||
return pc.green('✅');
|
||||
}
|
||||
case 'insight': {
|
||||
return '💬';
|
||||
}
|
||||
case 'error': {
|
||||
return pc.red('❌');
|
||||
}
|
||||
default: {
|
||||
return '·';
|
||||
}
|
||||
}
|
||||
}
|
||||
102
apps/cli/src/commands/completion.test.ts
Normal file
102
apps/cli/src/commands/completion.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { Command } from 'commander';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { registerCompletionCommand } from './completion';
|
||||
|
||||
describe('completion command', () => {
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
const originalShell = process.env.SHELL;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.mockRestore();
|
||||
delete process.env.LOBEHUB_COMP_CWORD;
|
||||
process.env.SHELL = originalShell;
|
||||
});
|
||||
|
||||
function createProgram() {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
|
||||
program
|
||||
.command('agent')
|
||||
.description('Agent commands')
|
||||
.command('list')
|
||||
.description('List agents');
|
||||
program.command('generate').alias('gen').description('Generate content');
|
||||
program.command('usage').description('Usage').option('--month <YYYY-MM>', 'Month to query');
|
||||
program.command('internal', { hidden: true });
|
||||
|
||||
registerCompletionCommand(program);
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
it('should output zsh completion script by default', async () => {
|
||||
process.env.SHELL = '/bin/zsh';
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'completion']);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('compdef _lobehub_completion'));
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('lh lobe lobehub'));
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('"${(@)words[@]:1}"'));
|
||||
});
|
||||
|
||||
it('should output bash completion script when requested', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'completion', 'bash']);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('complete -o nosort'));
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('__complete'));
|
||||
});
|
||||
|
||||
it('should suggest root commands and aliases', async () => {
|
||||
process.env.LOBEHUB_COMP_CWORD = '0';
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', '__complete', 'g']);
|
||||
|
||||
expect(consoleSpy.mock.calls.map(([value]) => value)).toEqual(['gen', 'generate']);
|
||||
});
|
||||
|
||||
it('should suggest nested subcommands in the current command context', async () => {
|
||||
process.env.LOBEHUB_COMP_CWORD = '1';
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', '__complete', 'agent']);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('list');
|
||||
});
|
||||
|
||||
it('should suggest command options after leaf commands', async () => {
|
||||
process.env.LOBEHUB_COMP_CWORD = '1';
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', '__complete', 'usage']);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('--month');
|
||||
});
|
||||
|
||||
it('should not suggest commands while completing an option value', async () => {
|
||||
process.env.LOBEHUB_COMP_CWORD = '2';
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', '__complete', 'usage', '--month']);
|
||||
|
||||
expect(consoleSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not expose hidden commands', async () => {
|
||||
process.env.LOBEHUB_COMP_CWORD = '0';
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', '__complete']);
|
||||
|
||||
expect(consoleSpy.mock.calls.map(([value]) => value)).not.toContain('internal');
|
||||
expect(consoleSpy.mock.calls.map(([value]) => value)).not.toContain('__complete');
|
||||
});
|
||||
});
|
||||
30
apps/cli/src/commands/completion.ts
Normal file
30
apps/cli/src/commands/completion.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { Command } from 'commander';
|
||||
|
||||
import {
|
||||
getCompletionCandidates,
|
||||
parseCompletionWordIndex,
|
||||
renderCompletionScript,
|
||||
resolveCompletionShell,
|
||||
} from '../utils/completion';
|
||||
|
||||
export function registerCompletionCommand(program: Command) {
|
||||
program
|
||||
.command('completion [shell]')
|
||||
.description('Output shell completion script')
|
||||
.action((shell?: string) => {
|
||||
console.log(renderCompletionScript(resolveCompletionShell(shell)));
|
||||
});
|
||||
|
||||
program
|
||||
.command('__complete', { hidden: true })
|
||||
.allowUnknownOption()
|
||||
.argument('[words...]')
|
||||
.action((words: string[] = []) => {
|
||||
const currentWordIndex = parseCompletionWordIndex(process.env.LOBEHUB_COMP_CWORD, words);
|
||||
const candidates = getCompletionCandidates(program, words, currentWordIndex);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
console.log(candidate);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -2,10 +2,16 @@ import { Command } from 'commander';
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../auth/resolveToken', () => ({
|
||||
resolveToken: vi.fn().mockResolvedValue({ token: 'test-token', userId: 'test-user' }),
|
||||
resolveToken: vi.fn().mockResolvedValue({
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
token: 'test-token',
|
||||
tokenType: 'jwt',
|
||||
userId: 'test-user',
|
||||
}),
|
||||
}));
|
||||
vi.mock('../settings', () => ({
|
||||
loadSettings: vi.fn().mockReturnValue(null),
|
||||
normalizeUrl: vi.fn((url?: string) => (url ? url.replace(/\/$/, '') : undefined)),
|
||||
saveSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
|
|
@ -161,6 +167,12 @@ describe('connect command', () => {
|
|||
serverUrl: 'https://self-hosted.example.com',
|
||||
});
|
||||
});
|
||||
it('should pass the resolved serverUrl to GatewayClient', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'connect']);
|
||||
|
||||
expect(clientOptions.serverUrl).toBe('https://app.lobehub.com');
|
||||
});
|
||||
|
||||
it('should handle tool call requests', async () => {
|
||||
const program = createProgram();
|
||||
|
|
@ -208,7 +220,12 @@ describe('connect command', () => {
|
|||
});
|
||||
|
||||
it('should handle auth_expired', async () => {
|
||||
vi.mocked(resolveToken).mockResolvedValueOnce({ token: 'new-tok', userId: 'user' });
|
||||
vi.mocked(resolveToken).mockResolvedValueOnce({
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
token: 'new-tok',
|
||||
tokenType: 'jwt',
|
||||
userId: 'user',
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'connect']);
|
||||
|
|
@ -220,6 +237,24 @@ describe('connect command', () => {
|
|||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should ignore auth_expired for api key auth', async () => {
|
||||
vi.mocked(resolveToken).mockResolvedValueOnce({
|
||||
serverUrl: 'https://self-hosted.example.com',
|
||||
token: 'test-api-key',
|
||||
tokenType: 'apiKey',
|
||||
userId: 'user',
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'connect']);
|
||||
|
||||
await clientEventHandlers['auth_expired']?.();
|
||||
|
||||
expect(log.error).not.toHaveBeenCalled();
|
||||
expect(cleanupAllProcesses).not.toHaveBeenCalled();
|
||||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle error event', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'connect']);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { GatewayClient } from '@lobechat/device-gateway-client';
|
|||
import type { Command } from 'commander';
|
||||
|
||||
import { resolveToken } from '../auth/resolveToken';
|
||||
import { CLI_API_KEY_ENV } from '../constants/auth';
|
||||
import { OFFICIAL_GATEWAY_URL } from '../constants/urls';
|
||||
import {
|
||||
appendLog,
|
||||
|
|
@ -23,7 +24,7 @@ import {
|
|||
stopDaemon,
|
||||
writeStatus,
|
||||
} from '../daemon/manager';
|
||||
import { loadSettings, saveSettings } from '../settings';
|
||||
import { loadSettings, normalizeUrl, saveSettings } from '../settings';
|
||||
import { executeToolCall } from '../tools';
|
||||
import { cleanupAllProcesses } from '../tools/shell';
|
||||
import { log, setVerbose } from '../utils/logger';
|
||||
|
|
@ -174,7 +175,7 @@ function buildDaemonArgs(options: ConnectOptions): string[] {
|
|||
async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
const auth = await resolveToken(options);
|
||||
const settings = loadSettings();
|
||||
const gatewayUrl = options.gateway?.replace(/\/$/, '') || settings?.gatewayUrl;
|
||||
const gatewayUrl = normalizeUrl(options.gateway) || settings?.gatewayUrl;
|
||||
|
||||
if (!gatewayUrl && settings?.serverUrl) {
|
||||
log.error(
|
||||
|
|
@ -194,7 +195,9 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
|||
deviceId: options.deviceId,
|
||||
gatewayUrl: resolvedGatewayUrl,
|
||||
logger: isDaemonChild ? createDaemonLogger() : log,
|
||||
serverUrl: auth.serverUrl,
|
||||
token: auth.token,
|
||||
tokenType: auth.tokenType,
|
||||
userId: auth.userId,
|
||||
});
|
||||
|
||||
|
|
@ -214,7 +217,7 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
|||
info(` Hostname : ${os.hostname()}`);
|
||||
info(` Platform : ${process.platform}`);
|
||||
info(` Gateway : ${resolvedGatewayUrl}`);
|
||||
info(` Auth : jwt`);
|
||||
info(` Auth : ${auth.tokenType}`);
|
||||
info(` Mode : ${isDaemonChild ? 'daemon' : 'foreground'}`);
|
||||
info('───────────────────');
|
||||
|
||||
|
|
@ -285,13 +288,19 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
|||
// Handle auth failed
|
||||
client.on('auth_failed', (reason) => {
|
||||
error(`Authentication failed: ${reason}`);
|
||||
error("Run 'lh login' to re-authenticate.");
|
||||
error(
|
||||
`Run 'lh login', or set ${CLI_API_KEY_ENV} and run 'lh login --server <url>' to configure API key authentication.`,
|
||||
);
|
||||
cleanup();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Handle auth expired
|
||||
client.on('auth_expired', async () => {
|
||||
if (auth.tokenType === 'apiKey') {
|
||||
return;
|
||||
}
|
||||
|
||||
error('Authentication expired. Attempting to refresh...');
|
||||
const refreshed = await resolveToken({});
|
||||
if (refreshed) {
|
||||
|
|
|
|||
|
|
@ -3,11 +3,15 @@ import fs from 'node:fs';
|
|||
import { Command } from 'commander';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { getUserIdFromApiKey } from '../auth/apiKey';
|
||||
import { saveCredentials } from '../auth/credentials';
|
||||
import { loadSettings, saveSettings } from '../settings';
|
||||
import { log } from '../utils/logger';
|
||||
import { registerLoginCommand, resolveCommandExecutable } from './login';
|
||||
|
||||
vi.mock('../auth/apiKey', () => ({
|
||||
getUserIdFromApiKey: vi.fn(),
|
||||
}));
|
||||
vi.mock('../auth/credentials', () => ({
|
||||
saveCredentials: vi.fn(),
|
||||
}));
|
||||
|
|
@ -37,6 +41,7 @@ vi.mock('node:child_process', () => ({
|
|||
|
||||
describe('login command', () => {
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
const originalApiKey = process.env.LOBEHUB_CLI_API_KEY;
|
||||
const originalPath = process.env.PATH;
|
||||
const originalPathext = process.env.PATHEXT;
|
||||
const originalSystemRoot = process.env.SystemRoot;
|
||||
|
|
@ -46,11 +51,13 @@ describe('login command', () => {
|
|||
vi.stubGlobal('fetch', vi.fn());
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
|
||||
vi.mocked(loadSettings).mockReturnValue(null);
|
||||
delete process.env.LOBEHUB_CLI_API_KEY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
exitSpy.mockRestore();
|
||||
process.env.LOBEHUB_CLI_API_KEY = originalApiKey;
|
||||
process.env.PATH = originalPath;
|
||||
process.env.PATHEXT = originalPathext;
|
||||
process.env.SystemRoot = originalSystemRoot;
|
||||
|
|
@ -102,8 +109,12 @@ describe('login command', () => {
|
|||
} as any;
|
||||
}
|
||||
|
||||
async function runLogin(program: Command, args: string[] = []) {
|
||||
return program.parseAsync(['node', 'test', 'login', ...args]);
|
||||
}
|
||||
|
||||
async function runLoginAndAdvanceTimers(program: Command, args: string[] = []) {
|
||||
const parsePromise = program.parseAsync(['node', 'test', 'login', ...args]);
|
||||
const parsePromise = runLogin(program, args);
|
||||
// Advance timers to let sleep resolve in the polling loop
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
|
|
@ -130,6 +141,19 @@ describe('login command', () => {
|
|||
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Login successful'));
|
||||
});
|
||||
|
||||
it('should use environment api key without storing credentials', async () => {
|
||||
process.env.LOBEHUB_CLI_API_KEY = 'sk-lh-env-test';
|
||||
vi.mocked(getUserIdFromApiKey).mockResolvedValue('user-123');
|
||||
|
||||
const program = createProgram();
|
||||
await runLogin(program);
|
||||
|
||||
expect(getUserIdFromApiKey).toHaveBeenCalledWith('sk-lh-env-test', 'https://app.lobehub.com');
|
||||
expect(saveCredentials).not.toHaveBeenCalled();
|
||||
expect(saveSettings).toHaveBeenCalledWith({ serverUrl: 'https://app.lobehub.com' });
|
||||
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Login successful'));
|
||||
});
|
||||
|
||||
it('should persist custom server into settings', async () => {
|
||||
vi.mocked(fetch)
|
||||
.mockResolvedValueOnce(deviceAuthResponse())
|
||||
|
|
@ -159,6 +183,23 @@ describe('login command', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should preserve existing gateway for environment api key on the same server', async () => {
|
||||
process.env.LOBEHUB_CLI_API_KEY = 'sk-lh-env-test';
|
||||
vi.mocked(getUserIdFromApiKey).mockResolvedValue('user-123');
|
||||
vi.mocked(loadSettings).mockReturnValueOnce({
|
||||
gatewayUrl: 'https://gateway.example.com',
|
||||
serverUrl: 'https://test.com',
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await runLogin(program, ['--server', 'https://test.com/']);
|
||||
|
||||
expect(saveSettings).toHaveBeenCalledWith({
|
||||
gatewayUrl: 'https://gateway.example.com',
|
||||
serverUrl: 'https://test.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear existing gateway when logging into a different server', async () => {
|
||||
vi.mocked(loadSettings).mockReturnValueOnce({
|
||||
gatewayUrl: 'https://gateway.example.com',
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ import path from 'node:path';
|
|||
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { getUserIdFromApiKey } from '../auth/apiKey';
|
||||
import { saveCredentials } from '../auth/credentials';
|
||||
import { CLI_API_KEY_ENV } from '../constants/auth';
|
||||
import { OFFICIAL_SERVER_URL } from '../constants/urls';
|
||||
import { loadSettings, saveSettings } from '../settings';
|
||||
import { loadSettings, normalizeUrl, saveSettings } from '../settings';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
const CLIENT_ID = 'lobehub-cli';
|
||||
|
|
@ -51,13 +53,43 @@ async function parseJsonResponse<T>(res: Response, endpoint: string): Promise<T>
|
|||
export function registerLoginCommand(program: Command) {
|
||||
program
|
||||
.command('login')
|
||||
.description('Log in to LobeHub via browser (Device Code Flow)')
|
||||
.description('Log in to LobeHub via browser (Device Code Flow) or configure API key server')
|
||||
.option('--server <url>', 'LobeHub server URL', OFFICIAL_SERVER_URL)
|
||||
.action(async (options: LoginOptions) => {
|
||||
const serverUrl = options.server.replace(/\/$/, '');
|
||||
const serverUrl = normalizeUrl(options.server) || OFFICIAL_SERVER_URL;
|
||||
|
||||
log.info('Starting login...');
|
||||
|
||||
const apiKey = process.env[CLI_API_KEY_ENV];
|
||||
if (apiKey) {
|
||||
try {
|
||||
await getUserIdFromApiKey(apiKey, serverUrl);
|
||||
|
||||
const existingSettings = loadSettings();
|
||||
const shouldPreserveGateway = existingSettings?.serverUrl === serverUrl;
|
||||
|
||||
saveSettings(
|
||||
shouldPreserveGateway
|
||||
? {
|
||||
gatewayUrl: existingSettings.gatewayUrl,
|
||||
serverUrl,
|
||||
}
|
||||
: {
|
||||
// Gateway auth is tied to the login server's token issuer/JWKS.
|
||||
// When server changes, clear old gateway to avoid stale cross-environment config.
|
||||
serverUrl,
|
||||
},
|
||||
);
|
||||
log.info('Login successful! Credentials saved.');
|
||||
return;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error(`API key validation failed: ${message}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Request device code
|
||||
let deviceAuth: DeviceAuthResponse;
|
||||
try {
|
||||
|
|
@ -164,6 +196,7 @@ export function registerLoginCommand(program: Command) {
|
|||
: undefined,
|
||||
refreshToken: body.refresh_token,
|
||||
});
|
||||
|
||||
const existingSettings = loadSettings();
|
||||
const shouldPreserveGateway = existingSettings?.serverUrl === serverUrl;
|
||||
|
||||
|
|
|
|||
85
apps/cli/src/commands/man.test.ts
Normal file
85
apps/cli/src/commands/man.test.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { Command } from 'commander';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { registerManCommand } from './man';
|
||||
|
||||
describe('man command', () => {
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
function createProgram() {
|
||||
const program = new Command();
|
||||
|
||||
program.name('lh').description('Sample CLI').version('1.0.0');
|
||||
|
||||
const generate = program
|
||||
.command('generate')
|
||||
.alias('gen')
|
||||
.description('Generate content')
|
||||
.option('-m, --model <model>', 'Model to use');
|
||||
|
||||
generate
|
||||
.command('text <prompt>')
|
||||
.description('Generate text from a prompt')
|
||||
.option('--json', 'Output raw JSON');
|
||||
|
||||
program.command('login').description('Log in to LobeHub');
|
||||
|
||||
registerManCommand(program);
|
||||
program.exitOverride();
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
it('renders a manual page for the root command', async () => {
|
||||
const program = createProgram();
|
||||
|
||||
await program.parseAsync(['node', 'test', 'man']);
|
||||
|
||||
const output = consoleSpy.mock.calls.at(0)?.[0];
|
||||
|
||||
expect(output).toContain('LH(1)');
|
||||
expect(output).toContain('NAME\n lh - Sample CLI');
|
||||
expect(output).toContain('ALIASES\n lobe, lobehub');
|
||||
expect(output).toContain('SYNOPSIS\n lh [options] [command]');
|
||||
expect(output).toContain('generate|gen [options] [command]');
|
||||
expect(output).toContain('man [options] [command...]');
|
||||
});
|
||||
|
||||
it('renders a manual page for a command with subcommands', async () => {
|
||||
const program = createProgram();
|
||||
|
||||
await program.parseAsync(['node', 'test', 'man', 'generate']);
|
||||
|
||||
const output = consoleSpy.mock.calls.at(0)?.[0];
|
||||
|
||||
expect(output).toContain('LH-GENERATE(1)');
|
||||
expect(output).toContain('NAME\n lh generate - Generate content');
|
||||
expect(output).toContain('ALIASES\n gen');
|
||||
expect(output).toContain('SYNOPSIS\n lh generate [options] [command]');
|
||||
expect(output).toContain('text [options] <prompt>');
|
||||
expect(output).toContain('-m, --model <model>');
|
||||
});
|
||||
|
||||
it('renders arguments for a leaf command', async () => {
|
||||
const program = createProgram();
|
||||
|
||||
await program.parseAsync(['node', 'test', 'man', 'generate', 'text']);
|
||||
|
||||
const output = consoleSpy.mock.calls.at(0)?.[0];
|
||||
|
||||
expect(output).toContain('LH-GENERATE-TEXT(1)');
|
||||
expect(output).toContain('NAME\n lh generate text - Generate text from a prompt');
|
||||
expect(output).toContain('ARGUMENTS');
|
||||
expect(output).toContain('<prompt>');
|
||||
expect(output).toContain('Required argument');
|
||||
expect(output).toContain('SEE ALSO');
|
||||
});
|
||||
});
|
||||
212
apps/cli/src/commands/man.ts
Normal file
212
apps/cli/src/commands/man.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import type { Argument, Command } from 'commander';
|
||||
|
||||
const ROOT_ALIASES = ['lobe', 'lobehub'];
|
||||
const HELP_COMMAND_NAME = 'help';
|
||||
|
||||
interface DefinitionItem {
|
||||
description: string;
|
||||
term: string;
|
||||
}
|
||||
|
||||
interface ResolutionResult {
|
||||
command?: Command;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function registerManCommand(program: Command) {
|
||||
program
|
||||
.command('man [command...]')
|
||||
.description('Show a manual page for the CLI or a subcommand')
|
||||
.action((commandPath: string[] | undefined) => {
|
||||
const segments = commandPath ?? [];
|
||||
const resolution = resolveCommandPath(program, segments);
|
||||
|
||||
if (!resolution.command) {
|
||||
program.error(resolution.error || 'Unknown command path.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(renderManualPage(program, resolution.command));
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCommandPath(root: Command, segments: string[]): ResolutionResult {
|
||||
let current = root;
|
||||
|
||||
for (const segment of segments) {
|
||||
const next = getVisibleCommands(current).find(
|
||||
(command) => command.name() === segment || command.aliases().includes(segment),
|
||||
);
|
||||
|
||||
if (!next) {
|
||||
const currentPath = buildCommandPath(current).join(' ');
|
||||
const available = getVisibleCommands(current)
|
||||
.map((command) => command.name())
|
||||
.join(', ');
|
||||
|
||||
return {
|
||||
error: `Unknown command "${segment}" under "${currentPath}". Available: ${available || 'none'}.`,
|
||||
};
|
||||
}
|
||||
|
||||
current = next;
|
||||
}
|
||||
|
||||
return { command: current };
|
||||
}
|
||||
|
||||
function renderManualPage(root: Command, command: Command) {
|
||||
const sections = [
|
||||
formatManualHeader(command),
|
||||
formatNameSection(command),
|
||||
formatSynopsisSection(root, command),
|
||||
formatAliasesSection(command),
|
||||
formatDescriptionSection(command),
|
||||
formatArgumentsSection(command),
|
||||
formatCommandsSection(command),
|
||||
formatOptionsSection(command),
|
||||
formatSeeAlsoSection(root, command),
|
||||
].filter(Boolean);
|
||||
|
||||
return sections.join('\n\n');
|
||||
}
|
||||
|
||||
function formatManualHeader(command: Command) {
|
||||
return `${buildCommandPath(command).join('-').toUpperCase()}(1)`;
|
||||
}
|
||||
|
||||
function formatNameSection(command: Command) {
|
||||
return ['NAME', ` ${buildCommandPath(command).join(' ')} - ${command.description()}`].join('\n');
|
||||
}
|
||||
|
||||
function formatSynopsisSection(root: Command, command: Command) {
|
||||
return ['SYNOPSIS', ` ${buildSynopsis(root, command)}`].join('\n');
|
||||
}
|
||||
|
||||
function formatAliasesSection(command: Command) {
|
||||
const aliases = command.parent ? command.aliases() : ROOT_ALIASES;
|
||||
|
||||
if (aliases.length === 0) return '';
|
||||
|
||||
return ['ALIASES', ` ${aliases.join(', ')}`].join('\n');
|
||||
}
|
||||
|
||||
function formatDescriptionSection(command: Command) {
|
||||
const description = command.description() || 'No description available.';
|
||||
|
||||
return ['DESCRIPTION', ` ${description}`].join('\n');
|
||||
}
|
||||
|
||||
function formatArgumentsSection(command: Command) {
|
||||
if (command.registeredArguments.length === 0) return '';
|
||||
|
||||
const items = command.registeredArguments.map((argument) => ({
|
||||
description: describeArgument(argument),
|
||||
term: formatArgumentTerm(argument),
|
||||
}));
|
||||
|
||||
return ['ARGUMENTS', ...formatDefinitionList(items)].join('\n');
|
||||
}
|
||||
|
||||
function formatCommandsSection(command: Command) {
|
||||
const help = command.createHelp();
|
||||
const items = getVisibleCommands(command).map((subcommand) => ({
|
||||
description: help.subcommandDescription(subcommand),
|
||||
term: buildSubcommandTerm(subcommand),
|
||||
}));
|
||||
|
||||
if (items.length === 0) return '';
|
||||
|
||||
return ['COMMANDS', ...formatDefinitionList(items)].join('\n');
|
||||
}
|
||||
|
||||
function formatOptionsSection(command: Command) {
|
||||
const help = command.createHelp();
|
||||
const items = help.visibleOptions(command).map((option) => ({
|
||||
description: help.optionDescription(option),
|
||||
term: help.optionTerm(option),
|
||||
}));
|
||||
|
||||
if (items.length === 0) return '';
|
||||
|
||||
return ['OPTIONS', ...formatDefinitionList(items)].join('\n');
|
||||
}
|
||||
|
||||
function formatSeeAlsoSection(root: Command, command: Command) {
|
||||
const items = new Set<string>();
|
||||
const currentPath = buildCommandPath(command);
|
||||
|
||||
items.add(`${currentPath.join(' ')} --help`);
|
||||
|
||||
const parent = command.parent;
|
||||
if (parent) {
|
||||
const parentPath = buildCommandPath(parent).slice(1).join(' ');
|
||||
items.add(parentPath ? `lh man ${parentPath}` : 'lh man');
|
||||
}
|
||||
|
||||
for (const subcommand of getVisibleCommands(command).slice(0, 5)) {
|
||||
items.add(`lh man ${buildCommandPath(subcommand).slice(1).join(' ')}`);
|
||||
}
|
||||
|
||||
return ['SEE ALSO', ...Array.from(items).map((item) => ` ${item}`)].join('\n');
|
||||
}
|
||||
|
||||
function getVisibleCommands(command: Command) {
|
||||
const help = command.createHelp();
|
||||
|
||||
return help
|
||||
.visibleCommands(command)
|
||||
.filter((subcommand) => subcommand.name() !== HELP_COMMAND_NAME);
|
||||
}
|
||||
|
||||
function buildSynopsis(root: Command, command: Command) {
|
||||
const path = buildCommandPath(command);
|
||||
|
||||
if (command === root) {
|
||||
return `${path[0]} ${command.usage()}`.trim();
|
||||
}
|
||||
|
||||
return `${path.join(' ')} ${command.usage()}`.trim();
|
||||
}
|
||||
|
||||
function buildCommandPath(command: Command): string[] {
|
||||
const path: string[] = [];
|
||||
let current: Command | null = command;
|
||||
|
||||
while (current) {
|
||||
path.unshift(current.name());
|
||||
current = current.parent || null;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
function buildSubcommandTerm(command: Command) {
|
||||
const name = [command.name(), ...command.aliases()].join('|');
|
||||
const usage = command.usage();
|
||||
|
||||
return usage ? `${name} ${usage}` : name;
|
||||
}
|
||||
|
||||
function formatDefinitionList(items: DefinitionItem[]) {
|
||||
const width = Math.max(...items.map((item) => item.term.length));
|
||||
|
||||
return items.map((item) => ` ${item.term.padEnd(width)} ${item.description}`);
|
||||
}
|
||||
|
||||
function formatArgumentTerm(argument: Argument) {
|
||||
const name = argument.name();
|
||||
|
||||
if (argument.required) {
|
||||
return argument.variadic ? `<${name}...>` : `<${name}>`;
|
||||
}
|
||||
|
||||
return argument.variadic ? `[${name}...]` : `[${name}]`;
|
||||
}
|
||||
|
||||
function describeArgument(argument: Argument) {
|
||||
const required = argument.required ? 'Required' : 'Optional';
|
||||
const variadic = argument.variadic ? 'variadic ' : '';
|
||||
|
||||
return `${required} ${variadic}argument`;
|
||||
}
|
||||
|
|
@ -3,10 +3,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
|
||||
// Mock resolveToken
|
||||
vi.mock('../auth/resolveToken', () => ({
|
||||
resolveToken: vi.fn().mockResolvedValue({ token: 'test-token', userId: 'test-user' }),
|
||||
resolveToken: vi.fn().mockResolvedValue({
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
token: 'test-token',
|
||||
tokenType: 'jwt',
|
||||
userId: 'test-user',
|
||||
}),
|
||||
}));
|
||||
vi.mock('../settings', () => ({
|
||||
loadSettings: vi.fn().mockReturnValue(null),
|
||||
normalizeUrl: vi.fn((url?: string) => (url ? url.replace(/\/$/, '') : undefined)),
|
||||
saveSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
|
|
@ -115,6 +121,16 @@ describe('status command', () => {
|
|||
serverUrl: 'https://self-hosted.example.com',
|
||||
});
|
||||
});
|
||||
it('should pass the resolved serverUrl to GatewayClient', async () => {
|
||||
const program = createProgram();
|
||||
const parsePromise = program.parseAsync(['node', 'test', 'status']);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
clientEventHandlers['connected']?.();
|
||||
|
||||
await parsePromise;
|
||||
expect(clientOptions.serverUrl).toBe('https://app.lobehub.com');
|
||||
});
|
||||
|
||||
it('should log CONNECTED on successful connection', async () => {
|
||||
const program = createProgram();
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { Command } from 'commander';
|
|||
|
||||
import { resolveToken } from '../auth/resolveToken';
|
||||
import { OFFICIAL_GATEWAY_URL } from '../constants/urls';
|
||||
import { loadSettings, saveSettings } from '../settings';
|
||||
import { loadSettings, normalizeUrl, saveSettings } from '../settings';
|
||||
import { log, setVerbose } from '../utils/logger';
|
||||
|
||||
interface StatusOptions {
|
||||
|
|
@ -30,7 +30,7 @@ export function registerStatusCommand(program: Command) {
|
|||
|
||||
const auth = await resolveToken(options);
|
||||
const settings = loadSettings();
|
||||
const gatewayUrl = options.gateway?.replace(/\/$/, '') || settings?.gatewayUrl;
|
||||
const gatewayUrl = normalizeUrl(options.gateway) || settings?.gatewayUrl;
|
||||
|
||||
if (!gatewayUrl && settings?.serverUrl) {
|
||||
log.error(
|
||||
|
|
@ -50,7 +50,9 @@ export function registerStatusCommand(program: Command) {
|
|||
autoReconnect: false,
|
||||
gatewayUrl: gatewayUrl || OFFICIAL_GATEWAY_URL,
|
||||
logger: log,
|
||||
serverUrl: auth.serverUrl,
|
||||
token: auth.token,
|
||||
tokenType: auth.tokenType,
|
||||
userId: auth.userId,
|
||||
});
|
||||
|
||||
|
|
|
|||
95
apps/cli/src/commands/task/checkpoint.ts
Normal file
95
apps/cli/src/commands/task/checkpoint.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
import { log } from '../../utils/logger';
|
||||
|
||||
export function registerCheckpointCommands(task: Command) {
|
||||
// ── checkpoint ──────────────────────────────────────────────
|
||||
|
||||
const cp = task.command('checkpoint').description('Manage task checkpoints');
|
||||
|
||||
cp.command('view <id>')
|
||||
.description('View checkpoint config for a task')
|
||||
.action(async (id: string) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.task.getCheckpoint.query({ id });
|
||||
const c = result.data as any;
|
||||
|
||||
console.log(`\n${pc.bold('Checkpoint config:')}`);
|
||||
console.log(` onAgentRequest: ${c.onAgentRequest ?? pc.dim('not set (default: true)')}`);
|
||||
if (c.topic) {
|
||||
console.log(` topic.before: ${c.topic.before ?? false}`);
|
||||
console.log(` topic.after: ${c.topic.after ?? false}`);
|
||||
}
|
||||
if (c.tasks?.beforeIds?.length > 0) {
|
||||
console.log(` tasks.beforeIds: ${c.tasks.beforeIds.join(', ')}`);
|
||||
}
|
||||
if (c.tasks?.afterIds?.length > 0) {
|
||||
console.log(` tasks.afterIds: ${c.tasks.afterIds.join(', ')}`);
|
||||
}
|
||||
if (
|
||||
!c.topic &&
|
||||
!c.tasks?.beforeIds?.length &&
|
||||
!c.tasks?.afterIds?.length &&
|
||||
c.onAgentRequest === undefined
|
||||
) {
|
||||
console.log(` ${pc.dim('(no checkpoints configured)')}`);
|
||||
}
|
||||
console.log();
|
||||
});
|
||||
|
||||
cp.command('set <id>')
|
||||
.description('Configure checkpoints')
|
||||
.option('--on-agent-request <bool>', 'Allow agent to request review (true/false)')
|
||||
.option('--topic-before <bool>', 'Pause before each topic (true/false)')
|
||||
.option('--topic-after <bool>', 'Pause after each topic (true/false)')
|
||||
.option('--before <ids>', 'Pause before these subtask identifiers (comma-separated)')
|
||||
.option('--after <ids>', 'Pause after these subtask identifiers (comma-separated)')
|
||||
.action(
|
||||
async (
|
||||
id: string,
|
||||
options: {
|
||||
after?: string;
|
||||
before?: string;
|
||||
onAgentRequest?: string;
|
||||
topicAfter?: string;
|
||||
topicBefore?: string;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// Get current config first
|
||||
const current = (await client.task.getCheckpoint.query({ id })).data as any;
|
||||
const checkpoint: any = { ...current };
|
||||
|
||||
if (options.onAgentRequest !== undefined) {
|
||||
checkpoint.onAgentRequest = options.onAgentRequest === 'true';
|
||||
}
|
||||
if (options.topicBefore !== undefined || options.topicAfter !== undefined) {
|
||||
checkpoint.topic = { ...checkpoint.topic };
|
||||
if (options.topicBefore !== undefined)
|
||||
checkpoint.topic.before = options.topicBefore === 'true';
|
||||
if (options.topicAfter !== undefined)
|
||||
checkpoint.topic.after = options.topicAfter === 'true';
|
||||
}
|
||||
if (options.before !== undefined) {
|
||||
checkpoint.tasks = { ...checkpoint.tasks };
|
||||
checkpoint.tasks.beforeIds = options.before
|
||||
.split(',')
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (options.after !== undefined) {
|
||||
checkpoint.tasks = { ...checkpoint.tasks };
|
||||
checkpoint.tasks.afterIds = options.after
|
||||
.split(',')
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
await client.task.updateCheckpoint.mutate({ checkpoint, id });
|
||||
log.info('Checkpoint updated.');
|
||||
},
|
||||
);
|
||||
}
|
||||
56
apps/cli/src/commands/task/dep.ts
Normal file
56
apps/cli/src/commands/task/dep.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import type { Command } from 'commander';
|
||||
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
import { outputJson, printTable, timeAgo } from '../../utils/format';
|
||||
import { log } from '../../utils/logger';
|
||||
|
||||
export function registerDepCommands(task: Command) {
|
||||
// ── dep ──────────────────────────────────────────────
|
||||
|
||||
const dep = task.command('dep').description('Manage task dependencies');
|
||||
|
||||
dep
|
||||
.command('add <taskId> <dependsOnId>')
|
||||
.description('Add dependency (taskId blocks on dependsOnId)')
|
||||
.option('--type <type>', 'Dependency type (blocks/relates)', 'blocks')
|
||||
.action(async (taskId: string, dependsOnId: string, options: { type?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.task.addDependency.mutate({
|
||||
dependsOnId,
|
||||
taskId,
|
||||
type: (options.type || 'blocks') as any,
|
||||
});
|
||||
log.info(`Dependency added: ${taskId} ${options.type || 'blocks'} on ${dependsOnId}`);
|
||||
});
|
||||
|
||||
dep
|
||||
.command('rm <taskId> <dependsOnId>')
|
||||
.description('Remove dependency')
|
||||
.action(async (taskId: string, dependsOnId: string) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.task.removeDependency.mutate({ dependsOnId, taskId });
|
||||
log.info(`Dependency removed.`);
|
||||
});
|
||||
|
||||
dep
|
||||
.command('list <taskId>')
|
||||
.description('List dependencies for a task')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(async (taskId: string, options: { json?: string | boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.task.getDependencies.query({ id: taskId });
|
||||
|
||||
if (options.json !== undefined) {
|
||||
outputJson(result.data, options.json);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.data || result.data.length === 0) {
|
||||
log.info('No dependencies.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = result.data.map((d: any) => [d.type, d.dependsOnId, timeAgo(d.createdAt)]);
|
||||
printTable(rows, ['TYPE', 'DEPENDS ON', 'CREATED']);
|
||||
});
|
||||
}
|
||||
102
apps/cli/src/commands/task/doc.ts
Normal file
102
apps/cli/src/commands/task/doc.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
import { log } from '../../utils/logger';
|
||||
|
||||
export function registerDocCommands(task: Command) {
|
||||
// ── doc ──────────────────────────────────────────────
|
||||
|
||||
const dc = task.command('doc').description('Manage task workspace documents');
|
||||
|
||||
dc.command('create <id>')
|
||||
.description('Create a document and pin it to the task')
|
||||
.requiredOption('-t, --title <title>', 'Document title')
|
||||
.option('-b, --body <content>', 'Document content')
|
||||
.option('--parent <docId>', 'Parent document/folder ID')
|
||||
.option('--folder', 'Create as folder')
|
||||
.action(
|
||||
async (
|
||||
id: string,
|
||||
options: { body?: string; folder?: boolean; parent?: string; title: string },
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// Create document
|
||||
const fileType = options.folder ? 'custom/folder' : undefined;
|
||||
const content = options.body || '';
|
||||
const result = await client.document.createDocument.mutate({
|
||||
content,
|
||||
editorData: options.folder ? undefined : JSON.stringify({ content, type: 'doc' }),
|
||||
fileType,
|
||||
parentId: options.parent,
|
||||
title: options.title,
|
||||
});
|
||||
|
||||
// Pin to task
|
||||
await client.task.pinDocument.mutate({
|
||||
documentId: result.id,
|
||||
pinnedBy: 'user',
|
||||
taskId: id,
|
||||
});
|
||||
|
||||
const icon = options.folder ? '📁' : '📄';
|
||||
log.info(`${icon} Created & pinned: ${pc.bold(options.title)} ${pc.dim(result.id)}`);
|
||||
},
|
||||
);
|
||||
|
||||
dc.command('pin <id> <documentId>')
|
||||
.description('Pin an existing document to a task')
|
||||
.action(async (id: string, documentId: string) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.task.pinDocument.mutate({ documentId, pinnedBy: 'user', taskId: id });
|
||||
log.info(`Pinned ${pc.dim(documentId)} to ${pc.bold(id)}.`);
|
||||
});
|
||||
|
||||
dc.command('unpin <id> <documentId>')
|
||||
.description('Unpin a document from a task')
|
||||
.action(async (id: string, documentId: string) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.task.unpinDocument.mutate({ documentId, taskId: id });
|
||||
log.info(`Unpinned ${pc.dim(documentId)} from ${pc.bold(id)}.`);
|
||||
});
|
||||
|
||||
dc.command('mv <id> <documentId> <folder>')
|
||||
.description('Move a document into a folder (auto-creates folder if not found)')
|
||||
.action(async (id: string, documentId: string, folder: string) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// Check if folder is a document ID or a folder name
|
||||
let folderId = folder;
|
||||
if (!folder.startsWith('docs_')) {
|
||||
// folder is a name, find or create it
|
||||
const detail = await client.task.detail.query({ id });
|
||||
const folders = detail.data.workspace || [];
|
||||
|
||||
// Search for existing folder by name
|
||||
const existingFolder = folders.find((f) => f.title === folder);
|
||||
|
||||
if (existingFolder) {
|
||||
folderId = existingFolder.documentId;
|
||||
} else {
|
||||
// Create folder and pin to task
|
||||
const result = await client.document.createDocument.mutate({
|
||||
content: '',
|
||||
fileType: 'custom/folder',
|
||||
title: folder,
|
||||
});
|
||||
await client.task.pinDocument.mutate({
|
||||
documentId: result.id,
|
||||
pinnedBy: 'user',
|
||||
taskId: id,
|
||||
});
|
||||
folderId = result.id;
|
||||
log.info(`📁 Created folder: ${pc.bold(folder)} ${pc.dim(folderId)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Move document into folder
|
||||
await client.document.updateDocument.mutate({ id: documentId, parentId: folderId });
|
||||
log.info(`Moved ${pc.dim(documentId)} → 📁 ${pc.bold(folder)}`);
|
||||
});
|
||||
}
|
||||
74
apps/cli/src/commands/task/helpers.ts
Normal file
74
apps/cli/src/commands/task/helpers.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import pc from 'picocolors';
|
||||
|
||||
export function statusBadge(status: string): string {
|
||||
const pad = (s: string) => s.padEnd(9);
|
||||
switch (status) {
|
||||
case 'backlog': {
|
||||
return pc.dim(`○ ${pad('backlog')}`);
|
||||
}
|
||||
case 'blocked': {
|
||||
return pc.red(`◉ ${pad('blocked')}`);
|
||||
}
|
||||
case 'running': {
|
||||
return pc.blue(`● ${pad('running')}`);
|
||||
}
|
||||
case 'paused': {
|
||||
return pc.yellow(`◐ ${pad('paused')}`);
|
||||
}
|
||||
case 'completed': {
|
||||
return pc.green(`✓ ${pad('completed')}`);
|
||||
}
|
||||
case 'failed': {
|
||||
return pc.red(`✗ ${pad('failed')}`);
|
||||
}
|
||||
case 'timeout': {
|
||||
return pc.red(`⏱ ${pad('timeout')}`);
|
||||
}
|
||||
case 'canceled': {
|
||||
return pc.dim(`⊘ ${pad('canceled')}`);
|
||||
}
|
||||
default: {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function briefIcon(type: string): string {
|
||||
switch (type) {
|
||||
case 'decision': {
|
||||
return '📋';
|
||||
}
|
||||
case 'result': {
|
||||
return '✅';
|
||||
}
|
||||
case 'insight': {
|
||||
return '💡';
|
||||
}
|
||||
case 'error': {
|
||||
return '❌';
|
||||
}
|
||||
default: {
|
||||
return '📌';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function priorityLabel(priority: number | null | undefined): string {
|
||||
switch (priority) {
|
||||
case 1: {
|
||||
return pc.red('urgent');
|
||||
}
|
||||
case 2: {
|
||||
return pc.yellow('high');
|
||||
}
|
||||
case 3: {
|
||||
return 'normal';
|
||||
}
|
||||
case 4: {
|
||||
return pc.dim('low');
|
||||
}
|
||||
default: {
|
||||
return pc.dim('-');
|
||||
}
|
||||
}
|
||||
}
|
||||
624
apps/cli/src/commands/task/index.ts
Normal file
624
apps/cli/src/commands/task/index.ts
Normal file
|
|
@ -0,0 +1,624 @@
|
|||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
import {
|
||||
confirm,
|
||||
displayWidth,
|
||||
outputJson,
|
||||
printTable,
|
||||
timeAgo,
|
||||
truncate,
|
||||
} from '../../utils/format';
|
||||
import { log } from '../../utils/logger';
|
||||
import { registerCheckpointCommands } from './checkpoint';
|
||||
import { registerDepCommands } from './dep';
|
||||
import { registerDocCommands } from './doc';
|
||||
import { briefIcon, priorityLabel, statusBadge } from './helpers';
|
||||
import { registerLifecycleCommands } from './lifecycle';
|
||||
import { registerReviewCommands } from './review';
|
||||
import { registerTopicCommands } from './topic';
|
||||
|
||||
export function registerTaskCommand(program: Command) {
|
||||
const task = program.command('task').description('Manage agent tasks');
|
||||
|
||||
// ── list ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('list')
|
||||
.description('List tasks')
|
||||
.option(
|
||||
'--status <status>',
|
||||
'Filter by status (pending/running/paused/completed/failed/canceled)',
|
||||
)
|
||||
.option('--root', 'Only show root tasks (no parent)')
|
||||
.option('--parent <id>', 'Filter by parent task ID')
|
||||
.option('--agent <id>', 'Filter by assignee agent ID')
|
||||
.option('-L, --limit <n>', 'Page size', '50')
|
||||
.option('--offset <n>', 'Offset', '0')
|
||||
.option('--tree', 'Display as tree structure')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(
|
||||
async (options: {
|
||||
agent?: string;
|
||||
json?: string | boolean;
|
||||
limit?: string;
|
||||
offset?: string;
|
||||
parent?: string;
|
||||
root?: boolean;
|
||||
status?: string;
|
||||
tree?: boolean;
|
||||
}) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const input: Record<string, any> = {};
|
||||
if (options.status) input.status = options.status;
|
||||
if (options.root) input.parentTaskId = null;
|
||||
if (options.parent) input.parentTaskId = options.parent;
|
||||
if (options.agent) input.assigneeAgentId = options.agent;
|
||||
if (options.limit) input.limit = Number.parseInt(options.limit, 10);
|
||||
if (options.offset) input.offset = Number.parseInt(options.offset, 10);
|
||||
|
||||
// For tree mode, fetch all tasks (no pagination limit)
|
||||
if (options.tree) {
|
||||
input.limit = 100;
|
||||
delete input.offset;
|
||||
}
|
||||
|
||||
const result = await client.task.list.query(input as any);
|
||||
|
||||
if (options.json !== undefined) {
|
||||
outputJson(result.data, options.json);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.data || result.data.length === 0) {
|
||||
log.info('No tasks found.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.tree) {
|
||||
// Build tree display
|
||||
const taskMap = new Map<string, any>();
|
||||
for (const t of result.data) taskMap.set(t.id, t);
|
||||
|
||||
const roots = result.data.filter((t: any) => !t.parentTaskId);
|
||||
const children = new Map<string, any[]>();
|
||||
for (const t of result.data) {
|
||||
if (t.parentTaskId) {
|
||||
const list = children.get(t.parentTaskId) || [];
|
||||
list.push(t);
|
||||
children.set(t.parentTaskId, list);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort children by sortOrder first, then seq
|
||||
for (const [, list] of children) {
|
||||
list.sort(
|
||||
(a: any, b: any) =>
|
||||
(a.sortOrder ?? 0) - (b.sortOrder ?? 0) || (a.seq ?? 0) - (b.seq ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
const printNode = (t: any, prefix: string, isLast: boolean, isRoot: boolean) => {
|
||||
const connector = isRoot ? '' : isLast ? '└── ' : '├── ';
|
||||
const name = truncate(t.name || t.instruction, 40);
|
||||
console.log(
|
||||
`${prefix}${connector}${pc.dim(t.identifier)} ${statusBadge(t.status)} ${name}`,
|
||||
);
|
||||
const childList = children.get(t.id) || [];
|
||||
const newPrefix = isRoot ? '' : prefix + (isLast ? ' ' : '│ ');
|
||||
childList.forEach((child: any, i: number) => {
|
||||
printNode(child, newPrefix, i === childList.length - 1, false);
|
||||
});
|
||||
};
|
||||
|
||||
for (const root of roots) {
|
||||
printNode(root, '', true, true);
|
||||
}
|
||||
log.info(`Total: ${result.total}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = result.data.map((t: any) => [
|
||||
pc.dim(t.identifier),
|
||||
truncate(t.name || t.instruction, 40),
|
||||
statusBadge(t.status),
|
||||
priorityLabel(t.priority),
|
||||
t.assigneeAgentId ? pc.dim(t.assigneeAgentId) : '-',
|
||||
t.parentTaskId ? pc.dim('↳ subtask') : '',
|
||||
timeAgo(t.createdAt),
|
||||
]);
|
||||
|
||||
printTable(rows, ['ID', 'NAME', 'STATUS', 'PRI', 'AGENT', 'TYPE', 'CREATED']);
|
||||
log.info(`Total: ${result.total}`);
|
||||
},
|
||||
);
|
||||
|
||||
// ── view ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('view <id>')
|
||||
.description('View task details (by ID or identifier like T-1)')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(async (id: string, options: { json?: string | boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// ── Auto-detect by id prefix ──
|
||||
|
||||
// docs_ → show document content
|
||||
if (id.startsWith('docs_')) {
|
||||
const doc = await client.document.getDocumentDetail.query({ id });
|
||||
|
||||
if (options.json !== undefined) {
|
||||
outputJson(doc, options.json);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!doc) {
|
||||
log.error('Document not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n📄 ${pc.bold(doc.title || 'Untitled')} ${pc.dim(doc.id)}`);
|
||||
if (doc.fileType) console.log(`${pc.dim('Type:')} ${doc.fileType}`);
|
||||
if (doc.totalCharCount) console.log(`${pc.dim('Size:')} ${doc.totalCharCount} chars`);
|
||||
console.log(`${pc.dim('Updated:')} ${timeAgo(doc.updatedAt)}`);
|
||||
console.log();
|
||||
if (doc.content) {
|
||||
console.log(doc.content);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// tpc_ → show topic messages
|
||||
if (id.startsWith('tpc_')) {
|
||||
const messages = await client.message.getMessages.query({ topicId: id });
|
||||
const items = Array.isArray(messages) ? messages : [];
|
||||
|
||||
if (options.json !== undefined) {
|
||||
outputJson(items, options.json);
|
||||
return;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
log.info('No messages in this topic.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log();
|
||||
for (const msg of items) {
|
||||
const role =
|
||||
msg.role === 'assistant'
|
||||
? pc.green('Assistant')
|
||||
: msg.role === 'user'
|
||||
? pc.blue('User')
|
||||
: pc.dim(msg.role);
|
||||
|
||||
console.log(`${pc.bold(role)} ${pc.dim(timeAgo(msg.createdAt))}`);
|
||||
if (msg.content) {
|
||||
console.log(msg.content);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: task detail
|
||||
const result = await client.task.detail.query({ id });
|
||||
|
||||
if (options.json !== undefined) {
|
||||
outputJson(result.data, options.json);
|
||||
return;
|
||||
}
|
||||
|
||||
const t = result.data;
|
||||
|
||||
// ── Header ──
|
||||
console.log(`\n${pc.bold(t.identifier)} ${t.name || ''}`);
|
||||
console.log(
|
||||
`${pc.dim('Status:')} ${statusBadge(t.status)} ${pc.dim('Priority:')} ${priorityLabel(t.priority)}`,
|
||||
);
|
||||
console.log(`${pc.dim('Instruction:')} ${t.instruction}`);
|
||||
if (t.description) console.log(`${pc.dim('Description:')} ${t.description}`);
|
||||
if (t.agentId) console.log(`${pc.dim('Agent:')} ${t.agentId}`);
|
||||
if (t.userId) console.log(`${pc.dim('User:')} ${t.userId}`);
|
||||
if (t.parent) {
|
||||
console.log(`${pc.dim('Parent:')} ${t.parent.identifier} ${t.parent.name || ''}`);
|
||||
}
|
||||
const topicInfo = t.topicCount ? `${t.topicCount}` : '0';
|
||||
const createdInfo = t.createdAt ? timeAgo(t.createdAt) : '-';
|
||||
console.log(`${pc.dim('Topics:')} ${topicInfo} ${pc.dim('Created:')} ${createdInfo}`);
|
||||
if (t.heartbeat?.timeout && t.heartbeat.lastAt) {
|
||||
const hb = timeAgo(t.heartbeat.lastAt);
|
||||
const interval = t.heartbeat.interval ? `${t.heartbeat.interval}s` : '-';
|
||||
const elapsed = (Date.now() - new Date(t.heartbeat.lastAt).getTime()) / 1000;
|
||||
const isStuck = t.status === 'running' && elapsed > t.heartbeat.timeout;
|
||||
console.log(
|
||||
`${pc.dim('Heartbeat:')} ${isStuck ? pc.red(hb) : hb} ${pc.dim('interval:')} ${interval} ${pc.dim('timeout:')} ${t.heartbeat.timeout}s${isStuck ? pc.red(' ⚠ TIMEOUT') : ''}`,
|
||||
);
|
||||
}
|
||||
if (t.error) console.log(`${pc.red('Error:')} ${t.error}`);
|
||||
|
||||
// ── Subtasks ──
|
||||
if (t.subtasks && t.subtasks.length > 0) {
|
||||
// Build lookup: which subtasks are completed
|
||||
const completedIdentifiers = new Set(
|
||||
t.subtasks.filter((s) => s.status === 'completed').map((s) => s.identifier),
|
||||
);
|
||||
|
||||
console.log(`\n${pc.bold('Subtasks:')}`);
|
||||
for (const s of t.subtasks) {
|
||||
const depInfo = s.blockedBy ? pc.dim(` ← blocks: ${s.blockedBy}`) : '';
|
||||
// Show 'blocked' instead of 'backlog' if task has unresolved dependencies
|
||||
const isBlocked = s.blockedBy && !completedIdentifiers.has(s.blockedBy);
|
||||
const displayStatus = s.status === 'backlog' && isBlocked ? 'blocked' : s.status;
|
||||
console.log(
|
||||
` ${pc.dim(s.identifier)} ${statusBadge(displayStatus)} ${s.name || '(unnamed)'}${depInfo}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dependencies ──
|
||||
if (t.dependencies && t.dependencies.length > 0) {
|
||||
console.log(`\n${pc.bold('Dependencies:')}`);
|
||||
for (const d of t.dependencies) {
|
||||
const depName = d.name ? ` ${d.name}` : '';
|
||||
console.log(` ${pc.dim(d.type || 'blocks')}: ${d.dependsOn}${depName}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Checkpoint ──
|
||||
{
|
||||
const cp = t.checkpoint || {};
|
||||
console.log(`\n${pc.bold('Checkpoint:')}`);
|
||||
const hasConfig =
|
||||
cp.onAgentRequest !== undefined ||
|
||||
cp.topic?.before ||
|
||||
cp.topic?.after ||
|
||||
cp.tasks?.beforeIds?.length ||
|
||||
cp.tasks?.afterIds?.length;
|
||||
|
||||
if (hasConfig) {
|
||||
if (cp.onAgentRequest !== undefined)
|
||||
console.log(` onAgentRequest: ${cp.onAgentRequest}`);
|
||||
if (cp.topic?.before) console.log(` topic.before: ${cp.topic.before}`);
|
||||
if (cp.topic?.after) console.log(` topic.after: ${cp.topic.after}`);
|
||||
if (cp.tasks?.beforeIds?.length)
|
||||
console.log(` tasks.before: ${cp.tasks.beforeIds.join(', ')}`);
|
||||
if (cp.tasks?.afterIds?.length)
|
||||
console.log(` tasks.after: ${cp.tasks.afterIds.join(', ')}`);
|
||||
} else {
|
||||
console.log(` ${pc.dim('(not configured, default: onAgentRequest=true)')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Review ──
|
||||
{
|
||||
const rv = t.review as any;
|
||||
console.log(`\n${pc.bold('Review:')}`);
|
||||
if (rv && rv.enabled) {
|
||||
console.log(
|
||||
` judge: ${rv.judge?.model || 'default'}${rv.judge?.provider ? ` (${rv.judge.provider})` : ''}`,
|
||||
);
|
||||
console.log(` maxIterations: ${rv.maxIterations} autoRetry: ${rv.autoRetry}`);
|
||||
if (rv.rubrics?.length > 0) {
|
||||
for (let i = 0; i < rv.rubrics.length; i++) {
|
||||
const rb = rv.rubrics[i];
|
||||
const threshold = rb.threshold ? ` ≥ ${Math.round(rb.threshold * 100)}%` : '';
|
||||
const typeTag = pc.dim(`[${rb.type}]`);
|
||||
let configInfo = '';
|
||||
if (rb.type === 'llm-rubric') configInfo = rb.config?.criteria || '';
|
||||
else if (rb.type === 'contains' || rb.type === 'equals')
|
||||
configInfo = `value="${rb.config?.value}"`;
|
||||
else if (rb.type === 'regex') configInfo = `pattern="${rb.config?.pattern}"`;
|
||||
console.log(` ${i + 1}. ${rb.name} ${typeTag}${threshold} ${pc.dim(configInfo)}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(` ${pc.dim('(not configured)')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Workspace ──
|
||||
{
|
||||
const nodes = t.workspace || [];
|
||||
if (nodes.length === 0) {
|
||||
console.log(`\n${pc.bold('Workspace:')}`);
|
||||
console.log(` ${pc.dim('No documents yet.')}`);
|
||||
} else {
|
||||
const countNodes = (list: typeof nodes): number =>
|
||||
list.reduce((sum, n) => sum + 1 + (n.children ? countNodes(n.children) : 0), 0);
|
||||
console.log(`\n${pc.bold(`Workspace (${countNodes(nodes)}):`)}`);
|
||||
|
||||
const formatSize = (chars: number | null | undefined) => {
|
||||
if (!chars) return '';
|
||||
if (chars >= 10_000) return `${(chars / 1000).toFixed(1)}k`;
|
||||
return `${chars}`;
|
||||
};
|
||||
|
||||
const LEFT_COL = 56;
|
||||
const FROM_WIDTH = 10;
|
||||
|
||||
const renderNodes = (list: typeof nodes, indent: string, isChild: boolean) => {
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const node = list[i];
|
||||
const isFolder = node.fileType === 'custom/folder';
|
||||
const isLast = i === list.length - 1;
|
||||
const icon = isFolder ? '📁' : '📄';
|
||||
const connector = isChild ? (isLast ? '└── ' : '├── ') : '';
|
||||
const prefix = `${indent}${connector}${icon} `;
|
||||
const titleStr = truncate(node.title || 'Untitled', LEFT_COL - displayWidth(prefix));
|
||||
const titlePad = ' '.repeat(
|
||||
Math.max(1, LEFT_COL - displayWidth(prefix) - displayWidth(titleStr)),
|
||||
);
|
||||
|
||||
const fromStr = node.sourceTaskIdentifier ? `← ${node.sourceTaskIdentifier}` : '';
|
||||
const fromPad = ' '.repeat(Math.max(1, FROM_WIDTH - fromStr.length + 1));
|
||||
const size =
|
||||
!isFolder && node.size
|
||||
? formatSize(node.size).padStart(6) + ' chars'
|
||||
: ''.padStart(12);
|
||||
|
||||
const ago = node.createdAt ? ` ${timeAgo(node.createdAt)}` : '';
|
||||
|
||||
console.log(
|
||||
`${prefix}${titleStr}${titlePad}${pc.dim(`(${node.documentId})`)} ${fromStr}${fromPad}${pc.dim(size)}${pc.dim(ago)}`,
|
||||
);
|
||||
|
||||
if (node.children && node.children.length > 0) {
|
||||
const childIndent = isChild ? indent + (isLast ? ' ' : '│ ') : indent;
|
||||
renderNodes(node.children, childIndent, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
renderNodes(nodes, ' ', false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Activities (already sorted desc by service) ──
|
||||
{
|
||||
console.log(`\n${pc.bold('Activities:')}`);
|
||||
const acts = t.activities || [];
|
||||
if (acts.length === 0) {
|
||||
console.log(` ${pc.dim('No activities yet.')}`);
|
||||
} else {
|
||||
for (const act of acts) {
|
||||
const ago = act.time ? timeAgo(act.time) : '';
|
||||
const idSuffix = act.id ? ` ${pc.dim(act.id)}` : '';
|
||||
if (act.type === 'topic') {
|
||||
const sBadge = statusBadge(act.status || 'running');
|
||||
console.log(
|
||||
` 💬 ${pc.dim(ago.padStart(7))} Topic #${act.seq || '?'} ${act.title || 'Untitled'} ${sBadge}${idSuffix}`,
|
||||
);
|
||||
} else if (act.type === 'brief') {
|
||||
const icon = briefIcon(act.briefType || '');
|
||||
const pri =
|
||||
act.priority === 'urgent'
|
||||
? pc.red(' [urgent]')
|
||||
: act.priority === 'normal'
|
||||
? pc.yellow(' [normal]')
|
||||
: '';
|
||||
const resolved = act.resolvedAction ? pc.green(` ✏️ ${act.resolvedAction}`) : '';
|
||||
const typeLabel = pc.dim(`[${act.briefType}]`);
|
||||
console.log(
|
||||
` ${icon} ${pc.dim(ago.padStart(7))} Brief ${typeLabel} ${act.title}${pri}${resolved}${idSuffix}`,
|
||||
);
|
||||
} else if (act.type === 'comment') {
|
||||
const author = act.agentId ? `🤖 ${act.agentId}` : '👤 user';
|
||||
console.log(` 💭 ${pc.dim(ago.padStart(7))} ${pc.cyan(author)} ${act.content}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log();
|
||||
});
|
||||
|
||||
// ── create ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('create')
|
||||
.description('Create a new task')
|
||||
.requiredOption('-i, --instruction <text>', 'Task instruction')
|
||||
.option('-n, --name <name>', 'Task name')
|
||||
.option('--agent <id>', 'Assign to agent')
|
||||
.option('--parent <id>', 'Parent task ID')
|
||||
.option('--priority <n>', 'Priority (0=none, 1=urgent, 2=high, 3=normal, 4=low)', '0')
|
||||
.option('--prefix <prefix>', 'Identifier prefix', 'T')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(
|
||||
async (options: {
|
||||
agent?: string;
|
||||
instruction: string;
|
||||
json?: string | boolean;
|
||||
name?: string;
|
||||
parent?: string;
|
||||
prefix?: string;
|
||||
priority?: string;
|
||||
}) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const input: Record<string, any> = {
|
||||
instruction: options.instruction,
|
||||
};
|
||||
if (options.name) input.name = options.name;
|
||||
if (options.agent) input.assigneeAgentId = options.agent;
|
||||
if (options.parent) input.parentTaskId = options.parent;
|
||||
if (options.priority) input.priority = Number.parseInt(options.priority, 10);
|
||||
if (options.prefix) input.identifierPrefix = options.prefix;
|
||||
|
||||
const result = await client.task.create.mutate(input as any);
|
||||
|
||||
if (options.json !== undefined) {
|
||||
outputJson(result.data, options.json);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(`Task created: ${pc.bold(result.data.identifier)} ${result.data.name || ''}`);
|
||||
},
|
||||
);
|
||||
|
||||
// ── edit ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('edit <id>')
|
||||
.description('Update a task')
|
||||
.option('-n, --name <name>', 'Task name')
|
||||
.option('-i, --instruction <text>', 'Task instruction')
|
||||
.option('--agent <id>', 'Assign to agent')
|
||||
.option('--priority <n>', 'Priority (0-4)')
|
||||
.option('--heartbeat-interval <n>', 'Heartbeat interval in seconds')
|
||||
.option('--heartbeat-timeout <n>', 'Heartbeat timeout in seconds (0 to disable)')
|
||||
.option('--description <text>', 'Task description')
|
||||
.option(
|
||||
'--status <status>',
|
||||
'Set status (backlog, running, paused, completed, failed, canceled)',
|
||||
)
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(
|
||||
async (
|
||||
id: string,
|
||||
options: {
|
||||
agent?: string;
|
||||
description?: string;
|
||||
heartbeatInterval?: string;
|
||||
heartbeatTimeout?: string;
|
||||
instruction?: string;
|
||||
json?: string | boolean;
|
||||
name?: string;
|
||||
priority?: string;
|
||||
status?: string;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// Handle --status separately (uses updateStatus API)
|
||||
if (options.status) {
|
||||
const valid = ['backlog', 'running', 'paused', 'completed', 'failed', 'canceled'];
|
||||
if (!valid.includes(options.status)) {
|
||||
log.error(`Invalid status "${options.status}". Must be one of: ${valid.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
const result = await client.task.updateStatus.mutate({ id, status: options.status });
|
||||
log.info(`${pc.bold(result.data.identifier)} → ${options.status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const input: Record<string, any> = { id };
|
||||
if (options.name) input.name = options.name;
|
||||
if (options.instruction) input.instruction = options.instruction;
|
||||
if (options.description) input.description = options.description;
|
||||
if (options.agent) input.assigneeAgentId = options.agent;
|
||||
if (options.priority) input.priority = Number.parseInt(options.priority, 10);
|
||||
if (options.heartbeatInterval)
|
||||
input.heartbeatInterval = Number.parseInt(options.heartbeatInterval, 10);
|
||||
if (options.heartbeatTimeout !== undefined) {
|
||||
const val = Number.parseInt(options.heartbeatTimeout, 10);
|
||||
input.heartbeatTimeout = val === 0 ? null : val;
|
||||
}
|
||||
|
||||
const result = await client.task.update.mutate(input as any);
|
||||
|
||||
if (options.json !== undefined) {
|
||||
outputJson(result.data, typeof options.json === 'string' ? options.json : undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(`Task updated: ${pc.bold(result.data.identifier)}`);
|
||||
},
|
||||
);
|
||||
|
||||
// ── delete ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('delete <id>')
|
||||
.description('Delete a task')
|
||||
.option('-y, --yes', 'Skip confirmation')
|
||||
.action(async (id: string, options: { yes?: boolean }) => {
|
||||
if (!options.yes) {
|
||||
const ok = await confirm(`Delete task ${pc.bold(id)}?`);
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
await client.task.delete.mutate({ id });
|
||||
log.info(`Task ${pc.bold(id)} deleted.`);
|
||||
});
|
||||
|
||||
// ── clear ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('clear')
|
||||
.description('Delete all tasks')
|
||||
.option('-y, --yes', 'Skip confirmation')
|
||||
.action(async (options: { yes?: boolean }) => {
|
||||
if (!options.yes) {
|
||||
const ok = await confirm(`Delete ${pc.red('ALL')} tasks? This cannot be undone.`);
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
const result = (await client.task.clearAll.mutate()) as any;
|
||||
log.info(`${result.count} task(s) deleted.`);
|
||||
});
|
||||
|
||||
// ── tree ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('tree <id>')
|
||||
.description('Show task tree (subtasks + dependencies)')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(async (id: string, options: { json?: string | boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.task.getTaskTree.query({ id });
|
||||
|
||||
if (options.json !== undefined) {
|
||||
outputJson(result.data, options.json);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.data || result.data.length === 0) {
|
||||
log.info('No tasks found.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build tree display (raw SQL returns snake_case)
|
||||
const taskMap = new Map<string, any>();
|
||||
for (const t of result.data) taskMap.set(t.id, t);
|
||||
|
||||
const printNode = (taskId: string, indent: number) => {
|
||||
const t = taskMap.get(taskId);
|
||||
if (!t) return;
|
||||
|
||||
const prefix = indent === 0 ? '' : ' '.repeat(indent) + '├── ';
|
||||
const name = t.name || t.identifier || '';
|
||||
const status = t.status || 'pending';
|
||||
const identifier = t.identifier || t.id;
|
||||
console.log(`${prefix}${pc.dim(identifier)} ${statusBadge(status)} ${name}`);
|
||||
|
||||
// Print children (handle both camelCase and snake_case)
|
||||
for (const child of result.data) {
|
||||
const childParent = child.parentTaskId || child.parent_task_id;
|
||||
if (childParent === taskId) {
|
||||
printNode(child.id, indent + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Find root - resolve identifier first
|
||||
const resolved = await client.task.find.query({ id });
|
||||
const rootId = resolved.data.id;
|
||||
const root = result.data.find((t: any) => t.id === rootId);
|
||||
if (root) printNode(root.id, 0);
|
||||
else log.info('Root task not found in tree.');
|
||||
});
|
||||
|
||||
// Register subcommand groups
|
||||
registerLifecycleCommands(task);
|
||||
registerCheckpointCommands(task);
|
||||
registerReviewCommands(task);
|
||||
registerDepCommands(task);
|
||||
registerTopicCommands(task);
|
||||
registerDocCommands(task);
|
||||
}
|
||||
303
apps/cli/src/commands/task/lifecycle.ts
Normal file
303
apps/cli/src/commands/task/lifecycle.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
import { getAuthInfo } from '../../api/http';
|
||||
import { streamAgentEvents } from '../../utils/agentStream';
|
||||
import { log } from '../../utils/logger';
|
||||
|
||||
export function registerLifecycleCommands(task: Command) {
|
||||
// ── start ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('start <id>')
|
||||
.description('Start a task (pending → running)')
|
||||
.option('--no-run', 'Only update status, do not trigger agent execution')
|
||||
.option('-p, --prompt <text>', 'Additional context for the agent')
|
||||
.option('-f, --follow', 'Follow agent output in real-time (default: run in background)')
|
||||
.option('--json', 'Output full JSON event stream')
|
||||
.option('-v, --verbose', 'Show detailed tool call info')
|
||||
.action(
|
||||
async (
|
||||
id: string,
|
||||
options: {
|
||||
follow?: boolean;
|
||||
json?: boolean;
|
||||
prompt?: string;
|
||||
run?: boolean;
|
||||
verbose?: boolean;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// Check if already running
|
||||
const taskDetail = await client.task.find.query({ id });
|
||||
|
||||
if (taskDetail.data.status === 'running') {
|
||||
log.info(`Task ${pc.bold(taskDetail.data.identifier)} is already running.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const statusResult = await client.task.updateStatus.mutate({ id, status: 'running' });
|
||||
log.info(`Task ${pc.bold(statusResult.data.identifier)} started.`);
|
||||
|
||||
// Auto-run unless --no-run
|
||||
if (options.run === false) return;
|
||||
|
||||
// Default agent to inbox if not assigned
|
||||
if (!taskDetail.data.assigneeAgentId) {
|
||||
await client.task.update.mutate({ assigneeAgentId: 'inbox', id });
|
||||
log.info(`Assigned default agent: ${pc.dim('inbox')}`);
|
||||
}
|
||||
|
||||
const result = (await client.task.run.mutate({
|
||||
id,
|
||||
...(options.prompt && { prompt: options.prompt }),
|
||||
})) as any;
|
||||
|
||||
if (!result.success) {
|
||||
log.error(`Failed to run task: ${result.error || result.message || 'Unknown error'}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
log.info(
|
||||
`Operation: ${pc.dim(result.operationId)} · Topic: ${pc.dim(result.topicId || 'n/a')}`,
|
||||
);
|
||||
|
||||
if (!options.follow) {
|
||||
log.info(
|
||||
`Agent running in background. Use ${pc.dim(`lh task view ${id}`)} to check status.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { serverUrl, headers } = await getAuthInfo();
|
||||
const streamUrl = `${serverUrl}/api/agent/stream?operationId=${encodeURIComponent(result.operationId)}`;
|
||||
|
||||
await streamAgentEvents(streamUrl, headers, {
|
||||
json: options.json,
|
||||
verbose: options.verbose,
|
||||
});
|
||||
|
||||
// Send heartbeat after completion
|
||||
try {
|
||||
await client.task.heartbeat.mutate({ id });
|
||||
} catch {
|
||||
// ignore heartbeat errors
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── run ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('run <id>')
|
||||
.description('Run a task — trigger agent execution')
|
||||
.option('-p, --prompt <text>', 'Additional context for the agent')
|
||||
.option('-c, --continue <topicId>', 'Continue running on an existing topic')
|
||||
.option('-f, --follow', 'Follow agent output in real-time (default: run in background)')
|
||||
.option('--topics <n>', 'Run N topics in sequence (default: 1, implies --follow)', '1')
|
||||
.option('--delay <s>', 'Delay between topics in seconds', '0')
|
||||
.option('--json', 'Output full JSON event stream')
|
||||
.option('-v, --verbose', 'Show detailed tool call info')
|
||||
.action(
|
||||
async (
|
||||
id: string,
|
||||
options: {
|
||||
continue?: string;
|
||||
delay?: string;
|
||||
follow?: boolean;
|
||||
json?: boolean;
|
||||
prompt?: string;
|
||||
topics?: string;
|
||||
verbose?: boolean;
|
||||
},
|
||||
) => {
|
||||
const topicCount = Number.parseInt(options.topics || '1', 10);
|
||||
const delaySec = Number.parseInt(options.delay || '0', 10);
|
||||
|
||||
// --topics > 1 implies --follow
|
||||
const shouldFollow = options.follow || topicCount > 1;
|
||||
|
||||
for (let i = 0; i < topicCount; i++) {
|
||||
if (i > 0) {
|
||||
log.info(`\n${'─'.repeat(60)}`);
|
||||
log.info(`Topic ${i + 1}/${topicCount}`);
|
||||
if (delaySec > 0) {
|
||||
log.info(`Waiting ${delaySec}s before next topic...`);
|
||||
await new Promise((r) => setTimeout(r, delaySec * 1000));
|
||||
}
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// Auto-assign inbox agent on first topic if not assigned
|
||||
if (i === 0) {
|
||||
const taskDetail = await client.task.find.query({ id });
|
||||
if (!taskDetail.data.assigneeAgentId) {
|
||||
await client.task.update.mutate({ assigneeAgentId: 'inbox', id });
|
||||
log.info(`Assigned default agent: ${pc.dim('inbox')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Only pass extra prompt and continue on first topic
|
||||
const result = (await client.task.run.mutate({
|
||||
id,
|
||||
...(i === 0 && options.prompt && { prompt: options.prompt }),
|
||||
...(i === 0 && options.continue && { continueTopicId: options.continue }),
|
||||
})) as any;
|
||||
|
||||
if (!result.success) {
|
||||
log.error(`Failed to run task: ${result.error || result.message || 'Unknown error'}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const operationId = result.operationId;
|
||||
if (i === 0) {
|
||||
log.info(`Task ${pc.bold(result.taskIdentifier)} running`);
|
||||
}
|
||||
log.info(`Operation: ${pc.dim(operationId)} · Topic: ${pc.dim(result.topicId || 'n/a')}`);
|
||||
|
||||
if (!shouldFollow) {
|
||||
log.info(
|
||||
`Agent running in background. Use ${pc.dim(`lh task view ${id}`)} to check status.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Connect to SSE stream and wait for completion
|
||||
const { serverUrl, headers } = await getAuthInfo();
|
||||
const streamUrl = `${serverUrl}/api/agent/stream?operationId=${encodeURIComponent(operationId)}`;
|
||||
|
||||
await streamAgentEvents(streamUrl, headers, {
|
||||
json: options.json,
|
||||
verbose: options.verbose,
|
||||
});
|
||||
|
||||
// Update heartbeat after each topic
|
||||
try {
|
||||
await client.task.heartbeat.mutate({ id });
|
||||
} catch {
|
||||
// ignore heartbeat errors
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── comment ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('comment <id>')
|
||||
.description('Add a comment to a task')
|
||||
.requiredOption('-m, --message <text>', 'Comment content')
|
||||
.action(async (id: string, options: { message: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.task.addComment.mutate({ content: options.message, id });
|
||||
log.info('Comment added.');
|
||||
});
|
||||
|
||||
// ── pause ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('pause <id>')
|
||||
.description('Pause a running task')
|
||||
.action(async (id: string) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.task.updateStatus.mutate({ id, status: 'paused' });
|
||||
log.info(`Task ${pc.bold(result.data.identifier)} paused.`);
|
||||
});
|
||||
|
||||
// ── resume ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('resume <id>')
|
||||
.description('Resume a paused task')
|
||||
.action(async (id: string) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.task.updateStatus.mutate({ id, status: 'running' });
|
||||
log.info(`Task ${pc.bold(result.data.identifier)} resumed.`);
|
||||
});
|
||||
|
||||
// ── complete ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('complete <id>')
|
||||
.description('Mark a task as completed')
|
||||
.action(async (id: string) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = (await client.task.updateStatus.mutate({ id, status: 'completed' })) as any;
|
||||
log.info(`Task ${pc.bold(result.data.identifier)} completed.`);
|
||||
if (result.unlocked?.length > 0) {
|
||||
log.info(`Unlocked: ${result.unlocked.map((id: string) => pc.bold(id)).join(', ')}`);
|
||||
}
|
||||
if (result.paused?.length > 0) {
|
||||
log.info(
|
||||
`Paused (checkpoint): ${result.paused.map((id: string) => pc.yellow(id)).join(', ')}`,
|
||||
);
|
||||
}
|
||||
if (result.checkpointTriggered) {
|
||||
log.info(`${pc.yellow('Checkpoint triggered')} — parent task paused for review.`);
|
||||
}
|
||||
if (result.allSubtasksDone) {
|
||||
log.info(`All subtasks of parent task completed.`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── cancel ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('cancel <id>')
|
||||
.description('Cancel a task')
|
||||
.action(async (id: string) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.task.updateStatus.mutate({ id, status: 'canceled' });
|
||||
log.info(`Task ${pc.bold(result.data.identifier)} canceled.`);
|
||||
});
|
||||
|
||||
// ── sort ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('sort <id> <identifiers...>')
|
||||
.description('Reorder subtasks (e.g. lh task sort T-1 T-2 T-4 T-3)')
|
||||
.action(async (id: string, identifiers: string[]) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = (await client.task.reorderSubtasks.mutate({
|
||||
id,
|
||||
order: identifiers,
|
||||
})) as any;
|
||||
|
||||
log.info('Subtasks reordered:');
|
||||
for (const item of result.data) {
|
||||
console.log(` ${pc.dim(`#${item.sortOrder}`)} ${item.identifier}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── heartbeat ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('heartbeat <id>')
|
||||
.description('Manually send heartbeat for a running task')
|
||||
.action(async (id: string) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.task.heartbeat.mutate({ id });
|
||||
log.info(`Heartbeat sent for ${pc.bold(id)}.`);
|
||||
});
|
||||
|
||||
// ── watchdog ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('watchdog')
|
||||
.description('Run watchdog check — detect and fail stuck tasks')
|
||||
.action(async () => {
|
||||
const client = await getTrpcClient();
|
||||
const result = (await client.task.watchdog.mutate()) as any;
|
||||
|
||||
if (result.failed?.length > 0) {
|
||||
log.info(
|
||||
`${pc.red('Stuck tasks failed:')} ${result.failed.map((id: string) => pc.bold(id)).join(', ')}`,
|
||||
);
|
||||
} else {
|
||||
log.info('No stuck tasks found.');
|
||||
}
|
||||
});
|
||||
}
|
||||
306
apps/cli/src/commands/task/review.ts
Normal file
306
apps/cli/src/commands/task/review.ts
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
import { printTable, truncate } from '../../utils/format';
|
||||
import { log } from '../../utils/logger';
|
||||
|
||||
export function registerReviewCommands(task: Command) {
|
||||
// ── review ──────────────────────────────────────────────
|
||||
|
||||
const rv = task.command('review').description('Manage task review (LLM-as-Judge)');
|
||||
|
||||
rv.command('view <id>')
|
||||
.description('View review config for a task')
|
||||
.action(async (id: string) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.task.getReview.query({ id });
|
||||
const r = result.data as any;
|
||||
|
||||
if (!r || !r.enabled) {
|
||||
log.info('Review not configured for this task.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n${pc.bold('Review config:')}`);
|
||||
console.log(` enabled: ${r.enabled}`);
|
||||
if (r.judge?.model)
|
||||
console.log(` judge: ${r.judge.model}${r.judge.provider ? ` (${r.judge.provider})` : ''}`);
|
||||
console.log(` maxIterations: ${r.maxIterations}`);
|
||||
console.log(` autoRetry: ${r.autoRetry}`);
|
||||
if (r.rubrics?.length > 0) {
|
||||
console.log(` rubrics:`);
|
||||
for (let i = 0; i < r.rubrics.length; i++) {
|
||||
const rb = r.rubrics[i];
|
||||
const threshold = rb.threshold ? ` ≥ ${Math.round(rb.threshold * 100)}%` : '';
|
||||
const typeTag = pc.dim(`[${rb.type}]`);
|
||||
let configInfo = '';
|
||||
if (rb.type === 'llm-rubric') configInfo = rb.config?.criteria || '';
|
||||
else if (rb.type === 'contains' || rb.type === 'equals')
|
||||
configInfo = `value="${rb.config?.value}"`;
|
||||
else if (rb.type === 'regex') configInfo = `pattern="${rb.config?.pattern}"`;
|
||||
console.log(` ${i + 1}. ${rb.name} ${typeTag}${threshold} ${pc.dim(configInfo)}`);
|
||||
}
|
||||
} else {
|
||||
console.log(` rubrics: ${pc.dim('(none)')}`);
|
||||
}
|
||||
console.log();
|
||||
});
|
||||
|
||||
rv.command('set <id>')
|
||||
.description('Enable review and configure judge settings')
|
||||
.option('--model <model>', 'Judge model')
|
||||
.option('--provider <provider>', 'Judge provider')
|
||||
.option('--max-iterations <n>', 'Max review iterations', '3')
|
||||
.option('--no-auto-retry', 'Disable auto retry on failure')
|
||||
.option('--recursive', 'Apply to all subtasks as well')
|
||||
.action(
|
||||
async (
|
||||
id: string,
|
||||
options: {
|
||||
autoRetry?: boolean;
|
||||
maxIterations?: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
recursive?: boolean;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// Read current review config to preserve rubrics
|
||||
const current = (await client.task.getReview.query({ id })).data as any;
|
||||
const existingRubrics = current?.rubrics || [];
|
||||
|
||||
const review = {
|
||||
autoRetry: options.autoRetry !== false,
|
||||
enabled: true,
|
||||
judge: {
|
||||
...(options.model && { model: options.model }),
|
||||
...(options.provider && { provider: options.provider }),
|
||||
},
|
||||
maxIterations: Number.parseInt(options.maxIterations || '3', 10),
|
||||
rubrics: existingRubrics,
|
||||
};
|
||||
|
||||
await client.task.updateReview.mutate({ id, review });
|
||||
|
||||
if (options.recursive) {
|
||||
const subtasks = await client.task.getSubtasks.query({ id });
|
||||
for (const s of subtasks.data || []) {
|
||||
const subCurrent = (await client.task.getReview.query({ id: s.id })).data as any;
|
||||
await client.task.updateReview.mutate({
|
||||
id: s.id,
|
||||
review: { ...review, rubrics: subCurrent?.rubrics || existingRubrics },
|
||||
});
|
||||
}
|
||||
log.info(
|
||||
`Review enabled for ${pc.bold(id)} + ${(subtasks.data || []).length} subtask(s).`,
|
||||
);
|
||||
} else {
|
||||
log.info('Review enabled.');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── review criteria ──────────────────────────────────────
|
||||
|
||||
const rc = rv.command('criteria').description('Manage review rubrics');
|
||||
|
||||
rc.command('list <id>')
|
||||
.description('List review rubrics for a task')
|
||||
.action(async (id: string) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.task.getReview.query({ id });
|
||||
const r = result.data as any;
|
||||
const rubrics = r?.rubrics || [];
|
||||
|
||||
if (rubrics.length === 0) {
|
||||
log.info('No rubrics configured.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = rubrics.map((r: any, i: number) => {
|
||||
const config = r.config || {};
|
||||
const configStr =
|
||||
r.type === 'llm-rubric'
|
||||
? config.criteria || ''
|
||||
: r.type === 'contains' || r.type === 'equals'
|
||||
? `value: "${config.value}"`
|
||||
: r.type === 'regex'
|
||||
? `pattern: "${config.pattern}"`
|
||||
: JSON.stringify(config);
|
||||
|
||||
return [
|
||||
String(i + 1),
|
||||
r.name,
|
||||
r.type,
|
||||
r.threshold ? `≥ ${Math.round(r.threshold * 100)}%` : '-',
|
||||
String(r.weight ?? 1),
|
||||
truncate(configStr, 40),
|
||||
];
|
||||
});
|
||||
|
||||
printTable(rows, ['#', 'NAME', 'TYPE', 'THRESHOLD', 'WEIGHT', 'CONFIG']);
|
||||
});
|
||||
|
||||
rc.command('add <id>')
|
||||
.description('Add a review rubric')
|
||||
.requiredOption('-n, --name <name>', 'Rubric name (e.g. "内容准确性")')
|
||||
.option('--type <type>', 'Rubric type (default: llm-rubric)', 'llm-rubric')
|
||||
.option('-t, --threshold <n>', 'Pass threshold 0-100 (converted to 0-1)')
|
||||
.option('-d, --description <text>', 'Criteria description (for llm-rubric type)')
|
||||
.option('--value <value>', 'Expected value (for contains/equals type)')
|
||||
.option('--pattern <pattern>', 'Regex pattern (for regex type)')
|
||||
.option('-w, --weight <n>', 'Weight for scoring (default: 1)')
|
||||
.option('--recursive', 'Add to all subtasks as well')
|
||||
.action(
|
||||
async (
|
||||
id: string,
|
||||
options: {
|
||||
description?: string;
|
||||
name: string;
|
||||
pattern?: string;
|
||||
recursive?: boolean;
|
||||
threshold?: string;
|
||||
type: string;
|
||||
value?: string;
|
||||
weight?: string;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// Build rubric config based on type
|
||||
const buildConfig = (): Record<string, any> | null => {
|
||||
switch (options.type) {
|
||||
case 'llm-rubric': {
|
||||
return { criteria: options.description || options.name };
|
||||
}
|
||||
case 'contains':
|
||||
case 'equals':
|
||||
case 'starts-with':
|
||||
case 'ends-with': {
|
||||
if (!options.value) {
|
||||
log.error(`--value is required for type "${options.type}"`);
|
||||
return null;
|
||||
}
|
||||
return { value: options.value };
|
||||
}
|
||||
case 'regex': {
|
||||
if (!options.pattern) {
|
||||
log.error('--pattern is required for type "regex"');
|
||||
return null;
|
||||
}
|
||||
return { pattern: options.pattern };
|
||||
}
|
||||
default: {
|
||||
return { criteria: options.description || options.name };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const config = buildConfig();
|
||||
if (!config) return;
|
||||
|
||||
const rubric: Record<string, any> = {
|
||||
config,
|
||||
id: `rubric-${Date.now()}`,
|
||||
name: options.name,
|
||||
type: options.type,
|
||||
weight: options.weight ? Number.parseFloat(options.weight) : 1,
|
||||
};
|
||||
if (options.threshold) {
|
||||
rubric.threshold = Number.parseInt(options.threshold, 10) / 100;
|
||||
}
|
||||
|
||||
const addToTask = async (taskId: string) => {
|
||||
const current = (await client.task.getReview.query({ id: taskId })).data as any;
|
||||
const rubrics = current?.rubrics || [];
|
||||
|
||||
// Replace if same name exists, otherwise append
|
||||
const filtered = rubrics.filter((r: any) => r.name !== options.name);
|
||||
filtered.push(rubric);
|
||||
|
||||
await client.task.updateReview.mutate({
|
||||
id: taskId,
|
||||
review: {
|
||||
autoRetry: current?.autoRetry ?? true,
|
||||
enabled: current?.enabled ?? true,
|
||||
judge: current?.judge ?? {},
|
||||
maxIterations: current?.maxIterations ?? 3,
|
||||
rubrics: filtered,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
await addToTask(id);
|
||||
|
||||
if (options.recursive) {
|
||||
const subtasks = await client.task.getSubtasks.query({ id });
|
||||
for (const s of subtasks.data || []) {
|
||||
await addToTask(s.id);
|
||||
}
|
||||
log.info(
|
||||
`Rubric "${options.name}" [${options.type}] added to ${pc.bold(id)} + ${(subtasks.data || []).length} subtask(s).`,
|
||||
);
|
||||
} else {
|
||||
log.info(`Rubric "${options.name}" [${options.type}] added.`);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
rc.command('rm <id>')
|
||||
.description('Remove a review rubric')
|
||||
.requiredOption('-n, --name <name>', 'Rubric name to remove')
|
||||
.option('--recursive', 'Remove from all subtasks as well')
|
||||
.action(async (id: string, options: { name: string; recursive?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const removeFromTask = async (taskId: string) => {
|
||||
const current = (await client.task.getReview.query({ id: taskId })).data as any;
|
||||
if (!current) return;
|
||||
|
||||
const rubrics = (current.rubrics || []).filter((r: any) => r.name !== options.name);
|
||||
|
||||
await client.task.updateReview.mutate({
|
||||
id: taskId,
|
||||
review: { ...current, rubrics },
|
||||
});
|
||||
};
|
||||
|
||||
await removeFromTask(id);
|
||||
|
||||
if (options.recursive) {
|
||||
const subtasks = await client.task.getSubtasks.query({ id });
|
||||
for (const s of subtasks.data || []) {
|
||||
await removeFromTask(s.id);
|
||||
}
|
||||
log.info(
|
||||
`Rubric "${options.name}" removed from ${pc.bold(id)} + ${(subtasks.data || []).length} subtask(s).`,
|
||||
);
|
||||
} else {
|
||||
log.info(`Rubric "${options.name}" removed.`);
|
||||
}
|
||||
});
|
||||
|
||||
rv.command('run <id>')
|
||||
.description('Manually run review on content')
|
||||
.requiredOption('--content <text>', 'Content to review')
|
||||
.action(async (id: string, options: { content: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = (await client.task.runReview.mutate({
|
||||
content: options.content,
|
||||
id,
|
||||
})) as any;
|
||||
const r = result.data;
|
||||
|
||||
console.log(
|
||||
`\n${r.passed ? pc.green('✓ Review passed') : pc.red('✗ Review failed')} (${r.overallScore}%)`,
|
||||
);
|
||||
for (const s of r.rubricResults || []) {
|
||||
const icon = s.passed ? pc.green('✓') : pc.red('✗');
|
||||
const pct = Math.round(s.score * 100);
|
||||
console.log(` ${icon} ${s.rubricId}: ${pct}%${s.reason ? ` — ${s.reason}` : ''}`);
|
||||
}
|
||||
console.log();
|
||||
});
|
||||
}
|
||||
117
apps/cli/src/commands/task/topic.ts
Normal file
117
apps/cli/src/commands/task/topic.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
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';
|
||||
import { statusBadge } from './helpers';
|
||||
|
||||
export function registerTopicCommands(task: Command) {
|
||||
// ── topic ──────────────────────────────────────────────
|
||||
|
||||
const tp = task.command('topic').description('Manage task topics');
|
||||
|
||||
tp.command('list <id>')
|
||||
.description('List topics for a task')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(async (id: string, options: { json?: string | boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.task.getTopics.query({ id });
|
||||
|
||||
if (options.json !== undefined) {
|
||||
outputJson(result.data, options.json);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.data || result.data.length === 0) {
|
||||
log.info('No topics found for this task.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = result.data.map((t: any) => [
|
||||
`#${t.seq}`,
|
||||
t.id,
|
||||
statusBadge(t.status || 'running'),
|
||||
truncate(t.title || 'Untitled', 40),
|
||||
t.operationId ? pc.dim(truncate(t.operationId, 20)) : '-',
|
||||
timeAgo(t.createdAt),
|
||||
]);
|
||||
|
||||
printTable(rows, ['SEQ', 'TOPIC ID', 'STATUS', 'TITLE', 'OPERATION', 'CREATED']);
|
||||
});
|
||||
|
||||
tp.command('view <id> <topicId>')
|
||||
.description('View messages of a topic (topicId can be a seq number like "1")')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(async (id: string, topicId: string, options: { json?: string | boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
let resolvedTopicId = topicId;
|
||||
|
||||
// If it's a number, treat as seq index
|
||||
const seqNum = Number.parseInt(topicId, 10);
|
||||
if (!Number.isNaN(seqNum) && String(seqNum) === topicId) {
|
||||
const topicsResult = await client.task.getTopics.query({ id });
|
||||
const match = (topicsResult.data || []).find((t: any) => t.seq === seqNum);
|
||||
if (!match) {
|
||||
log.error(`Topic #${seqNum} not found for this task.`);
|
||||
return;
|
||||
}
|
||||
resolvedTopicId = match.id;
|
||||
log.info(
|
||||
`Topic #${seqNum}: ${pc.bold(match.title || 'Untitled')} ${pc.dim(resolvedTopicId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const messages = await client.message.getMessages.query({ topicId: resolvedTopicId });
|
||||
const items = Array.isArray(messages) ? messages : [];
|
||||
|
||||
if (options.json !== undefined) {
|
||||
outputJson(items, options.json);
|
||||
return;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
log.info('No messages in this topic.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log();
|
||||
for (const msg of items) {
|
||||
const role =
|
||||
msg.role === 'assistant'
|
||||
? pc.green('Assistant')
|
||||
: msg.role === 'user'
|
||||
? pc.blue('User')
|
||||
: pc.dim(msg.role);
|
||||
|
||||
console.log(`${pc.bold(role)} ${pc.dim(timeAgo(msg.createdAt))}`);
|
||||
if (msg.content) {
|
||||
console.log(msg.content);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
});
|
||||
|
||||
tp.command('cancel <topicId>')
|
||||
.description('Cancel a running topic and pause the task')
|
||||
.action(async (topicId: string) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.task.cancelTopic.mutate({ topicId });
|
||||
log.info(`Topic ${pc.bold(topicId)} canceled. Task paused.`);
|
||||
});
|
||||
|
||||
tp.command('delete <topicId>')
|
||||
.description('Delete a topic and its messages')
|
||||
.option('-y, --yes', 'Skip confirmation')
|
||||
.action(async (topicId: string, options: { yes?: boolean }) => {
|
||||
if (!options.yes) {
|
||||
const ok = await confirm(`Delete topic ${pc.bold(topicId)} and all its messages?`);
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
await client.task.deleteTopic.mutate({ topicId });
|
||||
log.info(`Topic ${pc.bold(topicId)} deleted.`);
|
||||
});
|
||||
}
|
||||
1
apps/cli/src/constants/auth.ts
Normal file
1
apps/cli/src/constants/auth.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const CLI_API_KEY_ENV = 'LOBEHUB_CLI_API_KEY';
|
||||
|
|
@ -1,69 +1,3 @@
|
|||
import { createRequire } from 'node:module';
|
||||
import { createProgram } from './program';
|
||||
|
||||
import { Command } from 'commander';
|
||||
|
||||
import { registerAgentCommand } from './commands/agent';
|
||||
import { registerAgentGroupCommand } from './commands/agent-group';
|
||||
import { registerBotCommand } from './commands/bot';
|
||||
import { registerConfigCommand } from './commands/config';
|
||||
import { registerConnectCommand } from './commands/connect';
|
||||
import { registerCronCommand } from './commands/cron';
|
||||
import { registerDeviceCommand } from './commands/device';
|
||||
import { registerDocCommand } from './commands/doc';
|
||||
import { registerEvalCommand } from './commands/eval';
|
||||
import { registerFileCommand } from './commands/file';
|
||||
import { registerGenerateCommand } from './commands/generate';
|
||||
import { registerKbCommand } from './commands/kb';
|
||||
import { registerLoginCommand } from './commands/login';
|
||||
import { registerLogoutCommand } from './commands/logout';
|
||||
import { registerMemoryCommand } from './commands/memory';
|
||||
import { registerMessageCommand } from './commands/message';
|
||||
import { registerModelCommand } from './commands/model';
|
||||
import { registerPluginCommand } from './commands/plugin';
|
||||
import { registerProviderCommand } from './commands/provider';
|
||||
import { registerSearchCommand } from './commands/search';
|
||||
import { registerSessionGroupCommand } from './commands/session-group';
|
||||
import { registerSkillCommand } from './commands/skill';
|
||||
import { registerStatusCommand } from './commands/status';
|
||||
import { registerThreadCommand } from './commands/thread';
|
||||
import { registerTopicCommand } from './commands/topic';
|
||||
import { registerUserCommand } from './commands/user';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { version } = require('../package.json');
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('lh')
|
||||
.description('LobeHub CLI - manage and connect to LobeHub services')
|
||||
.version(version);
|
||||
|
||||
registerLoginCommand(program);
|
||||
registerLogoutCommand(program);
|
||||
registerConnectCommand(program);
|
||||
registerDeviceCommand(program);
|
||||
registerStatusCommand(program);
|
||||
registerDocCommand(program);
|
||||
registerSearchCommand(program);
|
||||
registerKbCommand(program);
|
||||
registerMemoryCommand(program);
|
||||
registerAgentCommand(program);
|
||||
registerAgentGroupCommand(program);
|
||||
registerBotCommand(program);
|
||||
registerCronCommand(program);
|
||||
registerGenerateCommand(program);
|
||||
registerFileCommand(program);
|
||||
registerSkillCommand(program);
|
||||
registerSessionGroupCommand(program);
|
||||
registerThreadCommand(program);
|
||||
registerTopicCommand(program);
|
||||
registerMessageCommand(program);
|
||||
registerModelCommand(program);
|
||||
registerProviderCommand(program);
|
||||
registerPluginCommand(program);
|
||||
registerUserCommand(program);
|
||||
registerConfigCommand(program);
|
||||
registerEvalCommand(program);
|
||||
|
||||
program.parse();
|
||||
createProgram().parse();
|
||||
|
|
|
|||
17
apps/cli/src/man/generate.ts
Normal file
17
apps/cli/src/man/generate.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { cliVersion, createProgram } from '../program';
|
||||
import { generateAliasManPage, generateRootManPage } from './roff';
|
||||
|
||||
const outputDir = fileURLToPath(new URL('../../man/man1/', import.meta.url));
|
||||
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
const program = createProgram();
|
||||
|
||||
await Promise.all([
|
||||
writeFile(`${outputDir}lh.1`, generateRootManPage(program, cliVersion)),
|
||||
writeFile(`${outputDir}lobe.1`, generateAliasManPage('lh')),
|
||||
writeFile(`${outputDir}lobehub.1`, generateAliasManPage('lh')),
|
||||
]);
|
||||
28
apps/cli/src/man/roff.test.ts
Normal file
28
apps/cli/src/man/roff.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { Command } from 'commander';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { generateAliasManPage, generateRootManPage } from './roff';
|
||||
|
||||
describe('roff manual generator', () => {
|
||||
it('renders a root man page from the command tree', () => {
|
||||
const program = new Command();
|
||||
|
||||
program.name('lh').description('Sample CLI').version('1.0.0');
|
||||
|
||||
program.command('generate').alias('gen').description('Generate content');
|
||||
program.command('login').description('Log in');
|
||||
|
||||
const output = generateRootManPage(program, '1.2.3');
|
||||
|
||||
expect(output).toContain('.TH LH 1 "" "@lobehub/cli 1.2.3" "User Commands"');
|
||||
expect(output).toContain('.SH COMMANDS');
|
||||
expect(output).toContain('.B generate');
|
||||
expect(output).toContain('Generate content Alias: gen.');
|
||||
expect(output).toContain('.B login');
|
||||
expect(output).toContain('.SH OPTIONS');
|
||||
});
|
||||
|
||||
it('renders alias man pages as so links', () => {
|
||||
expect(generateAliasManPage('lh')).toBe('.so man1/lh.1\n');
|
||||
});
|
||||
});
|
||||
148
apps/cli/src/man/roff.ts
Normal file
148
apps/cli/src/man/roff.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import type { Command } from 'commander';
|
||||
|
||||
const ROOT_ALIASES = ['lobe', 'lobehub'];
|
||||
const HELP_COMMAND_NAME = 'help';
|
||||
|
||||
interface RoffDefinition {
|
||||
description: string;
|
||||
term: string;
|
||||
}
|
||||
|
||||
const FILE_ENTRIES = [
|
||||
{
|
||||
description: 'Encrypted access and refresh tokens.',
|
||||
path: '~/.lobehub/credentials.json',
|
||||
},
|
||||
{
|
||||
description: 'CLI settings such as server and gateway URLs.',
|
||||
path: '~/.lobehub/settings.json',
|
||||
},
|
||||
{
|
||||
description: 'Background daemon PID file.',
|
||||
path: '~/.lobehub/daemon.pid',
|
||||
},
|
||||
{
|
||||
description: 'Background daemon status metadata.',
|
||||
path: '~/.lobehub/daemon.status',
|
||||
},
|
||||
{
|
||||
description: 'Background daemon log output.',
|
||||
path: '~/.lobehub/daemon.log',
|
||||
},
|
||||
] as const;
|
||||
|
||||
const EXAMPLES = [
|
||||
{
|
||||
command: 'lh login',
|
||||
description: 'Start interactive login in the browser.',
|
||||
},
|
||||
{
|
||||
command: 'lh connect --daemon',
|
||||
description: 'Start the device gateway connection in the background.',
|
||||
},
|
||||
{
|
||||
command: 'lh search -q "gpt-5"',
|
||||
description: 'Search local resources for a query.',
|
||||
},
|
||||
{
|
||||
command: 'lh generate text "Write release notes"',
|
||||
description: 'Generate text from a prompt.',
|
||||
},
|
||||
{
|
||||
command: 'lh man generate',
|
||||
description: 'Show the built-in manual for the generate command group.',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function generateRootManPage(program: Command, version: string) {
|
||||
const help = program.createHelp();
|
||||
const commands = getVisibleCommands(program).map((command) => ({
|
||||
description: formatCommandDescription(help.subcommandDescription(command), command.aliases()),
|
||||
term: command.name(),
|
||||
}));
|
||||
const options = help.visibleOptions(program).map((option) => ({
|
||||
description: help.optionDescription(option),
|
||||
term: help.optionTerm(option),
|
||||
}));
|
||||
|
||||
const lines = [
|
||||
'.\\" Code generated by `npm run man:generate`; DO NOT EDIT.',
|
||||
'.\\" Manual command details come from the Commander command tree.',
|
||||
`.TH LH 1 "" "${escapeRoff(`@lobehub/cli ${version}`)}" "User Commands"`,
|
||||
'.SH NAME',
|
||||
`lh \\- ${escapeRoff(program.description() || 'LobeHub CLI')}`,
|
||||
'.SH SYNOPSIS',
|
||||
...formatSynopsisLines(),
|
||||
'.SH DESCRIPTION',
|
||||
escapeRoff(
|
||||
`${program.name()} is the command-line interface for LobeHub. It provides authentication, device gateway connectivity, content generation, resource search, and management commands for agents, files, models, providers, plugins, knowledge bases, threads, topics, and related resources.`,
|
||||
),
|
||||
'.PP',
|
||||
'For command-specific manuals, use the built-in manual command:',
|
||||
'.PP',
|
||||
'.RS',
|
||||
'.B lh man',
|
||||
'[\\fICOMMAND\\fR]...',
|
||||
'.RE',
|
||||
'.SH COMMANDS',
|
||||
...formatDefinitionSection(commands, 'B'),
|
||||
'.SH OPTIONS',
|
||||
...formatDefinitionSection(options, 'B'),
|
||||
'.SH FILES',
|
||||
...FILE_ENTRIES.flatMap((entry) => [
|
||||
'.TP',
|
||||
`.I ${escapeRoff(entry.path)}`,
|
||||
escapeRoff(entry.description),
|
||||
]),
|
||||
'.PP',
|
||||
'The base directory can be overridden with the',
|
||||
'.B LOBEHUB_CLI_HOME',
|
||||
'environment variable.',
|
||||
'.SH EXAMPLES',
|
||||
...EXAMPLES.flatMap((example) => [
|
||||
'.TP',
|
||||
`.B ${escapeRoff(example.command)}`,
|
||||
escapeRoff(example.description),
|
||||
]),
|
||||
'.SH SEE ALSO',
|
||||
'.BR lobe (1),',
|
||||
'.BR lobehub (1)',
|
||||
];
|
||||
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
export function generateAliasManPage(target: string) {
|
||||
return `.so man1/${target}.1\n`;
|
||||
}
|
||||
|
||||
function formatSynopsisLines() {
|
||||
return ['lh', ...ROOT_ALIASES]
|
||||
.flatMap((binary) => [`.B ${binary}`, '[\\fIOPTION\\fR]...', '[\\fICOMMAND\\fR]', '.br'])
|
||||
.slice(0, -1);
|
||||
}
|
||||
|
||||
function getVisibleCommands(command: Command) {
|
||||
return command
|
||||
.createHelp()
|
||||
.visibleCommands(command)
|
||||
.filter((subcommand) => subcommand.name() !== HELP_COMMAND_NAME);
|
||||
}
|
||||
|
||||
function formatCommandDescription(description: string, aliases: string[]) {
|
||||
if (aliases.length === 0) return description;
|
||||
|
||||
return `${description} Alias: ${aliases.join(', ')}.`;
|
||||
}
|
||||
|
||||
function formatDefinitionSection(items: RoffDefinition[], macro: 'B' | 'I') {
|
||||
return items.flatMap((item) => [
|
||||
'.TP',
|
||||
`.${macro} ${escapeRoff(item.term)}`,
|
||||
escapeRoff(item.description),
|
||||
]);
|
||||
}
|
||||
|
||||
function escapeRoff(value: string) {
|
||||
return value.replaceAll('\\', '\\\\').replaceAll('-', '\\-');
|
||||
}
|
||||
77
apps/cli/src/program.ts
Normal file
77
apps/cli/src/program.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { createRequire } from 'node:module';
|
||||
|
||||
import { Command } from 'commander';
|
||||
|
||||
import { registerAgentCommand } from './commands/agent';
|
||||
import { registerAgentGroupCommand } from './commands/agent-group';
|
||||
import { registerBotCommand } from './commands/bot';
|
||||
import { registerCompletionCommand } from './commands/completion';
|
||||
import { registerConfigCommand } from './commands/config';
|
||||
import { registerConnectCommand } from './commands/connect';
|
||||
import { registerCronCommand } from './commands/cron';
|
||||
import { registerDeviceCommand } from './commands/device';
|
||||
import { registerDocCommand } from './commands/doc';
|
||||
import { registerEvalCommand } from './commands/eval';
|
||||
import { registerFileCommand } from './commands/file';
|
||||
import { registerGenerateCommand } from './commands/generate';
|
||||
import { registerKbCommand } from './commands/kb';
|
||||
import { registerLoginCommand } from './commands/login';
|
||||
import { registerLogoutCommand } from './commands/logout';
|
||||
import { registerManCommand } from './commands/man';
|
||||
import { registerMemoryCommand } from './commands/memory';
|
||||
import { registerMessageCommand } from './commands/message';
|
||||
import { registerModelCommand } from './commands/model';
|
||||
import { registerPluginCommand } from './commands/plugin';
|
||||
import { registerProviderCommand } from './commands/provider';
|
||||
import { registerSearchCommand } from './commands/search';
|
||||
import { registerSessionGroupCommand } from './commands/session-group';
|
||||
import { registerSkillCommand } from './commands/skill';
|
||||
import { registerStatusCommand } from './commands/status';
|
||||
import { registerThreadCommand } from './commands/thread';
|
||||
import { registerTopicCommand } from './commands/topic';
|
||||
import { registerUserCommand } from './commands/user';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { version } = require('../package.json');
|
||||
|
||||
export function createProgram() {
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('lh')
|
||||
.description('LobeHub CLI - manage and connect to LobeHub services')
|
||||
.version(version);
|
||||
|
||||
registerLoginCommand(program);
|
||||
registerLogoutCommand(program);
|
||||
registerCompletionCommand(program);
|
||||
registerManCommand(program);
|
||||
registerConnectCommand(program);
|
||||
registerDeviceCommand(program);
|
||||
registerStatusCommand(program);
|
||||
registerDocCommand(program);
|
||||
registerSearchCommand(program);
|
||||
registerKbCommand(program);
|
||||
registerMemoryCommand(program);
|
||||
registerAgentCommand(program);
|
||||
registerAgentGroupCommand(program);
|
||||
registerBotCommand(program);
|
||||
registerCronCommand(program);
|
||||
registerGenerateCommand(program);
|
||||
registerFileCommand(program);
|
||||
registerSkillCommand(program);
|
||||
registerSessionGroupCommand(program);
|
||||
registerThreadCommand(program);
|
||||
registerTopicCommand(program);
|
||||
registerMessageCommand(program);
|
||||
registerModelCommand(program);
|
||||
registerProviderCommand(program);
|
||||
registerPluginCommand(program);
|
||||
registerUserCommand(program);
|
||||
registerConfigCommand(program);
|
||||
registerEvalCommand(program);
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
export { version as cliVersion };
|
||||
|
|
@ -5,18 +5,19 @@ import path from 'node:path';
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { log } from '../utils/logger';
|
||||
import { loadSettings, saveSettings } from './index';
|
||||
import { loadSettings, normalizeUrl, resolveServerUrl, saveSettings } from './index';
|
||||
|
||||
const tmpDir = path.join(os.tmpdir(), 'lobehub-cli-test-settings');
|
||||
const settingsDir = path.join(tmpDir, '.lobehub');
|
||||
const settingsFile = path.join(settingsDir, 'settings.json');
|
||||
const originalServer = process.env.LOBEHUB_SERVER;
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, any>>();
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
...actual['default'],
|
||||
...actual.default,
|
||||
homedir: () => path.join(os.tmpdir(), 'lobehub-cli-test-settings'),
|
||||
},
|
||||
};
|
||||
|
|
@ -31,10 +32,12 @@ vi.mock('../utils/logger', () => ({
|
|||
describe('settings', () => {
|
||||
beforeEach(() => {
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
delete process.env.LOBEHUB_SERVER;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { force: true, recursive: true });
|
||||
process.env.LOBEHUB_SERVER = originalServer;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
|
|
@ -64,4 +67,28 @@ describe('settings', () => {
|
|||
expect(loadSettings()).toBeNull();
|
||||
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('Please delete this file'));
|
||||
});
|
||||
|
||||
it('should normalize trailing slashes', () => {
|
||||
expect(normalizeUrl('https://self-hosted.example.com/')).toBe(
|
||||
'https://self-hosted.example.com',
|
||||
);
|
||||
expect(normalizeUrl(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should prefer LOBEHUB_SERVER over settings', () => {
|
||||
saveSettings({ serverUrl: 'https://settings.example.com/' });
|
||||
process.env.LOBEHUB_SERVER = 'https://env.example.com/';
|
||||
|
||||
expect(resolveServerUrl()).toBe('https://env.example.com');
|
||||
});
|
||||
|
||||
it('should fall back to settings then official server', () => {
|
||||
saveSettings({ serverUrl: 'https://settings.example.com/' });
|
||||
|
||||
expect(resolveServerUrl()).toBe('https://settings.example.com');
|
||||
|
||||
fs.unlinkSync(settingsFile);
|
||||
|
||||
expect(resolveServerUrl()).toBe('https://app.lobehub.com');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,10 +14,17 @@ const LOBEHUB_DIR_NAME = process.env.LOBEHUB_CLI_HOME || '.lobehub';
|
|||
const SETTINGS_DIR = path.join(os.homedir(), LOBEHUB_DIR_NAME);
|
||||
const SETTINGS_FILE = path.join(SETTINGS_DIR, 'settings.json');
|
||||
|
||||
function normalizeUrl(url: string | undefined): string | undefined {
|
||||
export function normalizeUrl(url: string | undefined): string | undefined {
|
||||
return url ? url.replace(/\/$/, '') : undefined;
|
||||
}
|
||||
|
||||
export function resolveServerUrl(): string {
|
||||
const envServerUrl = normalizeUrl(process.env.LOBEHUB_SERVER);
|
||||
const settingsServerUrl = normalizeUrl(loadSettings()?.serverUrl);
|
||||
|
||||
return envServerUrl || settingsServerUrl || OFFICIAL_SERVER_URL;
|
||||
}
|
||||
|
||||
export function saveSettings(settings: StoredSettings): void {
|
||||
const serverUrl = normalizeUrl(settings.serverUrl);
|
||||
const gatewayUrl = normalizeUrl(settings.gatewayUrl);
|
||||
|
|
|
|||
157
apps/cli/src/utils/completion.ts
Normal file
157
apps/cli/src/utils/completion.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import type { Command, Option } from 'commander';
|
||||
import { InvalidArgumentError } from 'commander';
|
||||
|
||||
const CLI_BIN_NAMES = ['lh', 'lobe', 'lobehub'] as const;
|
||||
const SUPPORTED_SHELLS = ['bash', 'zsh'] as const;
|
||||
|
||||
type SupportedShell = (typeof SUPPORTED_SHELLS)[number];
|
||||
|
||||
interface HiddenCommand extends Command {
|
||||
_hidden?: boolean;
|
||||
}
|
||||
|
||||
interface HiddenOption extends Option {
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
function isVisibleCommand(command: Command) {
|
||||
return !(command as HiddenCommand)._hidden;
|
||||
}
|
||||
|
||||
function isVisibleOption(option: Option) {
|
||||
return !(option as HiddenOption).hidden;
|
||||
}
|
||||
|
||||
function listCommandTokens(command: Command) {
|
||||
return [command.name(), ...command.aliases()].filter(Boolean);
|
||||
}
|
||||
|
||||
function listOptionTokens(command: Command) {
|
||||
return command.options
|
||||
.filter(isVisibleOption)
|
||||
.flatMap((option) => [option.short, option.long].filter(Boolean) as string[]);
|
||||
}
|
||||
|
||||
function findSubcommand(command: Command, token: string) {
|
||||
return command.commands.find(
|
||||
(subcommand) => isVisibleCommand(subcommand) && listCommandTokens(subcommand).includes(token),
|
||||
);
|
||||
}
|
||||
|
||||
function findOption(command: Command, token: string) {
|
||||
return command.options.find(
|
||||
(option) =>
|
||||
isVisibleOption(option) && (option.short === token || option.long === token || false),
|
||||
);
|
||||
}
|
||||
|
||||
function filterCandidates(candidates: string[], currentWord: string) {
|
||||
const unique = [...new Set(candidates)];
|
||||
|
||||
if (!currentWord) return unique.sort();
|
||||
|
||||
return unique.filter((candidate) => candidate.startsWith(currentWord)).sort();
|
||||
}
|
||||
|
||||
function resolveCommandContext(program: Command, completedWords: string[]) {
|
||||
let command = program;
|
||||
let expectsOptionValue = false;
|
||||
|
||||
for (const token of completedWords) {
|
||||
if (expectsOptionValue) {
|
||||
expectsOptionValue = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!token) continue;
|
||||
|
||||
if (token.startsWith('-')) {
|
||||
const option = findOption(command, token);
|
||||
|
||||
expectsOptionValue = Boolean(
|
||||
option && (option.required || option.optional || option.variadic),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const subcommand = findSubcommand(command, token);
|
||||
if (subcommand) {
|
||||
command = subcommand;
|
||||
}
|
||||
}
|
||||
|
||||
return { command, expectsOptionValue };
|
||||
}
|
||||
|
||||
export function getCompletionCandidates(
|
||||
program: Command,
|
||||
words: string[],
|
||||
currentWordIndex = words.length,
|
||||
) {
|
||||
const safeCurrentWordIndex = Math.min(Math.max(currentWordIndex, 0), words.length);
|
||||
const completedWords = words.slice(0, safeCurrentWordIndex);
|
||||
const currentWord = safeCurrentWordIndex < words.length ? words[safeCurrentWordIndex] || '' : '';
|
||||
const { command, expectsOptionValue } = resolveCommandContext(program, completedWords);
|
||||
|
||||
if (expectsOptionValue) return [];
|
||||
|
||||
const commandCandidates = currentWord.startsWith('-')
|
||||
? []
|
||||
: command.commands
|
||||
.filter(isVisibleCommand)
|
||||
.flatMap((subcommand) => listCommandTokens(subcommand));
|
||||
|
||||
if (commandCandidates.length > 0) {
|
||||
return filterCandidates(commandCandidates, currentWord);
|
||||
}
|
||||
|
||||
return filterCandidates(listOptionTokens(command), currentWord);
|
||||
}
|
||||
|
||||
export function parseCompletionWordIndex(rawValue: string | undefined, words: string[]) {
|
||||
const parsedValue = rawValue ? Number.parseInt(rawValue, 10) : Number.NaN;
|
||||
|
||||
if (Number.isNaN(parsedValue)) return words.length;
|
||||
|
||||
return Math.min(Math.max(parsedValue, 0), words.length);
|
||||
}
|
||||
|
||||
export function resolveCompletionShell(shell?: string): SupportedShell {
|
||||
const fallbackShell = process.env.SHELL?.split('/').pop() || 'zsh';
|
||||
const resolvedShell = (shell || fallbackShell).toLowerCase();
|
||||
|
||||
if ((SUPPORTED_SHELLS as readonly string[]).includes(resolvedShell)) {
|
||||
return resolvedShell as SupportedShell;
|
||||
}
|
||||
|
||||
throw new InvalidArgumentError(
|
||||
`Unsupported shell "${resolvedShell}". Supported shells: ${SUPPORTED_SHELLS.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function renderCompletionScript(shell: SupportedShell) {
|
||||
if (shell === 'bash') {
|
||||
return [
|
||||
'# shellcheck shell=bash',
|
||||
'_lobehub_completion() {',
|
||||
" local IFS=$'\\n'",
|
||||
' local current_index=$((COMP_CWORD - 1))',
|
||||
' local completions',
|
||||
' completions=$(LOBEHUB_COMP_CWORD="$current_index" "${COMP_WORDS[0]}" __complete "${COMP_WORDS[@]:1}")',
|
||||
' COMPREPLY=($(printf \'%s\\n\' "$completions"))',
|
||||
'}',
|
||||
`complete -o nosort -F _lobehub_completion ${CLI_BIN_NAMES.join(' ')}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
return [
|
||||
`#compdef ${CLI_BIN_NAMES.join(' ')}`,
|
||||
'_lobehub_completion() {',
|
||||
' local -a completions',
|
||||
' local current_index=$((CURRENT - 2))',
|
||||
' completions=("${(@f)$(LOBEHUB_COMP_CWORD="$current_index" "$words[1]" __complete "${(@)words[@]:1}")}")',
|
||||
" _describe 'values' completions",
|
||||
'}',
|
||||
`compdef _lobehub_completion ${CLI_BIN_NAMES.join(' ')}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
|
@ -87,7 +87,7 @@ function stripAnsi(s: string): string {
|
|||
* Calculate the display width of a string in the terminal.
|
||||
* CJK characters and fullwidth symbols occupy 2 columns.
|
||||
*/
|
||||
function displayWidth(s: string): number {
|
||||
export function displayWidth(s: string): number {
|
||||
const plain = stripAnsi(s);
|
||||
let width = 0;
|
||||
for (const char of plain) {
|
||||
|
|
|
|||
14
apps/cli/tsdown.config.ts
Normal file
14
apps/cli/tsdown.config.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { defineConfig } from 'tsdown';
|
||||
|
||||
export default defineConfig({
|
||||
banner: { js: '#!/usr/bin/env node' },
|
||||
clean: true,
|
||||
deps: {
|
||||
neverBundle: ['@napi-rs/canvas'],
|
||||
},
|
||||
entry: ['src/index.ts'],
|
||||
fixedExtension: false,
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
});
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
banner: { js: '#!/usr/bin/env node' },
|
||||
clean: true,
|
||||
entry: ['src/index.ts'],
|
||||
external: ['@napi-rs/canvas', 'fast-glob', 'diff', 'debug'],
|
||||
format: ['esm'],
|
||||
noExternal: [
|
||||
'@lobechat/device-gateway-client',
|
||||
'@lobechat/local-file-shell',
|
||||
'@lobechat/file-loaders',
|
||||
'@trpc/client',
|
||||
'superjson',
|
||||
],
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
});
|
||||
|
|
@ -52,8 +52,9 @@ export default defineConfig({
|
|||
minify: !isDev,
|
||||
outDir: 'dist/main',
|
||||
rollupOptions: {
|
||||
// Native modules must be externalized to work correctly
|
||||
external: getExternalDependencies(),
|
||||
// Native modules must be externalized to work correctly.
|
||||
// bufferutil and utf-8-validate are optional peer deps of ws that may not be installed.
|
||||
external: [...getExternalDependencies(), 'bufferutil', 'utf-8-validate'],
|
||||
output: {
|
||||
// Prevent debug package from being bundled into index.js to avoid side-effect pollution
|
||||
manualChunks(id) {
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@
|
|||
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@lobechat/desktop-bridge": "workspace:*",
|
||||
"@lobechat/device-gateway-client": "workspace:*",
|
||||
"@lobechat/electron-client-ipc": "workspace:*",
|
||||
"@lobechat/electron-server-ipc": "workspace:*",
|
||||
"@lobechat/file-loaders": "workspace:*",
|
||||
|
|
@ -66,7 +67,7 @@
|
|||
"consola": "^3.4.2",
|
||||
"cookie": "^1.1.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"diff": "^8.0.2",
|
||||
"diff": "^8.0.4",
|
||||
"electron": "41.0.2",
|
||||
"electron-builder": "^26.8.1",
|
||||
"electron-devtools-installer": "4.0.0",
|
||||
|
|
|
|||
|
|
@ -3,5 +3,6 @@ packages:
|
|||
- '../../packages/electron-client-ipc'
|
||||
- '../../packages/file-loaders'
|
||||
- '../../packages/desktop-bridge'
|
||||
- '../../packages/device-gateway-client'
|
||||
- '../../packages/local-file-shell'
|
||||
- '.'
|
||||
|
|
|
|||
|
|
@ -28,6 +28,11 @@ export const defaultProxySettings: NetworkProxySettings = {
|
|||
export const STORE_DEFAULTS: ElectronMainStore = {
|
||||
dataSyncConfig: { storageMode: 'cloud' },
|
||||
encryptedTokens: {},
|
||||
gatewayDeviceDescription: '',
|
||||
gatewayDeviceId: '',
|
||||
gatewayDeviceName: '',
|
||||
gatewayEnabled: true,
|
||||
gatewayUrl: 'https://device-gateway.lobehub.com',
|
||||
locale: 'auto',
|
||||
networkProxy: defaultProxySettings,
|
||||
shortcuts: DEFAULT_SHORTCUTS_CONFIG,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type {
|
|||
} from '@lobechat/electron-client-ipc';
|
||||
import { BrowserWindow, shell } from 'electron';
|
||||
|
||||
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
|
||||
import { appendVercelCookie } from '@/utils/http-headers';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
|
|
@ -43,14 +44,14 @@ export default class AuthCtr extends ControllerModule {
|
|||
/**
|
||||
* Polling related parameters
|
||||
*/
|
||||
|
||||
|
||||
private pollingInterval: NodeJS.Timeout | null = null;
|
||||
private cachedRemoteUrl: string | null = null;
|
||||
|
||||
/**
|
||||
* Auto-refresh timer
|
||||
*/
|
||||
|
||||
|
||||
private autoRefreshTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
/**
|
||||
|
|
@ -531,6 +532,9 @@ export default class AuthCtr extends ControllerModule {
|
|||
// Start auto-refresh timer
|
||||
this.startAutoRefresh();
|
||||
|
||||
// Connect to device gateway after successful login
|
||||
this.connectGateway();
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Exchanging authorization code failed:', error);
|
||||
|
|
@ -538,6 +542,19 @@ export default class AuthCtr extends ControllerModule {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to device gateway (fire-and-forget)
|
||||
*/
|
||||
private connectGateway() {
|
||||
const gatewaySrv = this.app.getService(GatewayConnectionService);
|
||||
if (gatewaySrv) {
|
||||
logger.info('Triggering gateway connection after login');
|
||||
gatewaySrv.connect().catch((error) => {
|
||||
logger.error('Gateway connection after login failed:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast token refreshed event
|
||||
*/
|
||||
|
|
|
|||
139
apps/desktop/src/main/controllers/GatewayConnectionCtr.ts
Normal file
139
apps/desktop/src/main/controllers/GatewayConnectionCtr.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import type { GatewayConnectionStatus } from '@lobechat/electron-client-ipc';
|
||||
|
||||
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
|
||||
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
import LocalFileCtr from './LocalFileCtr';
|
||||
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
|
||||
import ShellCommandCtr from './ShellCommandCtr';
|
||||
|
||||
/**
|
||||
* GatewayConnectionCtr
|
||||
*
|
||||
* Thin IPC layer that delegates to GatewayConnectionService.
|
||||
*/
|
||||
export default class GatewayConnectionCtr extends ControllerModule {
|
||||
static override readonly groupName = 'gatewayConnection';
|
||||
|
||||
// ─── Service Accessor ───
|
||||
|
||||
private get service() {
|
||||
return this.app.getService(GatewayConnectionService);
|
||||
}
|
||||
|
||||
private get remoteServerConfigCtr() {
|
||||
return this.app.getController(RemoteServerConfigCtr);
|
||||
}
|
||||
|
||||
private get localFileCtr() {
|
||||
return this.app.getController(LocalFileCtr);
|
||||
}
|
||||
|
||||
private get shellCommandCtr() {
|
||||
return this.app.getController(ShellCommandCtr);
|
||||
}
|
||||
|
||||
// ─── Lifecycle ───
|
||||
|
||||
afterAppReady() {
|
||||
const srv = this.service;
|
||||
|
||||
srv.loadOrCreateDeviceId();
|
||||
|
||||
// Wire up token provider and refresher
|
||||
srv.setTokenProvider(() => this.remoteServerConfigCtr.getAccessToken());
|
||||
srv.setTokenRefresher(() => this.remoteServerConfigCtr.refreshAccessToken());
|
||||
|
||||
// Wire up tool call handler
|
||||
srv.setToolCallHandler((apiName, args) => this.executeToolCall(apiName, args));
|
||||
|
||||
// Auto-connect if already logged in
|
||||
this.tryAutoConnect();
|
||||
}
|
||||
|
||||
// ─── IPC Methods (Renderer → Main) ───
|
||||
|
||||
@IpcMethod()
|
||||
async connect(): Promise<{ error?: string; success: boolean }> {
|
||||
this.app.storeManager.set('gatewayEnabled', true);
|
||||
return this.service.connect();
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async disconnect(): Promise<{ success: boolean }> {
|
||||
this.app.storeManager.set('gatewayEnabled', false);
|
||||
return this.service.disconnect();
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async getConnectionStatus(): Promise<{ status: GatewayConnectionStatus }> {
|
||||
return { status: this.service.getStatus() };
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async getDeviceInfo(): Promise<{
|
||||
description: string;
|
||||
deviceId: string;
|
||||
hostname: string;
|
||||
name: string;
|
||||
platform: string;
|
||||
}> {
|
||||
return this.service.getDeviceInfo();
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async setDeviceName(params: { name: string }): Promise<{ success: boolean }> {
|
||||
this.service.setDeviceName(params.name);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async setDeviceDescription(params: { description: string }): Promise<{ success: boolean }> {
|
||||
this.service.setDeviceDescription(params.description);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ─── Auto Connect ───
|
||||
|
||||
private async tryAutoConnect() {
|
||||
const gatewayEnabled = this.app.storeManager.get('gatewayEnabled');
|
||||
if (!gatewayEnabled) return;
|
||||
|
||||
const isConfigured = await this.remoteServerConfigCtr.isRemoteServerConfigured();
|
||||
if (!isConfigured) return;
|
||||
|
||||
const token = await this.remoteServerConfigCtr.getAccessToken();
|
||||
if (!token) return;
|
||||
|
||||
await this.service.connect();
|
||||
}
|
||||
|
||||
// ─── Tool Call Routing ───
|
||||
|
||||
private async executeToolCall(apiName: string, args: any): Promise<unknown> {
|
||||
const methodMap: Record<string, () => Promise<unknown>> = {
|
||||
editLocalFile: () => this.localFileCtr.handleEditFile(args),
|
||||
globLocalFiles: () => this.localFileCtr.handleGlobFiles(args),
|
||||
grepContent: () => this.localFileCtr.handleGrepContent(args),
|
||||
listLocalFiles: () => this.localFileCtr.listLocalFiles(args),
|
||||
moveLocalFiles: () => this.localFileCtr.handleMoveFiles(args),
|
||||
readLocalFile: () => this.localFileCtr.readFile(args),
|
||||
renameLocalFile: () => this.localFileCtr.handleRenameFile(args),
|
||||
searchLocalFiles: () => this.localFileCtr.handleLocalFilesSearch(args),
|
||||
writeLocalFile: () => this.localFileCtr.handleWriteFile(args),
|
||||
|
||||
getCommandOutput: () => this.shellCommandCtr.handleGetCommandOutput(args),
|
||||
killCommand: () => this.shellCommandCtr.handleKillCommand(args),
|
||||
runCommand: () => this.shellCommandCtr.handleRunCommand(args),
|
||||
};
|
||||
|
||||
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 handler();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
import { constants } from 'node:fs';
|
||||
import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { access, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
type AuditSafePathsParams,
|
||||
type AuditSafePathsResult,
|
||||
type EditLocalFileParams,
|
||||
type EditLocalFileResult,
|
||||
type GlobFilesParams,
|
||||
|
|
@ -52,6 +54,72 @@ import { ControllerModule, IpcMethod } from './index';
|
|||
// Create logger
|
||||
const logger = createLogger('controllers:LocalFileCtr');
|
||||
|
||||
const SAFE_PATH_PREFIXES = ['/tmp', '/var/tmp'] as const;
|
||||
|
||||
const normalizeAbsolutePath = (inputPath: string): string =>
|
||||
path.normalize(path.isAbsolute(inputPath) ? inputPath : `/${inputPath}`);
|
||||
|
||||
const resolvePathWithScope = (inputPath: string, scope: string): string =>
|
||||
path.isAbsolute(inputPath) ? inputPath : path.join(scope, inputPath);
|
||||
|
||||
const isWithinSafePathPrefixes = (targetPath: string, prefixes: readonly string[]): boolean =>
|
||||
prefixes.some((prefix) => targetPath === prefix || targetPath.startsWith(`${prefix}${path.sep}`));
|
||||
|
||||
const resolveNearestExistingRealPath = async (targetPath: string): Promise<string | undefined> => {
|
||||
let currentPath = targetPath;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
await access(currentPath, constants.F_OK);
|
||||
return normalizeAbsolutePath(await realpath(currentPath));
|
||||
} catch {
|
||||
const parentPath = path.dirname(currentPath);
|
||||
if (parentPath === currentPath) return undefined;
|
||||
currentPath = parentPath;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resolveSafePathRealPrefixes = async (): Promise<string[]> => {
|
||||
const prefixes = new Set<string>(SAFE_PATH_PREFIXES);
|
||||
|
||||
for (const safePrefix of SAFE_PATH_PREFIXES) {
|
||||
try {
|
||||
prefixes.add(normalizeAbsolutePath(await realpath(safePrefix)));
|
||||
} catch {
|
||||
// Keep the lexical prefix if the platform does not expose this directory.
|
||||
}
|
||||
}
|
||||
|
||||
return [...prefixes];
|
||||
};
|
||||
|
||||
const areAllPathsSafeOnDisk = async (
|
||||
paths: string[],
|
||||
resolveAgainstScope: string,
|
||||
): Promise<boolean> => {
|
||||
if (paths.length === 0) return false;
|
||||
|
||||
const safeRealPrefixes = await resolveSafePathRealPrefixes();
|
||||
|
||||
for (const currentPath of paths) {
|
||||
const normalizedPath = normalizeAbsolutePath(
|
||||
resolvePathWithScope(currentPath, resolveAgainstScope),
|
||||
);
|
||||
|
||||
if (!isWithinSafePathPrefixes(normalizedPath, SAFE_PATH_PREFIXES)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const realPath = await resolveNearestExistingRealPath(normalizedPath);
|
||||
if (!realPath || !isWithinSafePathPrefixes(realPath, safeRealPrefixes)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export default class LocalFileCtr extends ControllerModule {
|
||||
static override readonly groupName = 'localSystem';
|
||||
private get searchService() {
|
||||
|
|
@ -240,6 +308,18 @@ export default class LocalFileCtr extends ControllerModule {
|
|||
return writeLocalFile({ content, path: filePath });
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async auditSafePaths({
|
||||
paths,
|
||||
resolveAgainstScope,
|
||||
}: AuditSafePathsParams): Promise<AuditSafePathsResult> {
|
||||
logger.debug('Auditing safe paths', { count: paths.length, resolveAgainstScope });
|
||||
|
||||
return {
|
||||
allSafe: await areAllPathsSafeOnDisk(paths, resolveAgainstScope),
|
||||
};
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async handlePrepareSkillDirectory({
|
||||
forceRefresh,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import retry from 'async-retry';
|
|||
import { safeStorage, session as electronSession } from 'electron';
|
||||
|
||||
import { OFFICIAL_CLOUD_SERVER } from '@/const/env';
|
||||
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
|
||||
import { appendVercelCookie } from '@/utils/http-headers';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
|
|
@ -319,6 +320,13 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
|||
// Also clear from persistent storage
|
||||
logger.debug(`Deleting tokens from store key: ${this.encryptedTokensKey}`);
|
||||
this.app.storeManager.delete(this.encryptedTokensKey);
|
||||
|
||||
// Disconnect gateway when tokens are cleared (logout / token refresh failure)
|
||||
const gatewaySrv = this.app.getService(GatewayConnectionService);
|
||||
if (gatewaySrv) {
|
||||
logger.debug('Disconnecting gateway due to token clear');
|
||||
await gatewaySrv.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
import type { DataSyncConfig } from '@lobechat/electron-client-ipc';
|
||||
import { BrowserWindow, shell } from 'electron';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
|
@ -100,6 +99,7 @@ const mockApp = {
|
|||
}
|
||||
return null;
|
||||
}),
|
||||
getService: vi.fn(() => null),
|
||||
} as unknown as App;
|
||||
|
||||
describe('AuthCtr', () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,606 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
|
||||
|
||||
import GatewayConnectionCtr from '../GatewayConnectionCtr';
|
||||
import LocalFileCtr from '../LocalFileCtr';
|
||||
import RemoteServerConfigCtr from '../RemoteServerConfigCtr';
|
||||
import ShellCommandCtr from '../ShellCommandCtr';
|
||||
|
||||
// ─── Mocks ───
|
||||
|
||||
const { ipcMainHandleMock, MockGatewayClient } = vi.hoisted(() => {
|
||||
const { EventEmitter } = require('node:events');
|
||||
|
||||
// Must be defined inside vi.hoisted so it's available when vi.mock factories run
|
||||
class _MockGatewayClient extends EventEmitter {
|
||||
static lastInstance: _MockGatewayClient | null = null;
|
||||
static lastOptions: any = null;
|
||||
|
||||
connectionStatus = 'disconnected' as string;
|
||||
currentDeviceId: string;
|
||||
|
||||
connect = vi.fn(async () => {
|
||||
this.connectionStatus = 'connecting';
|
||||
this.emit('status_changed', 'connecting');
|
||||
});
|
||||
|
||||
disconnect = vi.fn(async () => {
|
||||
this.connectionStatus = 'disconnected';
|
||||
});
|
||||
|
||||
sendToolCallResponse = vi.fn();
|
||||
|
||||
constructor(options: any) {
|
||||
super();
|
||||
this.currentDeviceId = options.deviceId || 'mock-device-id';
|
||||
_MockGatewayClient.lastInstance = this;
|
||||
_MockGatewayClient.lastOptions = options;
|
||||
}
|
||||
|
||||
// Test helpers
|
||||
simulateConnected() {
|
||||
this.connectionStatus = 'connected';
|
||||
this.emit('status_changed', 'connected');
|
||||
this.emit('connected');
|
||||
}
|
||||
|
||||
simulateStatusChanged(status: string) {
|
||||
this.connectionStatus = status;
|
||||
this.emit('status_changed', status);
|
||||
}
|
||||
|
||||
simulateToolCallRequest(apiName: string, args: object, requestId = 'req-1') {
|
||||
this.emit('tool_call_request', {
|
||||
requestId,
|
||||
toolCall: {
|
||||
apiName,
|
||||
arguments: JSON.stringify(args),
|
||||
identifier: 'test-tool',
|
||||
},
|
||||
type: 'tool_call_request',
|
||||
});
|
||||
}
|
||||
|
||||
simulateAuthExpired() {
|
||||
this.emit('auth_expired');
|
||||
}
|
||||
|
||||
simulateError(message: string) {
|
||||
this.emit('error', new Error(message));
|
||||
}
|
||||
|
||||
simulateReconnecting(delay: number) {
|
||||
this.connectionStatus = 'reconnecting';
|
||||
this.emit('status_changed', 'reconnecting');
|
||||
this.emit('reconnecting', delay);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
MockGatewayClient: _MockGatewayClient,
|
||||
ipcMainHandleMock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: vi.fn((name: string) => `/mock/${name}`),
|
||||
},
|
||||
ipcMain: { handle: ipcMainHandleMock },
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
verbose: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('electron-is', () => ({
|
||||
macOS: vi.fn(() => false),
|
||||
windows: vi.fn(() => false),
|
||||
linux: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('@/const/env', () => ({
|
||||
OFFICIAL_CLOUD_SERVER: 'https://lobehub-cloud.com',
|
||||
isMac: false,
|
||||
isWindows: false,
|
||||
isLinux: false,
|
||||
isDev: false,
|
||||
}));
|
||||
|
||||
vi.mock('node:crypto', () => ({
|
||||
randomUUID: vi.fn(() => 'mock-device-uuid'),
|
||||
}));
|
||||
|
||||
vi.mock('node:os', () => ({
|
||||
default: { hostname: vi.fn(() => 'mock-hostname') },
|
||||
}));
|
||||
|
||||
vi.mock('@lobechat/device-gateway-client', () => ({
|
||||
GatewayClient: MockGatewayClient,
|
||||
}));
|
||||
|
||||
// ─── Mock Controllers ───
|
||||
|
||||
const mockLocalFileCtr = {
|
||||
handleEditFile: vi.fn().mockResolvedValue({ success: true }),
|
||||
handleGlobFiles: vi.fn().mockResolvedValue({ files: [] }),
|
||||
handleGrepContent: vi.fn().mockResolvedValue({ matches: [] }),
|
||||
handleLocalFilesSearch: vi.fn().mockResolvedValue([]),
|
||||
handleMoveFiles: vi.fn().mockResolvedValue([]),
|
||||
handleRenameFile: vi.fn().mockResolvedValue({ newPath: '/mock/renamed.txt', success: true }),
|
||||
handleWriteFile: vi.fn().mockResolvedValue({ success: true }),
|
||||
listLocalFiles: vi.fn().mockResolvedValue([]),
|
||||
readFile: vi.fn().mockResolvedValue({
|
||||
charCount: 12,
|
||||
content: 'file content',
|
||||
createdTime: new Date('2024-01-01'),
|
||||
filename: 'test.txt',
|
||||
fileType: '.txt',
|
||||
lineCount: 1,
|
||||
loc: [1, 1] as [number, number],
|
||||
modifiedTime: new Date('2024-01-01'),
|
||||
totalCharCount: 12,
|
||||
totalLineCount: 1,
|
||||
}),
|
||||
} as unknown as LocalFileCtr;
|
||||
|
||||
const mockShellCommandCtr = {
|
||||
handleGetCommandOutput: vi.fn().mockResolvedValue({ output: '' }),
|
||||
handleKillCommand: vi.fn().mockResolvedValue({ success: true }),
|
||||
handleRunCommand: vi.fn().mockResolvedValue({ success: true, stdout: '' }),
|
||||
} as unknown as ShellCommandCtr;
|
||||
|
||||
const mockRemoteServerConfigCtr = {
|
||||
getAccessToken: vi.fn().mockResolvedValue('mock-access-token'),
|
||||
isRemoteServerConfigured: vi.fn().mockResolvedValue(true),
|
||||
refreshAccessToken: vi.fn().mockResolvedValue({ success: true }),
|
||||
} as unknown as RemoteServerConfigCtr;
|
||||
|
||||
const mockBroadcast = vi.fn();
|
||||
const mockStoreGet = vi.fn();
|
||||
const mockStoreSet = vi.fn();
|
||||
|
||||
const mockApp = {
|
||||
browserManager: { broadcastToAllWindows: mockBroadcast },
|
||||
getController: vi.fn((Cls) => {
|
||||
if (Cls === RemoteServerConfigCtr) return mockRemoteServerConfigCtr;
|
||||
if (Cls === LocalFileCtr) return mockLocalFileCtr;
|
||||
if (Cls === ShellCommandCtr) return mockShellCommandCtr;
|
||||
return null;
|
||||
}),
|
||||
getService: vi.fn((Cls) => {
|
||||
if (Cls === GatewayConnectionService) return mockGatewayConnectionSrv;
|
||||
return null;
|
||||
}),
|
||||
storeManager: { get: mockStoreGet, set: mockStoreSet },
|
||||
} as unknown as App;
|
||||
|
||||
// Lazily initialized — created in beforeEach so it uses the current mockApp
|
||||
let mockGatewayConnectionSrv: GatewayConnectionService;
|
||||
|
||||
// ─── Test Suite ───
|
||||
|
||||
describe('GatewayConnectionCtr', () => {
|
||||
let ctr: GatewayConnectionCtr;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
MockGatewayClient.lastInstance = null;
|
||||
MockGatewayClient.lastOptions = null;
|
||||
mockStoreGet.mockImplementation((key: string) => {
|
||||
if (key === 'gatewayEnabled') return true;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
mockGatewayConnectionSrv = new GatewayConnectionService(mockApp);
|
||||
ctr = new GatewayConnectionCtr(mockApp);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ctr.disconnect();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ─── Connection ───
|
||||
|
||||
describe('connect', () => {
|
||||
it('should create GatewayClient with correct options', async () => {
|
||||
mockStoreGet.mockImplementation((key: string) => {
|
||||
if (key === 'gatewayEnabled') return true;
|
||||
if (key === 'gatewayDeviceId') return 'stored-device-id';
|
||||
if (key === 'gatewayUrl') return undefined;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
ctr = new GatewayConnectionCtr(mockApp);
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
const options = MockGatewayClient.lastOptions;
|
||||
expect(options).not.toBeNull();
|
||||
expect(options.token).toBe('mock-access-token');
|
||||
expect(options.deviceId).toBe('stored-device-id');
|
||||
expect(options.gatewayUrl).toBe('https://device-gateway.lobehub.com');
|
||||
expect(options.logger).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use custom gateway URL from store when set', async () => {
|
||||
mockStoreGet.mockImplementation((key: string) => {
|
||||
if (key === 'gatewayEnabled') return true;
|
||||
if (key === 'gatewayUrl') return 'http://localhost:8787';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
ctr = new GatewayConnectionCtr(mockApp);
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(MockGatewayClient.lastOptions.gatewayUrl).toBe('http://localhost:8787');
|
||||
});
|
||||
|
||||
it('should return success:false when no access token', async () => {
|
||||
// Prevent auto-connect, then set up providers manually
|
||||
vi.mocked(mockRemoteServerConfigCtr.isRemoteServerConfigured).mockResolvedValueOnce(false);
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
vi.mocked(mockRemoteServerConfigCtr.getAccessToken).mockResolvedValueOnce(null);
|
||||
|
||||
const result = await ctr.connect();
|
||||
expect(result).toEqual({ error: 'No access token available', success: false });
|
||||
expect(MockGatewayClient.lastInstance).toBeNull();
|
||||
});
|
||||
|
||||
it('should persist gatewayEnabled=true on connect', async () => {
|
||||
vi.mocked(mockRemoteServerConfigCtr.isRemoteServerConfigured).mockResolvedValueOnce(false);
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
mockStoreSet.mockClear();
|
||||
|
||||
await ctr.connect();
|
||||
expect(mockStoreSet).toHaveBeenCalledWith('gatewayEnabled', true);
|
||||
});
|
||||
|
||||
it('should no-op when already connected', async () => {
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const firstClient = MockGatewayClient.lastInstance;
|
||||
firstClient!.simulateConnected();
|
||||
|
||||
const result = await ctr.connect();
|
||||
expect(result).toEqual({ success: true });
|
||||
// No new client created
|
||||
expect(MockGatewayClient.lastInstance).toBe(firstClient);
|
||||
});
|
||||
|
||||
it('should broadcast status changes: disconnected → connecting → connected', async () => {
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(mockBroadcast).toHaveBeenCalledWith('gatewayConnectionStatusChanged', {
|
||||
status: 'connecting',
|
||||
});
|
||||
|
||||
MockGatewayClient.lastInstance!.simulateConnected();
|
||||
expect(mockBroadcast).toHaveBeenCalledWith('gatewayConnectionStatusChanged', {
|
||||
status: 'connected',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Disconnect ───
|
||||
|
||||
describe('disconnect', () => {
|
||||
it('should disconnect client and set status to disconnected', async () => {
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const client = MockGatewayClient.lastInstance!;
|
||||
client.simulateConnected();
|
||||
mockBroadcast.mockClear();
|
||||
|
||||
await ctr.disconnect();
|
||||
|
||||
expect(client.disconnect).toHaveBeenCalled();
|
||||
expect(mockBroadcast).toHaveBeenCalledWith('gatewayConnectionStatusChanged', {
|
||||
status: 'disconnected',
|
||||
});
|
||||
});
|
||||
|
||||
it('should persist gatewayEnabled=false on disconnect', async () => {
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
MockGatewayClient.lastInstance!.simulateConnected();
|
||||
mockStoreSet.mockClear();
|
||||
|
||||
await ctr.disconnect();
|
||||
expect(mockStoreSet).toHaveBeenCalledWith('gatewayEnabled', false);
|
||||
});
|
||||
|
||||
it('should not trigger reconnect after intentional disconnect', async () => {
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const client = MockGatewayClient.lastInstance!;
|
||||
client.simulateConnected();
|
||||
|
||||
await ctr.disconnect();
|
||||
mockBroadcast.mockClear();
|
||||
|
||||
// Advance timers — no reconnect should happen
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(mockBroadcast).not.toHaveBeenCalledWith('gatewayConnectionStatusChanged', {
|
||||
status: 'reconnecting',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Auto-Connect ───
|
||||
|
||||
describe('afterAppReady (auto-connect)', () => {
|
||||
it('should auto-connect when server is configured and token exists', async () => {
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(MockGatewayClient.lastInstance).not.toBeNull();
|
||||
expect(MockGatewayClient.lastInstance!.connect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip auto-connect when gatewayEnabled is false', async () => {
|
||||
mockStoreGet.mockImplementation((key: string) => {
|
||||
if (key === 'gatewayEnabled') return false;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
ctr = new GatewayConnectionCtr(mockApp);
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(MockGatewayClient.lastInstance).toBeNull();
|
||||
});
|
||||
|
||||
it('should skip auto-connect when remote server not configured', async () => {
|
||||
vi.mocked(mockRemoteServerConfigCtr.isRemoteServerConfigured).mockResolvedValueOnce(false);
|
||||
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(MockGatewayClient.lastInstance).toBeNull();
|
||||
});
|
||||
|
||||
it('should skip auto-connect when no access token', async () => {
|
||||
vi.mocked(mockRemoteServerConfigCtr.getAccessToken).mockResolvedValueOnce(null);
|
||||
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(MockGatewayClient.lastInstance).toBeNull();
|
||||
});
|
||||
|
||||
it('should create device ID on first launch and persist it', () => {
|
||||
mockStoreGet.mockReturnValue(undefined);
|
||||
ctr.afterAppReady();
|
||||
|
||||
expect(mockStoreSet).toHaveBeenCalledWith('gatewayDeviceId', 'mock-device-uuid');
|
||||
});
|
||||
|
||||
it('should reuse persisted device ID', () => {
|
||||
mockStoreGet.mockImplementation((key: string) => {
|
||||
if (key === 'gatewayEnabled') return true;
|
||||
if (key === 'gatewayDeviceId') return 'existing-id';
|
||||
return undefined;
|
||||
});
|
||||
ctr = new GatewayConnectionCtr(mockApp);
|
||||
ctr.afterAppReady();
|
||||
|
||||
expect(mockStoreSet).not.toHaveBeenCalledWith('gatewayDeviceId', expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Reconnection ───
|
||||
|
||||
describe('reconnection', () => {
|
||||
it('should broadcast reconnecting status when client emits reconnecting', async () => {
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const client = MockGatewayClient.lastInstance!;
|
||||
client.simulateConnected();
|
||||
mockBroadcast.mockClear();
|
||||
|
||||
client.simulateReconnecting(1000);
|
||||
|
||||
expect(mockBroadcast).toHaveBeenCalledWith('gatewayConnectionStatusChanged', {
|
||||
status: 'reconnecting',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tool Call Routing ───
|
||||
|
||||
describe('tool call routing', () => {
|
||||
async function connectAndOpen() {
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const client = MockGatewayClient.lastInstance!;
|
||||
client.simulateConnected();
|
||||
return client;
|
||||
}
|
||||
|
||||
it.each([
|
||||
['readLocalFile', 'readFile', mockLocalFileCtr],
|
||||
['listLocalFiles', 'listLocalFiles', mockLocalFileCtr],
|
||||
['moveLocalFiles', 'handleMoveFiles', mockLocalFileCtr],
|
||||
['renameLocalFile', 'handleRenameFile', mockLocalFileCtr],
|
||||
['searchLocalFiles', 'handleLocalFilesSearch', mockLocalFileCtr],
|
||||
['writeLocalFile', 'handleWriteFile', mockLocalFileCtr],
|
||||
['editLocalFile', 'handleEditFile', mockLocalFileCtr],
|
||||
['globLocalFiles', 'handleGlobFiles', mockLocalFileCtr],
|
||||
['grepContent', 'handleGrepContent', mockLocalFileCtr],
|
||||
['runCommand', 'handleRunCommand', mockShellCommandCtr],
|
||||
['getCommandOutput', 'handleGetCommandOutput', mockShellCommandCtr],
|
||||
['killCommand', 'handleKillCommand', mockShellCommandCtr],
|
||||
] as const)('should route %s to %s', async (apiName, methodName, controller) => {
|
||||
const client = await connectAndOpen();
|
||||
const args = { test: 'arg' };
|
||||
|
||||
client.simulateToolCallRequest(apiName, args);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect((controller as any)[methodName]).toHaveBeenCalledWith(args);
|
||||
});
|
||||
|
||||
it('should send tool_call_response with success result', async () => {
|
||||
vi.mocked(mockLocalFileCtr.readFile).mockResolvedValueOnce({
|
||||
charCount: 5,
|
||||
content: 'hello',
|
||||
createdTime: new Date('2024-01-01'),
|
||||
filename: 'a.txt',
|
||||
fileType: '.txt',
|
||||
lineCount: 1,
|
||||
loc: [1, 1] as [number, number],
|
||||
modifiedTime: new Date('2024-01-01'),
|
||||
totalCharCount: 5,
|
||||
totalLineCount: 1,
|
||||
});
|
||||
const client = await connectAndOpen();
|
||||
|
||||
client.simulateToolCallRequest('readLocalFile', { path: '/a.txt' }, 'req-42');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should send tool_call_response with error on failure', async () => {
|
||||
vi.mocked(mockLocalFileCtr.readFile).mockRejectedValueOnce(new Error('File not found'));
|
||||
const client = await connectAndOpen();
|
||||
|
||||
client.simulateToolCallRequest('readLocalFile', { path: '/missing' }, 'req-err');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(client.sendToolCallResponse).toHaveBeenCalledWith({
|
||||
requestId: 'req-err',
|
||||
result: {
|
||||
content: 'File not found',
|
||||
error: 'File not found',
|
||||
success: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should send error for unknown apiName', async () => {
|
||||
const client = await connectAndOpen();
|
||||
|
||||
client.simulateToolCallRequest('unknownApi', {}, 'req-unknown');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
const errorMsg =
|
||||
'Tool "unknownApi" is not available on this device. It may not be supported in the current desktop version. Please skip this tool and try alternative approaches.';
|
||||
expect(client.sendToolCallResponse).toHaveBeenCalledWith({
|
||||
requestId: 'req-unknown',
|
||||
result: {
|
||||
content: errorMsg,
|
||||
error: errorMsg,
|
||||
success: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Auth Expired ───
|
||||
|
||||
describe('auth_expired handling', () => {
|
||||
it('should refresh token and reconnect on auth_expired', async () => {
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const client1 = MockGatewayClient.lastInstance!;
|
||||
client1.simulateConnected();
|
||||
|
||||
client1.simulateAuthExpired();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(mockRemoteServerConfigCtr.refreshAccessToken).toHaveBeenCalled();
|
||||
// Should have created a new GatewayClient for reconnection
|
||||
expect(MockGatewayClient.lastInstance).not.toBe(client1);
|
||||
expect(MockGatewayClient.lastInstance!.connect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set status to disconnected when token refresh fails', async () => {
|
||||
vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValueOnce({
|
||||
error: 'invalid_grant',
|
||||
success: false,
|
||||
});
|
||||
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const client = MockGatewayClient.lastInstance!;
|
||||
client.simulateConnected();
|
||||
mockBroadcast.mockClear();
|
||||
|
||||
client.simulateAuthExpired();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(mockBroadcast).toHaveBeenCalledWith('gatewayConnectionStatusChanged', {
|
||||
status: 'disconnected',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── IPC Methods ───
|
||||
|
||||
describe('getConnectionStatus', () => {
|
||||
it('should return current status', async () => {
|
||||
expect(await ctr.getConnectionStatus()).toEqual({ status: 'disconnected' });
|
||||
|
||||
ctr.afterAppReady();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(await ctr.getConnectionStatus()).toEqual({ status: 'connecting' });
|
||||
|
||||
MockGatewayClient.lastInstance!.simulateConnected();
|
||||
expect(await ctr.getConnectionStatus()).toEqual({ status: 'connected' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDeviceInfo', () => {
|
||||
it('should return device information', async () => {
|
||||
mockStoreGet.mockImplementation((key: string) => {
|
||||
if (key === 'gatewayEnabled') return true;
|
||||
if (key === 'gatewayDeviceId') return 'my-device';
|
||||
return undefined;
|
||||
});
|
||||
ctr = new GatewayConnectionCtr(mockApp);
|
||||
ctr.afterAppReady();
|
||||
|
||||
const info = await ctr.getDeviceInfo();
|
||||
expect(info).toEqual({
|
||||
description: '',
|
||||
deviceId: 'my-device',
|
||||
hostname: 'mock-hostname',
|
||||
name: 'mock-hostname',
|
||||
platform: process.platform,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -45,6 +45,7 @@ vi.mock('node:fs/promises', () => ({
|
|||
mkdir: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
realpath: vi.fn(),
|
||||
rename: vi.fn(),
|
||||
rm: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
|
|
@ -301,6 +302,46 @@ describe('LocalFileCtr', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('auditSafePaths', () => {
|
||||
it('should treat real temporary paths as safe', async () => {
|
||||
vi.mocked(mockFsPromises.access).mockResolvedValue(undefined);
|
||||
vi.mocked(mockFsPromises.realpath).mockImplementation(async (targetPath: string) => {
|
||||
if (targetPath === '/tmp') return '/private/tmp';
|
||||
if (targetPath === '/var/tmp') return '/private/var/tmp';
|
||||
if (targetPath === '/tmp/out') return '/private/tmp/out';
|
||||
return targetPath;
|
||||
});
|
||||
|
||||
const result = await localFileCtr.auditSafePaths({
|
||||
paths: ['/tmp/out'],
|
||||
resolveAgainstScope: '/Users/me/project',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ allSafe: true });
|
||||
});
|
||||
|
||||
it('should reject safe-path candidates whose real target escapes the temporary roots', async () => {
|
||||
vi.mocked(mockFsPromises.access).mockImplementation(async (targetPath: string) => {
|
||||
if (targetPath === '/tmp/out/config') {
|
||||
throw new Error('ENOENT');
|
||||
}
|
||||
});
|
||||
vi.mocked(mockFsPromises.realpath).mockImplementation(async (targetPath: string) => {
|
||||
if (targetPath === '/tmp') return '/private/tmp';
|
||||
if (targetPath === '/var/tmp') return '/private/var/tmp';
|
||||
if (targetPath === '/tmp/out') return '/Users/me/.ssh';
|
||||
return targetPath;
|
||||
});
|
||||
|
||||
const result = await localFileCtr.auditSafePaths({
|
||||
paths: ['/tmp/out/config'],
|
||||
resolveAgainstScope: '/Users/me/project',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ allSafe: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('handlePrepareSkillDirectory', () => {
|
||||
it('should download and extract a skill zip into a local cache directory', async () => {
|
||||
const zipped = zipSync({
|
||||
|
|
|
|||
|
|
@ -47,8 +47,14 @@ const mockBrowserManager = {
|
|||
broadcastToAllWindows: vi.fn(),
|
||||
};
|
||||
|
||||
const mockGatewayConnectionSrv = {
|
||||
disconnect: vi.fn().mockResolvedValue({ success: true }),
|
||||
};
|
||||
|
||||
const mockApp = {
|
||||
browserManager: mockBrowserManager,
|
||||
getController: vi.fn(),
|
||||
getService: vi.fn().mockReturnValue(mockGatewayConnectionSrv),
|
||||
storeManager: mockStoreManager,
|
||||
} as unknown as App;
|
||||
|
||||
|
|
@ -294,6 +300,13 @@ describe('RemoteServerConfigCtr', () => {
|
|||
const accessToken = await controller.getAccessToken();
|
||||
expect(accessToken).toBeNull();
|
||||
});
|
||||
|
||||
it('should disconnect gateway when tokens are cleared', async () => {
|
||||
await controller.saveTokens('access', 'refresh', 3600);
|
||||
await controller.clearTokens();
|
||||
|
||||
expect(mockGatewayConnectionSrv.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTokenExpiresAt', () => {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { CreateServicesResult, IpcServiceConstructor, MergeIpcService } fro
|
|||
import AuthCtr from './AuthCtr';
|
||||
import BrowserWindowsCtr from './BrowserWindowsCtr';
|
||||
import DevtoolsCtr from './DevtoolsCtr';
|
||||
import GatewayConnectionCtr from './GatewayConnectionCtr';
|
||||
import LocalFileCtr from './LocalFileCtr';
|
||||
import McpCtr from './McpCtr';
|
||||
import McpInstallCtr from './McpInstallCtr';
|
||||
|
|
@ -23,6 +24,7 @@ export const controllerIpcConstructors = [
|
|||
AuthCtr,
|
||||
BrowserWindowsCtr,
|
||||
DevtoolsCtr,
|
||||
GatewayConnectionCtr,
|
||||
LocalFileCtr,
|
||||
McpCtr,
|
||||
McpInstallCtr,
|
||||
|
|
|
|||
317
apps/desktop/src/main/services/gatewayConnectionSrv.ts
Normal file
317
apps/desktop/src/main/services/gatewayConnectionSrv.ts
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
|
||||
import type {
|
||||
SystemInfoRequestMessage,
|
||||
ToolCallRequestMessage,
|
||||
} from '@lobechat/device-gateway-client';
|
||||
import { GatewayClient } from '@lobechat/device-gateway-client';
|
||||
import type { GatewayConnectionStatus } from '@lobechat/electron-client-ipc';
|
||||
import { app } from 'electron';
|
||||
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ServiceModule } from './index';
|
||||
|
||||
const logger = createLogger('services:GatewayConnectionSrv');
|
||||
|
||||
const DEFAULT_GATEWAY_URL = 'https://device-gateway.lobehub.com';
|
||||
|
||||
interface ToolCallHandler {
|
||||
(apiName: string, args: any): Promise<unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* GatewayConnectionService
|
||||
*
|
||||
* Core business logic for managing WebSocket connection to the cloud device-gateway.
|
||||
* Extracted from GatewayConnectionCtr so other controllers can reuse connect/disconnect.
|
||||
*/
|
||||
export default class GatewayConnectionService extends ServiceModule {
|
||||
private client: GatewayClient | null = null;
|
||||
private status: GatewayConnectionStatus = 'disconnected';
|
||||
private deviceId: string | null = null;
|
||||
|
||||
private tokenProvider: (() => Promise<string | null>) | null = null;
|
||||
private tokenRefresher: (() => Promise<{ error?: string; success: boolean }>) | null = null;
|
||||
private toolCallHandler: ToolCallHandler | null = null;
|
||||
|
||||
// ─── Configuration ───
|
||||
|
||||
/**
|
||||
* Set token provider function (to decouple from RemoteServerConfigCtr)
|
||||
*/
|
||||
setTokenProvider(provider: () => Promise<string | null>) {
|
||||
this.tokenProvider = provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set token refresher function (for auth_expired handling)
|
||||
*/
|
||||
setTokenRefresher(refresher: () => Promise<{ error?: string; success: boolean }>) {
|
||||
this.tokenRefresher = refresher;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set tool call handler (to route tool calls to LocalFileCtr/ShellCommandCtr)
|
||||
*/
|
||||
setToolCallHandler(handler: ToolCallHandler) {
|
||||
this.toolCallHandler = handler;
|
||||
}
|
||||
|
||||
// ─── Device ID ───
|
||||
|
||||
loadOrCreateDeviceId() {
|
||||
const stored = this.app.storeManager.get('gatewayDeviceId') as string | undefined;
|
||||
if (stored) {
|
||||
this.deviceId = stored;
|
||||
} else {
|
||||
this.deviceId = randomUUID();
|
||||
this.app.storeManager.set('gatewayDeviceId', this.deviceId);
|
||||
}
|
||||
logger.debug(`Device ID: ${this.deviceId}`);
|
||||
}
|
||||
|
||||
getDeviceId(): string {
|
||||
return this.deviceId || 'unknown';
|
||||
}
|
||||
|
||||
// ─── Connection Status ───
|
||||
|
||||
getStatus(): GatewayConnectionStatus {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
getDeviceInfo() {
|
||||
return {
|
||||
description: this.getDeviceDescription(),
|
||||
deviceId: this.getDeviceId(),
|
||||
hostname: os.hostname(),
|
||||
name: this.getDeviceName(),
|
||||
platform: process.platform,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Device Name & Description ───
|
||||
|
||||
getDeviceName(): string {
|
||||
return (this.app.storeManager.get('gatewayDeviceName') as string) || os.hostname();
|
||||
}
|
||||
|
||||
setDeviceName(name: string) {
|
||||
this.app.storeManager.set('gatewayDeviceName', name);
|
||||
}
|
||||
|
||||
getDeviceDescription(): string {
|
||||
return (this.app.storeManager.get('gatewayDeviceDescription') as string) || '';
|
||||
}
|
||||
|
||||
setDeviceDescription(description: string) {
|
||||
this.app.storeManager.set('gatewayDeviceDescription', description);
|
||||
}
|
||||
|
||||
// ─── Connection Logic ───
|
||||
|
||||
async connect(): Promise<{ error?: string; success: boolean }> {
|
||||
if (this.status === 'connected' || this.status === 'connecting') {
|
||||
return { success: true };
|
||||
}
|
||||
return this.doConnect();
|
||||
}
|
||||
|
||||
async disconnect(): Promise<{ success: boolean }> {
|
||||
if (this.client) {
|
||||
await this.client.disconnect();
|
||||
this.client = null;
|
||||
}
|
||||
this.setStatus('disconnected');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async doConnect(): Promise<{ error?: string; success: boolean }> {
|
||||
// Clean up any existing client
|
||||
if (this.client) {
|
||||
await this.client.disconnect();
|
||||
this.client = null;
|
||||
}
|
||||
|
||||
if (!this.tokenProvider) {
|
||||
logger.warn('Cannot connect: no token provider configured');
|
||||
return { error: 'No token provider configured', success: false };
|
||||
}
|
||||
|
||||
const token = await this.tokenProvider();
|
||||
if (!token) {
|
||||
logger.warn('Cannot connect: no access token');
|
||||
return { error: 'No access token available', success: false };
|
||||
}
|
||||
|
||||
const gatewayUrl = this.getGatewayUrl();
|
||||
const userId = this.extractUserIdFromToken(token);
|
||||
logger.info(`Connecting to device gateway: ${gatewayUrl}, userId: ${userId || 'unknown'}`);
|
||||
|
||||
const client = new GatewayClient({
|
||||
deviceId: this.getDeviceId(),
|
||||
gatewayUrl,
|
||||
logger,
|
||||
token,
|
||||
userId: userId || undefined,
|
||||
});
|
||||
|
||||
this.setupClientEvents(client);
|
||||
this.client = client;
|
||||
|
||||
await client.connect();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private setupClientEvents(client: GatewayClient) {
|
||||
client.on('status_changed', (status) => {
|
||||
this.setStatus(status);
|
||||
});
|
||||
|
||||
client.on('tool_call_request', (request) => {
|
||||
this.handleToolCallRequest(request, client);
|
||||
});
|
||||
|
||||
client.on('system_info_request', (request) => {
|
||||
this.handleSystemInfoRequest(client, request);
|
||||
});
|
||||
|
||||
client.on('auth_expired', () => {
|
||||
logger.warn('Received auth_expired, will reconnect with refreshed token');
|
||||
this.handleAuthExpired();
|
||||
});
|
||||
|
||||
client.on('error', (error) => {
|
||||
logger.error('WebSocket error:', error.message);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Auth Expired Handling ───
|
||||
|
||||
private async handleAuthExpired() {
|
||||
// Disconnect the current client
|
||||
if (this.client) {
|
||||
await this.client.disconnect();
|
||||
this.client = null;
|
||||
}
|
||||
|
||||
if (!this.tokenRefresher) {
|
||||
logger.error('No token refresher configured, cannot handle auth_expired');
|
||||
this.setStatus('disconnected');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('Attempting token refresh before reconnect');
|
||||
const result = await this.tokenRefresher();
|
||||
|
||||
if (result.success) {
|
||||
logger.info('Token refreshed, reconnecting');
|
||||
await this.doConnect();
|
||||
} else {
|
||||
logger.error('Token refresh failed:', result.error);
|
||||
this.setStatus('disconnected');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── System Info ───
|
||||
|
||||
private handleSystemInfoRequest(client: GatewayClient, request: SystemInfoRequestMessage) {
|
||||
logger.info(`Received system_info_request: requestId=${request.requestId}`);
|
||||
client.sendSystemInfoResponse({
|
||||
requestId: request.requestId,
|
||||
result: {
|
||||
success: true,
|
||||
systemInfo: {
|
||||
arch: os.arch(),
|
||||
desktopPath: app.getPath('desktop'),
|
||||
documentsPath: app.getPath('documents'),
|
||||
downloadsPath: app.getPath('downloads'),
|
||||
homePath: app.getPath('home'),
|
||||
musicPath: app.getPath('music'),
|
||||
picturesPath: app.getPath('pictures'),
|
||||
userDataPath: app.getPath('userData'),
|
||||
videosPath: app.getPath('videos'),
|
||||
workingDirectory: process.cwd(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Tool Call Routing ───
|
||||
|
||||
private handleToolCallRequest = async (
|
||||
request: ToolCallRequestMessage,
|
||||
client: GatewayClient,
|
||||
) => {
|
||||
const { requestId, toolCall } = request;
|
||||
const { apiName, arguments: argsStr } = toolCall;
|
||||
|
||||
logger.info(`Received tool call: apiName=${apiName}, requestId=${requestId}`);
|
||||
|
||||
try {
|
||||
if (!this.toolCallHandler) {
|
||||
throw new Error('No tool call handler configured');
|
||||
}
|
||||
|
||||
const args = JSON.parse(argsStr);
|
||||
const result = await this.toolCallHandler(apiName, args);
|
||||
|
||||
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}`);
|
||||
|
||||
client.sendToolCallResponse({
|
||||
requestId,
|
||||
result: {
|
||||
content: errorMsg,
|
||||
error: errorMsg,
|
||||
success: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Status Broadcasting ───
|
||||
|
||||
private setStatus(status: GatewayConnectionStatus) {
|
||||
if (this.status === status) return;
|
||||
|
||||
logger.info(`Connection status: ${this.status} → ${status}`);
|
||||
this.status = status;
|
||||
this.app.browserManager.broadcastToAllWindows('gatewayConnectionStatusChanged', { status });
|
||||
}
|
||||
|
||||
// ─── Gateway URL ───
|
||||
|
||||
private getGatewayUrl(): string {
|
||||
return this.app.storeManager.get('gatewayUrl') || DEFAULT_GATEWAY_URL;
|
||||
}
|
||||
|
||||
// ─── Token Helpers ───
|
||||
|
||||
/**
|
||||
* Extract userId (sub claim) from JWT without verification.
|
||||
* The token will be verified server-side; we just need the userId for routing.
|
||||
*/
|
||||
private extractUserIdFromToken(token: string): string | null {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'));
|
||||
return payload.sub || null;
|
||||
} catch {
|
||||
logger.warn('Failed to extract userId from JWT token');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,11 @@ export interface ElectronMainStore {
|
|||
lastRefreshAt?: number;
|
||||
refreshToken?: string;
|
||||
};
|
||||
gatewayDeviceDescription: string;
|
||||
gatewayDeviceId: string;
|
||||
gatewayDeviceName: string;
|
||||
gatewayEnabled: boolean;
|
||||
gatewayUrl: string;
|
||||
locale: string;
|
||||
networkProxy: NetworkProxySettings;
|
||||
shortcuts: Record<string, string>;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { DurableObject } from 'cloudflare:workers';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import { verifyDesktopToken } from './auth';
|
||||
import { resolveSocketAuth, verifyApiKeyToken, verifyDesktopToken } from './auth';
|
||||
import type { DeviceAttachment, Env } from './types';
|
||||
|
||||
const AUTH_TIMEOUT = 10_000; // 10s to authenticate after connect
|
||||
|
|
@ -58,24 +58,25 @@ export class DeviceGatewayDO extends DurableObject<Env> {
|
|||
if (att.authenticated) return; // Already authenticated, ignore
|
||||
|
||||
try {
|
||||
const token = data.token as string;
|
||||
if (!token) throw new Error('Missing token');
|
||||
const token = data.token as string | undefined;
|
||||
const tokenType = data.tokenType as 'apiKey' | 'jwt' | 'serviceToken' | undefined;
|
||||
const serverUrl = data.serverUrl as string | undefined;
|
||||
const storedUserId = await this.ctx.storage.get<string>('_userId');
|
||||
|
||||
let verifiedUserId: string;
|
||||
|
||||
if (token === this.env.SERVICE_TOKEN) {
|
||||
// Service token auth (for CLI debugging)
|
||||
const storedUserId = await this.ctx.storage.get<string>('_userId');
|
||||
if (!storedUserId) throw new Error('Missing userId');
|
||||
verifiedUserId = storedUserId;
|
||||
} else {
|
||||
// JWT auth (normal desktop flow)
|
||||
const result = await verifyDesktopToken(this.env, token);
|
||||
verifiedUserId = result.userId;
|
||||
}
|
||||
const verifiedUserId = await resolveSocketAuth({
|
||||
serverUrl,
|
||||
serviceToken: this.env.SERVICE_TOKEN,
|
||||
storedUserId,
|
||||
token,
|
||||
tokenType,
|
||||
verifyApiKey: verifyApiKeyToken,
|
||||
verifyJwt: async (jwt) => {
|
||||
const result = await verifyDesktopToken(this.env, jwt);
|
||||
return { userId: result.userId };
|
||||
},
|
||||
});
|
||||
|
||||
// Verify userId matches the DO routing
|
||||
const storedUserId = await this.ctx.storage.get<string>('_userId');
|
||||
if (storedUserId && verifiedUserId !== storedUserId) {
|
||||
throw new Error('userId mismatch');
|
||||
}
|
||||
|
|
|
|||
96
apps/device-gateway/src/auth.test.ts
Normal file
96
apps/device-gateway/src/auth.test.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { resolveSocketAuth } from './auth';
|
||||
|
||||
describe('resolveSocketAuth', () => {
|
||||
it('rejects missing token', async () => {
|
||||
const verifyApiKey = vi.fn();
|
||||
const verifyJwt = vi.fn();
|
||||
|
||||
await expect(
|
||||
resolveSocketAuth({
|
||||
serviceToken: 'service-secret',
|
||||
storedUserId: 'user-123',
|
||||
verifyApiKey,
|
||||
verifyJwt,
|
||||
}),
|
||||
).rejects.toThrow('Missing token');
|
||||
|
||||
expect(verifyApiKey).not.toHaveBeenCalled();
|
||||
expect(verifyJwt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects the real service token when storedUserId is missing', async () => {
|
||||
const verifyApiKey = vi.fn();
|
||||
const verifyJwt = vi.fn();
|
||||
|
||||
await expect(
|
||||
resolveSocketAuth({
|
||||
serviceToken: 'service-secret',
|
||||
token: 'service-secret',
|
||||
tokenType: 'serviceToken',
|
||||
verifyApiKey,
|
||||
verifyJwt,
|
||||
}),
|
||||
).rejects.toThrow('Missing userId');
|
||||
|
||||
expect(verifyApiKey).not.toHaveBeenCalled();
|
||||
expect(verifyJwt).not.toHaveBeenCalled();
|
||||
});
|
||||
it('rejects clients that only self-declare serviceToken mode', async () => {
|
||||
const verifyApiKey = vi.fn();
|
||||
const verifyJwt = vi.fn().mockRejectedValue(new Error('invalid jwt'));
|
||||
|
||||
await expect(
|
||||
resolveSocketAuth({
|
||||
serviceToken: 'service-secret',
|
||||
storedUserId: 'user-123',
|
||||
token: 'attacker-token',
|
||||
tokenType: 'serviceToken',
|
||||
verifyApiKey,
|
||||
verifyJwt,
|
||||
}),
|
||||
).rejects.toThrow('invalid jwt');
|
||||
|
||||
expect(verifyApiKey).not.toHaveBeenCalled();
|
||||
expect(verifyJwt).toHaveBeenCalledWith('attacker-token');
|
||||
});
|
||||
|
||||
it('treats a forged serviceToken claim with a valid JWT as JWT auth', async () => {
|
||||
const verifyApiKey = vi.fn();
|
||||
const verifyJwt = vi.fn().mockResolvedValue({ userId: 'user-123' });
|
||||
|
||||
await expect(
|
||||
resolveSocketAuth({
|
||||
serviceToken: 'service-secret',
|
||||
storedUserId: 'user-123',
|
||||
token: 'valid-jwt',
|
||||
tokenType: 'serviceToken',
|
||||
verifyApiKey,
|
||||
verifyJwt,
|
||||
}),
|
||||
).resolves.toBe('user-123');
|
||||
|
||||
expect(verifyApiKey).not.toHaveBeenCalled();
|
||||
expect(verifyJwt).toHaveBeenCalledWith('valid-jwt');
|
||||
});
|
||||
|
||||
it('accepts the real service token', async () => {
|
||||
const verifyApiKey = vi.fn();
|
||||
const verifyJwt = vi.fn();
|
||||
|
||||
await expect(
|
||||
resolveSocketAuth({
|
||||
serviceToken: 'service-secret',
|
||||
storedUserId: 'user-123',
|
||||
token: 'service-secret',
|
||||
tokenType: 'serviceToken',
|
||||
verifyApiKey,
|
||||
verifyJwt,
|
||||
}),
|
||||
).resolves.toBe('user-123');
|
||||
|
||||
expect(verifyApiKey).not.toHaveBeenCalled();
|
||||
expect(verifyJwt).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -4,6 +4,26 @@ import type { Env } from './types';
|
|||
|
||||
let cachedKey: CryptoKey | null = null;
|
||||
|
||||
interface CurrentUserResponse {
|
||||
data?: {
|
||||
id?: string;
|
||||
userId?: string;
|
||||
};
|
||||
error?: string;
|
||||
message?: string;
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
export interface ResolveSocketAuthOptions {
|
||||
serverUrl?: string;
|
||||
serviceToken: string;
|
||||
storedUserId?: string;
|
||||
token?: string;
|
||||
tokenType?: 'apiKey' | 'jwt' | 'serviceToken';
|
||||
verifyApiKey: (serverUrl: string, token: string) => Promise<{ userId: string }>;
|
||||
verifyJwt: (token: string) => Promise<{ userId: string }>;
|
||||
}
|
||||
|
||||
async function getPublicKey(env: Env): Promise<CryptoKey> {
|
||||
if (cachedKey) return cachedKey;
|
||||
|
||||
|
|
@ -34,3 +54,57 @@ export async function verifyDesktopToken(
|
|||
userId: payload.sub,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyApiKeyToken(
|
||||
serverUrl: string,
|
||||
token: string,
|
||||
): Promise<{ userId: string }> {
|
||||
const normalizedServerUrl = new URL(serverUrl).toString().replace(/\/$/, '');
|
||||
|
||||
const response = await fetch(`${normalizedServerUrl}/api/v1/users/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
let body: CurrentUserResponse | undefined;
|
||||
try {
|
||||
body = (await response.json()) as CurrentUserResponse;
|
||||
} catch {
|
||||
throw new Error(`Failed to parse response from ${normalizedServerUrl}/api/v1/users/me.`);
|
||||
}
|
||||
|
||||
if (!response.ok || body?.success === false) {
|
||||
throw new Error(
|
||||
body?.error || body?.message || `Request failed with status ${response.status}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const userId = body?.data?.id || body?.data?.userId;
|
||||
if (!userId) {
|
||||
throw new Error('Current user response did not include a user id.');
|
||||
}
|
||||
|
||||
return { userId };
|
||||
}
|
||||
|
||||
export async function resolveSocketAuth(options: ResolveSocketAuthOptions): Promise<string> {
|
||||
const { serverUrl, serviceToken, storedUserId, token, tokenType, verifyApiKey, verifyJwt } =
|
||||
options;
|
||||
|
||||
if (!token) throw new Error('Missing token');
|
||||
|
||||
if (tokenType === 'apiKey') {
|
||||
if (!serverUrl) throw new Error('Missing serverUrl');
|
||||
const result = await verifyApiKey(serverUrl, token);
|
||||
return result.userId;
|
||||
}
|
||||
|
||||
if (token === serviceToken) {
|
||||
if (!storedUserId) throw new Error('Missing userId');
|
||||
return storedUserId;
|
||||
}
|
||||
|
||||
const result = await verifyJwt(token);
|
||||
return result.userId;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,9 @@ export interface DeviceAttachment {
|
|||
|
||||
// Desktop → CF
|
||||
export interface AuthMessage {
|
||||
serverUrl?: string;
|
||||
token: string;
|
||||
tokenType?: 'apiKey' | 'jwt' | 'serviceToken';
|
||||
type: 'auth';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@
|
|||
"https://file.rene.wang/clipboard-1769050853107-750be5f83cbe3.png": "/blog/assetse6139c4d5b1b26b05f41a579d98fc6f3.webp",
|
||||
"https://file.rene.wang/clipboard-1769052898732-b7bb78ae1f1f8.png": "/blog/assetsafa74c85aafea8a057e6047b0823e280.webp",
|
||||
"https://file.rene.wang/clipboard-1769056077960-cac34bc157a65.png": "/blog/assetsa8e173bec038d1d21d413f6fa0ace342.webp",
|
||||
"https://file.rene.wang/clipboard-1769137275089-21cf7ab42d52b.png": "/blog/assets095af3a0a0f850fc206fc3bbc19a4095.webp",
|
||||
"https://file.rene.wang/clipboard-1769137300488-0b894cc8c7a67.png": "/blog/assetsebc1ebe8330d982f6a0b757aafb3f4a1.webp",
|
||||
"https://file.rene.wang/clipboard-1769155711708-710967bee57bc.png": "/blog/assets7f3b38c1d76cceb91edb29d6b1eb60db.webp",
|
||||
"https://file.rene.wang/clipboard-1769155737647-1b4fc6558f029.png": "/blog/assets3a7f0b29839603336e39e923b423409b.webp",
|
||||
"https://file.rene.wang/clipboard-1769155791342-7f43b72cc6b42.png": "/blog/assets35e6aa692b0c16009c61964279514166.webp",
|
||||
|
|
@ -44,6 +46,8 @@
|
|||
"https://file.rene.wang/clipboard-1769156005535-c2e79e11f4b56.png": "/blog/assets2a36d86a4eed6e7938dd6e9c684701ed.webp",
|
||||
"https://file.rene.wang/clipboard-1769156036607-2b4fe37c4b56c.png": "/blog/assetsc0efdb82443556ae3acefe00099b3f23.webp",
|
||||
"https://file.rene.wang/clipboard-1769156050787-ecf4f48474ae2.png": "/blog/assetse743f0a47127390dde766a0a790476db.webp",
|
||||
"https://file.rene.wang/clipboard-1770261091677-74b74e4d6bf23.png": "/blog/assets3059f679eef80c5e777085db3d2d056e.webp",
|
||||
"https://file.rene.wang/clipboard-1770266335710-1fec523143aab.png": "/blog/assets636c78daf95c590cd7d80284c68eb6d9.webp",
|
||||
"https://file.rene.wang/lobehub/467951f5-ad65-498d-aea9-fca8f35a4314.png": "/blog/assets907ea775d228958baca38e2dbb65939a.webp",
|
||||
"https://file.rene.wang/lobehub/58d91528-373a-4a42-b520-cf6cb1f8ce1e.png": "/blog/assets7dccdd4df55aede71001da649639437f.webp",
|
||||
"https://file.rene.wang/lobehub/ee700103-3c08-41dc-9ddf-c7705bb7bc6a.png": "/blog/assets196d679bc7071abbf71f2a8566f05aa3.webp",
|
||||
|
|
@ -258,6 +262,7 @@
|
|||
"https://github.com/user-attachments/assets/22e1a039-5e6e-4c40-8266-19821677618a": "/blog/assets89b45345c84f8b7c3bf4d554169689ac.webp",
|
||||
"https://github.com/user-attachments/assets/237864d6-cc5d-4fe4-8a2b-c278016855c5": "/blog/assetsf3e7c2e961d1d2886fe231a4ac59e2f1.webp",
|
||||
"https://github.com/user-attachments/assets/2787824c-a13c-466c-ba6f-820bddfe099f": "/blog/assets/8d6c17a6ea5e784edf4449fb18ca3f76.webp",
|
||||
"https://github.com/user-attachments/assets/27c37617-a813-4de5-b0bf-c7167999c856": "/blog/assetsc958eae64465451c4374cdee8f6fd596.webp",
|
||||
"https://github.com/user-attachments/assets/28590f7f-bfee-4215-b50b-8feddbf72366": "/blog/assets89a8dadc85902334ce8d2d5b78abf709.webp",
|
||||
"https://github.com/user-attachments/assets/29508dda-2382-430f-bc81-fb23f02149f8": "/blog/assets/29b13dc042e3b839ad8865354afe2fac.webp",
|
||||
"https://github.com/user-attachments/assets/2a4116a7-15ad-43e5-b801-cc62d8da2012": "/blog/assets/37d85fdfccff9ed56e9c6827faee01c7.webp",
|
||||
|
|
@ -286,6 +291,7 @@
|
|||
"https://github.com/user-attachments/assets/4c792f62-5203-4f13-8f23-df228f70d67f": "/blog/assets94f55c97a24a08c7a5923c23ee2d7eef.webp",
|
||||
"https://github.com/user-attachments/assets/4cbbbcce-36be-48ff-bb0b-31607a0bba5c": "/blog/assetsb33085e7553d2b7194005b102184553e.webp",
|
||||
"https://github.com/user-attachments/assets/4d671a7c-5d94-4c4b-b4fd-71a5a0e9d227": "/blog/assetsc74cf5c8daee1515c37a85bce087f0d6.webp",
|
||||
"https://github.com/user-attachments/assets/4dde41ec-985b-4781-8c77-aac65555a32f": "/blog/assets04fecea4e5f4ce3490bf11bec66ff477.webp",
|
||||
"https://github.com/user-attachments/assets/4e04928d-0171-48d1-afff-e22fc2faaf4e": "/blog/assetsb26b68a4875a6510ddc202dd4b40d010.webp",
|
||||
"https://github.com/user-attachments/assets/530c7c96-bac3-456d-a429-f60e7d2ade66": "/blog/assets6541bab7e0047f9c5dbad98dc272d64d.webp",
|
||||
"https://github.com/user-attachments/assets/5321f987-2c64-4211-8549-bd30ca9b59b9": "/blog/assetsaf57d31364a41634b10c243ed9b1f8f8.webp",
|
||||
|
|
@ -327,6 +333,7 @@
|
|||
"https://github.com/user-attachments/assets/7cb3019b-78c1-48e0-a64c-a6a4836affd9": "/blog/assets3ca963d92475f34b0789cfa50071bc52.webp",
|
||||
"https://github.com/user-attachments/assets/808f8849-5738-4a60-8ccf-01e300b0dc88": "/blog/assets0f893c504377ba45a9f5cdbb5ccb1612.webp",
|
||||
"https://github.com/user-attachments/assets/81d0349a-44fe-4dfc-bbc4-8e9a1e09567d": "/blog/assets29de82efbe7657a8b9ba7daf0904585d.webp",
|
||||
"https://github.com/user-attachments/assets/81f18b20-3918-4f77-8571-07d0c4a79aec": "/blog/assets43d66c62b79a027895b5a6127b2f2de2.webp",
|
||||
"https://github.com/user-attachments/assets/82a7ebe0-69ad-43b6-8767-1316b443fa03": "/blog/assets5374759bfe39ca7fc864e72ddfce98d0.webp",
|
||||
"https://github.com/user-attachments/assets/82bfc467-e0c6-4d99-9b1f-18e4aea24285": "/blog/assets/eb477e62217f4d1b644eff975c7ac168.webp",
|
||||
"https://github.com/user-attachments/assets/840442b1-bf56-4a5f-9700-b3608b16a8a5": "/blog/assetsc6ff27b7134f280727e1fd7ff83ed2fa.webp",
|
||||
|
|
|
|||
30
docs/changelog/2026-01-27-v2.mdx
Normal file
30
docs/changelog/2026-01-27-v2.mdx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
title: "LobeHub v2.0 — Group Chat & Multi-Agent Collaboration \U0001F389"
|
||||
description: >-
|
||||
LobeHub v2.0 brings major upgrades including multi-agent group chat, enhanced
|
||||
model settings, SSO-only mode, and desktop improvements.
|
||||
tags:
|
||||
- v2.0
|
||||
- Group Chat
|
||||
- Multi-Agent
|
||||
- SSO
|
||||
---
|
||||
|
||||
# LobeHub v2.0 🎉
|
||||
|
||||
January marks the landmark release of LobeHub v2.0, introducing powerful multi-agent group chat capabilities, refined model settings, and a streamlined authentication experience.
|
||||
|
||||
## What's New
|
||||
|
||||
- A major version upgrade with redesigned architecture and enhanced features
|
||||
- Multi-Agent Collaboration: Bring multiple specialized agents into one conversation. They debate, reason, and solve complex problems together—faster and smarter.
|
||||
- Agent Builder: Describe what you want, and LobeHub builds the complete agent—skills, behavior, tools, and personality. No setup required.
|
||||
- Pages: write, read and organize documents with Lobe AI
|
||||
- Memory: Your agents remember your preferences, style, goals, and past projects—delivering uniquely personalized assistance that gets better over time.
|
||||
- New Knowledge Base: Use folders to organize your knowledge & resource
|
||||
- Marketplace: Publish, adopt, or remix agents in a thriving community where intelligence grows together.
|
||||
|
||||
## Improvement
|
||||
|
||||
- Enhanced model settings: New ExtendParamsTypeSchema for more flexible model configuration
|
||||
- Model updates: Updated Kimi K2.5 and Qwen3 Max Thinking models, plus Gemini 2.5 streaming fixes
|
||||
30
docs/changelog/2026-01-27-v2.zh-CN.mdx
Normal file
30
docs/changelog/2026-01-27-v2.zh-CN.mdx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
title: "LobeHub v2.0 — Group Chat & Multi-Agent Collaboration \U0001F389"
|
||||
description: >-
|
||||
LobeHub v2.0 brings major upgrades including multi-agent group chat, enhanced
|
||||
model settings, SSO-only mode, and desktop improvements.
|
||||
tags:
|
||||
- v2.0
|
||||
- Group Chat
|
||||
- Multi-Agent
|
||||
- SSO
|
||||
---
|
||||
|
||||
# LobeHub v2.0 🎉
|
||||
|
||||
LobeHub v2.0 正式发布,带来强大的多智能体群聊功能、优化的模型设置以及简化的身份验证体验。
|
||||
|
||||
## 新功能
|
||||
|
||||
- 重大版本升级,架构重新设计,功能增强
|
||||
- 多智能体协作:将多个专业智能体汇聚于同一对话中。它们可以共同讨论、推理并解决复杂问题,速度更快、更智能。
|
||||
- 智能体构建器:描述您的需求,LobeHub 将构建完整的智能体 —— 包括技能、行为、工具和个性。无需任何设置。
|
||||
- 页面:使用 Lobe AI 编写、阅读和整理文档
|
||||
- 记忆:您的智能体会记住您的偏好、风格、目标和过往项目,提供个性化的专属帮助,并随着时间的推移不断优化。
|
||||
- 全新知识库:使用文件夹整理您的知识和资源
|
||||
- 应用市场:在一个蓬勃发展的社区中发布、采用或重新组合智能体,共同提升智能水平。
|
||||
|
||||
## 改进
|
||||
|
||||
- 增强模型设置:新增 ExtendParamsTypeSchema,实现更灵活的模型配置
|
||||
- 模型更新:更新了 Kimi K2.5 和 Qwen3 Max Thinking 模型,并修复了 Gemini 2.5 的流式传输问题
|
||||
28
docs/changelog/2026-02-08-runtime-auth.mdx
Normal file
28
docs/changelog/2026-02-08-runtime-auth.mdx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
title: "Model Runtime & Authentication Improvements \U0001F527"
|
||||
description: >-
|
||||
Enhanced model runtime with Claude Opus 4.6 on Bedrock, improved
|
||||
authentication flows, and better mobile experience.
|
||||
tags:
|
||||
- Model Runtime
|
||||
- Authentication
|
||||
- Claude Opus 4.6
|
||||
- Notebook
|
||||
---
|
||||
|
||||
# Model Runtime & Authentication Improvements 🔧
|
||||
|
||||
In February, LobeHub focused on model runtime enhancements, authentication reliability, and polishing the overall user experience across platforms.
|
||||
|
||||
## 🌟 Key Updates
|
||||
|
||||
- 🤖 Claude Opus 4.6 on Bedrock: Added Claude Opus 4.6 support for AWS Bedrock runtime
|
||||
- 📓 Notebook tool: Registered Notebook tool in server runtime with improved system prompts
|
||||
- 🔗 OpenAI Responses API: Added end-user info support on OpenAI Responses API calls
|
||||
- 🔐 Auth improvements: Fixed Microsoft authentication, improved OIDC provider account linking, and enhanced Feishu SSO
|
||||
- 📱 Mobile enhancements: Enabled vertical scrolling for topic list on mobile, fixed multimodal image rendering
|
||||
- 🏗️ Runtime refactoring: Extracted Anthropic factory and converted Moonshot to RouterRuntime
|
||||
|
||||
## 💫 Experience Improvements
|
||||
|
||||
Improved tasks display, enhanced local-system tool implementation, fixed PDF parsing in Docker, fixed editor content loss on send error, added custom avatars for group chat sidebar, and showed notifications for file upload storage limit errors.
|
||||
26
docs/changelog/2026-02-08-runtime-auth.zh-CN.mdx
Normal file
26
docs/changelog/2026-02-08-runtime-auth.zh-CN.mdx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
title: "模型运行时与认证改进 \U0001F527"
|
||||
description: 增强模型运行时并支持 Bedrock 上的 Claude Opus 4.6,改进认证流程,优化移动端体验。
|
||||
tags:
|
||||
- 模型运行时
|
||||
- 认证
|
||||
- Claude Opus 4.6
|
||||
- 笔记本
|
||||
---
|
||||
|
||||
# 模型运行时与认证改进 🔧
|
||||
|
||||
二月,LobeHub 专注于模型运行时增强、认证可靠性提升,以及跨平台用户体验的打磨优化。
|
||||
|
||||
## 🌟 重要更新
|
||||
|
||||
- 🤖 Bedrock 上的 Claude Opus 4.6:新增 AWS Bedrock 运行时对 Claude Opus 4.6 的支持
|
||||
- 📓 笔记本工具:在服务端运行时注册笔记本工具,改进系统提示词
|
||||
- 🔗 OpenAI Responses API:支持在 OpenAI Responses API 调用中添加终端用户信息
|
||||
- 🔐 认证改进:修复 Microsoft 认证、改进 OIDC 提供商账户关联、增强飞书 SSO
|
||||
- 📱 移动端增强:启用话题列表垂直滚动,修复多模态图像渲染
|
||||
- 🏗️ 运行时重构:提取 Anthropic 工厂,将 Moonshot 转换为 RouterRuntime
|
||||
|
||||
## 💫 体验优化
|
||||
|
||||
改进任务展示、增强本地系统工具实现、修复 Docker 中的 PDF 解析、修复发送错误时编辑器内容丢失、为群聊侧边栏添加自定义头像,以及在文件上传超出存储限制时显示通知。
|
||||
27
docs/changelog/2026-03-16-search.mdx
Normal file
27
docs/changelog/2026-03-16-search.mdx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
---
|
||||
title: "Search Optimization & Agent Documents \U0001F50D"
|
||||
description: >-
|
||||
Introduces BM25 search indexes, agent document storage, and full-text search
|
||||
capabilities.
|
||||
tags:
|
||||
- Search
|
||||
- BM25
|
||||
- Agent Documents
|
||||
- Full-Text Search
|
||||
---
|
||||
|
||||
# Search Optimization & Agent Documents 🔍
|
||||
|
||||
In March, LobeHub significantly enhanced its search infrastructure and introduced agent document capabilities, laying the groundwork for smarter knowledge retrieval.
|
||||
|
||||
## 🌟 Key Updates
|
||||
|
||||
- 🔍 BM25 search indexes: Added BM25 indexes with ICU tokenizer for optimized full-text search
|
||||
- 📄 Agent documents: Introduced the `agent_documents` table for agent-level knowledge storage
|
||||
- 🗄️ pg\_search extension: Enabled the `pg_search` PostgreSQL extension for advanced search capabilities
|
||||
- 📝 Topic descriptions: Added description column to the topics table for better topic organization
|
||||
- 🔑 API key security: Added API key hash column for enhanced security
|
||||
|
||||
## 💫 Experience Improvements
|
||||
|
||||
Fixed changelog auto-generation in release workflow, corrected stable renderer tar source path, and resolved market M2M token registration for trust client scenarios.
|
||||
25
docs/changelog/2026-03-16-search.zh-CN.mdx
Normal file
25
docs/changelog/2026-03-16-search.zh-CN.mdx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
---
|
||||
title: "搜索优化与智能体文档 \U0001F50D"
|
||||
description: 引入 BM25 搜索索引、智能体文档存储和全文检索能力。
|
||||
tags:
|
||||
- 搜索
|
||||
- BM25
|
||||
- 智能体文档
|
||||
- 全文检索
|
||||
---
|
||||
|
||||
# 搜索优化与智能体文档 🔍
|
||||
|
||||
三月,LobeHub 大幅增强了搜索基础设施,并引入智能体文档功能,为更智能的知识检索奠定基础。
|
||||
|
||||
## 🌟 重要更新
|
||||
|
||||
- 🔍 BM25 搜索索引:新增基于 ICU 分词器的 BM25 索引,优化全文检索
|
||||
- 📄 智能体文档:引入 `agent_documents` 表,支持智能体级别的知识存储
|
||||
- 🗄️ pg\_search 扩展:启用 `pg_search` PostgreSQL 扩展,提供高级搜索能力
|
||||
- 📝 话题描述:为话题表添加描述字段,改进话题组织管理
|
||||
- 🔑 API 密钥安全:新增 API 密钥哈希列,增强安全性
|
||||
|
||||
## 💫 体验优化
|
||||
|
||||
修复发布工作流中的更新日志自动生成、修正稳定版渲染器打包路径,以及解决信任客户端场景下的市场 M2M 令牌注册问题。
|
||||
|
|
@ -2,6 +2,23 @@
|
|||
"$schema": "https://github.com/lobehub/lobe-chat/blob/main/docs/changelog/schema.json",
|
||||
"cloud": [],
|
||||
"community": [
|
||||
{
|
||||
"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"]
|
||||
},
|
||||
{
|
||||
"id": "2026-02-08-runtime-auth",
|
||||
"date": "2026-02-08",
|
||||
"versionRange": ["2.1.6", "2.1.26"]
|
||||
},
|
||||
{
|
||||
"image": "https://private-user-images.githubusercontent.com/17870709/540830955-0fe626a3-0ddc-4f67-b595-3c5b3f1701e0.png?jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NzQwODY2MzYsIm5iZiI6MTc3NDA4NjMzNiwicGF0aCI6Ii8xNzg3MDcwOS81NDA4MzA5NTUtMGZlNjI2YTMtMGRkYy00ZjY3LWI1OTUtM2M1YjNmMTcwMWUwLnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNjAzMjElMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjYwMzIxVDA5NDUzNlomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPWRkMjg5MjUxMGI2OTYzMjYyYjA0NTExZTA4OTY4ODg1YmI2OWU4MmRiNDU4MjZhNzNiYWI3MjNjYmVkYzYwYTcmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.KmNeu3YwMCu8wMVCxB5VuJ9Em49fchBJqPYdfoz4G-Q",
|
||||
"id": "2026-01-27-v2",
|
||||
"date": "2026-01-27",
|
||||
"versionRange": ["2.0.1", "2.1.5"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets7f3b38c1d76cceb91edb29d6b1eb60db.webp",
|
||||
"id": "2025-12-20-mcp",
|
||||
|
|
|
|||
428
docs/development/basic/add-new-bot-platform.mdx
Normal file
428
docs/development/basic/add-new-bot-platform.mdx
Normal file
|
|
@ -0,0 +1,428 @@
|
|||
---
|
||||
title: Adding a New Bot Platform
|
||||
description: >-
|
||||
Learn how to add a new bot platform (e.g., Slack, WhatsApp) to LobeHub's
|
||||
channel system, including schema definition, client implementation, and
|
||||
platform registration.
|
||||
tags:
|
||||
- Bot Platform
|
||||
- Message Channels
|
||||
- Integration
|
||||
- Development Guide
|
||||
---
|
||||
|
||||
# Adding a New Bot Platform
|
||||
|
||||
This guide walks through the steps to add a new bot platform to LobeHub's channel system. The platform architecture is modular — each platform is a self-contained directory under `src/server/services/bot/platforms/`.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
src/server/services/bot/platforms/
|
||||
├── types.ts # Core interfaces (FieldSchema, PlatformClient, ClientFactory, etc.)
|
||||
├── registry.ts # PlatformRegistry class
|
||||
├── index.ts # Singleton registry + platform registration
|
||||
├── utils.ts # Shared utilities
|
||||
├── discord/ # Example: Discord platform
|
||||
│ ├── definition.ts # PlatformDefinition export
|
||||
│ ├── schema.ts # FieldSchema[] for credentials & settings
|
||||
│ ├── client.ts # ClientFactory + PlatformClient implementation
|
||||
│ └── api.ts # Platform API helper class
|
||||
└── <your-platform>/ # Your new platform
|
||||
```
|
||||
|
||||
**Key concepts:**
|
||||
|
||||
- **FieldSchema** — Declarative schema that drives both server-side validation and frontend form auto-generation
|
||||
- **PlatformClient** — Runtime interface for interacting with the platform (messaging, lifecycle)
|
||||
- **ClientFactory** — Creates PlatformClient instances and validates credentials
|
||||
- **PlatformDefinition** — Metadata + schema + factory, registered in the global registry
|
||||
- **Chat SDK Adapter** — Bridges the platform's webhook/events into the unified Chat SDK
|
||||
|
||||
## Prerequisite: Chat SDK Adapter
|
||||
|
||||
Each platform requires a **Chat SDK adapter** that bridges the platform's webhook events into the unified [Vercel Chat SDK](https://github.com/vercel/chat) (`chat` npm package). Before implementing the platform, determine which adapter to use:
|
||||
|
||||
### Option A: Use an existing npm adapter
|
||||
|
||||
Some platforms have official adapters published under `@chat-adapter/*`:
|
||||
|
||||
- `@chat-adapter/discord` — Discord
|
||||
- `@chat-adapter/slack` — Slack
|
||||
- `@chat-adapter/telegram` — Telegram
|
||||
|
||||
Check npm with `npm view @chat-adapter/<platform>` to see if one exists.
|
||||
|
||||
### Option B: Develop a custom adapter in `packages/`
|
||||
|
||||
If no npm adapter exists, you need to create one as a workspace package. Reference the existing implementations:
|
||||
|
||||
- `packages/chat-adapter-feishu` — Feishu/Lark adapter (`@lobechat/chat-adapter-feishu`)
|
||||
- `packages/chat-adapter-qq` — QQ adapter (`@lobechat/chat-adapter-qq`)
|
||||
|
||||
Each adapter package follows this structure:
|
||||
|
||||
```
|
||||
packages/chat-adapter-<platform>/
|
||||
├── package.json # name: @lobechat/chat-adapter-<platform>
|
||||
├── tsconfig.json
|
||||
├── tsup.config.ts
|
||||
└── src/
|
||||
├── index.ts # Public exports: createXxxAdapter, XxxApiClient, etc.
|
||||
├── adapter.ts # Adapter class implementing chat SDK's Adapter interface
|
||||
├── api.ts # Platform API client (webhook verification, message parsing)
|
||||
├── crypto.ts # Request signature verification
|
||||
├── format-converter.ts # Message format conversion (platform format ↔ chat SDK AST)
|
||||
└── types.ts # Platform-specific type definitions
|
||||
```
|
||||
|
||||
Key points for developing a custom adapter:
|
||||
|
||||
- The adapter must implement the `Adapter` interface from the `chat` package
|
||||
- It handles webhook request verification, event parsing, and message format conversion
|
||||
- The `createXxxAdapter(config)` factory function is what `PlatformClient.createAdapter()` will call
|
||||
- Add `"chat": "^4.14.0"` as a dependency in `package.json`
|
||||
|
||||
## Step 1: Create the Platform Directory
|
||||
|
||||
```bash
|
||||
mkdir src/server/services/bot/platforms/<platform-name>
|
||||
```
|
||||
|
||||
You will create four files:
|
||||
|
||||
| File | Purpose |
|
||||
| --------------- | ------------------------------------------------- |
|
||||
| `schema.ts` | Credential and settings field definitions |
|
||||
| `api.ts` | Lightweight API client for outbound messaging |
|
||||
| `client.ts` | `ClientFactory` + `PlatformClient` implementation |
|
||||
| `definition.ts` | `PlatformDefinition` export |
|
||||
|
||||
## Step 2: Define the Schema (`schema.ts`)
|
||||
|
||||
The schema is an array of `FieldSchema` objects with two top-level sections: `credentials` and `settings`.
|
||||
|
||||
```ts
|
||||
import type { FieldSchema } from '../types';
|
||||
|
||||
export const schema: FieldSchema[] = [
|
||||
{
|
||||
key: 'credentials',
|
||||
label: 'channel.credentials',
|
||||
properties: [
|
||||
{
|
||||
key: 'applicationId',
|
||||
description: 'channel.applicationIdHint',
|
||||
label: 'channel.applicationId',
|
||||
required: true,
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
key: 'botToken',
|
||||
description: 'channel.botTokenEncryptedHint',
|
||||
label: 'channel.botToken',
|
||||
required: true,
|
||||
type: 'password', // Encrypted in storage, masked in UI
|
||||
},
|
||||
],
|
||||
type: 'object',
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
label: 'channel.settings',
|
||||
properties: [
|
||||
{
|
||||
key: 'charLimit',
|
||||
default: 4000,
|
||||
description: 'channel.charLimitHint',
|
||||
label: 'channel.charLimit',
|
||||
minimum: 100,
|
||||
type: 'number',
|
||||
},
|
||||
// Add platform-specific settings...
|
||||
],
|
||||
type: 'object',
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
**Schema conventions:**
|
||||
|
||||
- `type: 'password'` fields are encrypted at rest and masked in the form
|
||||
- Use existing i18n keys (e.g., `channel.botToken`, `channel.charLimit`) for shared fields
|
||||
- Use `channel.<platform>.<key>` for platform-specific i18n keys
|
||||
- `devOnly: true` fields only appear when `NODE_ENV === 'development'`
|
||||
- Credentials must include a field that resolves to `applicationId` — either an explicit `applicationId` field, an `appId` field, or a `botToken` from which the ID is derived (see `resolveApplicationId` in the channel detail page)
|
||||
|
||||
## Step 3: Create the API Client (`api.ts`)
|
||||
|
||||
A lightweight class for outbound messaging operations used by the callback service (outside the Chat SDK adapter):
|
||||
|
||||
```ts
|
||||
import debug from 'debug';
|
||||
|
||||
const log = debug('bot-platform:<platform>:client');
|
||||
|
||||
export const API_BASE = 'https://api.example.com';
|
||||
|
||||
export class PlatformApi {
|
||||
private readonly token: string;
|
||||
|
||||
constructor(token: string) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
async sendMessage(channelId: string, text: string): Promise<{ id: string }> {
|
||||
log('sendMessage: channel=%s', channelId);
|
||||
return this.call('messages.send', { channel: channelId, text });
|
||||
}
|
||||
|
||||
async editMessage(channelId: string, messageId: string, text: string): Promise<void> {
|
||||
log('editMessage: channel=%s, message=%s', channelId, messageId);
|
||||
await this.call('messages.update', { channel: channelId, id: messageId, text });
|
||||
}
|
||||
|
||||
// ... other operations (typing indicator, reactions, etc.)
|
||||
|
||||
private async call(method: string, body: Record<string, unknown>): Promise<any> {
|
||||
const response = await fetch(`${API_BASE}/${method}`, {
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
log('API error: method=%s, status=%d, body=%s', method, response.status, text);
|
||||
throw new Error(`API ${method} failed: ${response.status} ${text}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 4: Implement the Client (`client.ts`)
|
||||
|
||||
Implement `PlatformClient` and extend `ClientFactory`:
|
||||
|
||||
```ts
|
||||
import { createPlatformAdapter } from '@chat-adapter/<platform>';
|
||||
import debug from 'debug';
|
||||
|
||||
import {
|
||||
type BotPlatformRuntimeContext,
|
||||
type BotProviderConfig,
|
||||
ClientFactory,
|
||||
type PlatformClient,
|
||||
type PlatformMessenger,
|
||||
type ValidationResult,
|
||||
} from '../types';
|
||||
import { PlatformApi } from './api';
|
||||
|
||||
const log = debug('bot-platform:<platform>:bot');
|
||||
|
||||
class MyPlatformClient implements PlatformClient {
|
||||
readonly id = '<platform>';
|
||||
readonly applicationId: string;
|
||||
|
||||
private config: BotProviderConfig;
|
||||
private context: BotPlatformRuntimeContext;
|
||||
|
||||
constructor(config: BotProviderConfig, context: BotPlatformRuntimeContext) {
|
||||
this.config = config;
|
||||
this.context = context;
|
||||
this.applicationId = config.applicationId;
|
||||
}
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
||||
async start(): Promise<void> {
|
||||
// Register webhook or start listening
|
||||
// For webhook platforms: configure the webhook URL with the platform API
|
||||
// For gateway platforms: open a persistent connection
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
// Cleanup: remove webhook registration or close connection
|
||||
}
|
||||
|
||||
// --- Runtime Operations ---
|
||||
|
||||
createAdapter(): Record<string, any> {
|
||||
// Return a Chat SDK adapter instance for inbound message handling
|
||||
return {
|
||||
'<platform>': createPlatformAdapter({
|
||||
botToken: this.config.credentials.botToken,
|
||||
// ... adapter-specific config
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
getMessenger(platformThreadId: string): PlatformMessenger {
|
||||
const api = new PlatformApi(this.config.credentials.botToken);
|
||||
const channelId = platformThreadId.split(':')[1];
|
||||
|
||||
return {
|
||||
createMessage: (content) => api.sendMessage(channelId, content).then(() => {}),
|
||||
editMessage: (messageId, content) => api.editMessage(channelId, messageId, content),
|
||||
removeReaction: (messageId, emoji) => api.removeReaction(channelId, messageId, emoji),
|
||||
triggerTyping: () => Promise.resolve(),
|
||||
};
|
||||
}
|
||||
|
||||
extractChatId(platformThreadId: string): string {
|
||||
return platformThreadId.split(':')[1];
|
||||
}
|
||||
|
||||
parseMessageId(compositeId: string): string {
|
||||
return compositeId;
|
||||
}
|
||||
|
||||
// --- Optional methods ---
|
||||
|
||||
// sanitizeUserInput(text: string): string { ... }
|
||||
// shouldSubscribe(threadId: string): boolean { ... }
|
||||
// formatReply(body: string, stats?: UsageStats): string { ... }
|
||||
}
|
||||
|
||||
export class MyPlatformClientFactory extends ClientFactory {
|
||||
createClient(config: BotProviderConfig, context: BotPlatformRuntimeContext): PlatformClient {
|
||||
return new MyPlatformClient(config, context);
|
||||
}
|
||||
|
||||
async validateCredentials(credentials: Record<string, string>): Promise<ValidationResult> {
|
||||
// Call the platform API to verify the credentials are valid
|
||||
try {
|
||||
const res = await fetch('https://api.example.com/auth.test', {
|
||||
headers: { Authorization: `Bearer ${credentials.botToken}` },
|
||||
method: 'POST',
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return { valid: true };
|
||||
} catch {
|
||||
return {
|
||||
errors: [{ field: 'botToken', message: 'Failed to authenticate' }],
|
||||
valid: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key interfaces to implement:**
|
||||
|
||||
| Method | Purpose |
|
||||
| --------------------- | ----------------------------------------------------------- |
|
||||
| `start()` | Register webhook or start gateway listener |
|
||||
| `stop()` | Clean up resources on shutdown |
|
||||
| `createAdapter()` | Return Chat SDK adapter for inbound event handling |
|
||||
| `getMessenger()` | Return outbound messaging interface for a thread |
|
||||
| `extractChatId()` | Parse platform channel ID from composite thread ID |
|
||||
| `parseMessageId()` | Convert composite message ID to platform-native format |
|
||||
| `sanitizeUserInput()` | *(Optional)* Strip bot mention artifacts from user input |
|
||||
| `shouldSubscribe()` | *(Optional)* Control thread auto-subscription behavior |
|
||||
| `formatReply()` | *(Optional)* Append platform-specific formatting to replies |
|
||||
|
||||
## Step 5: Export the Definition (`definition.ts`)
|
||||
|
||||
```ts
|
||||
import type { PlatformDefinition } from '../types';
|
||||
import { MyPlatformClientFactory } from './client';
|
||||
import { schema } from './schema';
|
||||
|
||||
export const myPlatform: PlatformDefinition = {
|
||||
id: '<platform>',
|
||||
name: 'Platform Name',
|
||||
description: 'Connect a Platform bot',
|
||||
documentation: {
|
||||
portalUrl: 'https://developers.example.com',
|
||||
setupGuideUrl: 'https://lobehub.com/docs/usage/channels/<platform>',
|
||||
},
|
||||
schema,
|
||||
showWebhookUrl: true, // Set to true if users need to manually copy the webhook URL
|
||||
clientFactory: new MyPlatformClientFactory(),
|
||||
};
|
||||
```
|
||||
|
||||
**`showWebhookUrl`:** Set to `true` for platforms where the user must manually paste a webhook URL (e.g., Slack, Feishu). Set to `false` (or omit) for platforms that auto-register webhooks via API (e.g., Telegram).
|
||||
|
||||
## Step 6: Register the Platform
|
||||
|
||||
Edit `src/server/services/bot/platforms/index.ts`:
|
||||
|
||||
```ts
|
||||
import { myPlatform } from './<platform>/definition';
|
||||
|
||||
// Add to exports
|
||||
export { myPlatform } from './<platform>/definition';
|
||||
|
||||
// Register
|
||||
platformRegistry.register(myPlatform);
|
||||
```
|
||||
|
||||
## Step 7: Add i18n Keys
|
||||
|
||||
### Default keys (`src/locales/default/agent.ts`)
|
||||
|
||||
Add platform-specific keys. Reuse generic keys where possible:
|
||||
|
||||
```ts
|
||||
// Reusable (already exist):
|
||||
// 'channel.botToken', 'channel.applicationId', 'channel.charLimit', etc.
|
||||
|
||||
// Platform-specific:
|
||||
'channel.<platform>.description': 'Connect this assistant to Platform for ...',
|
||||
'channel.<platform>.someFieldHint': 'Description of this field.',
|
||||
```
|
||||
|
||||
### Translations (`locales/zh-CN/agent.json`, `locales/en-US/agent.json`)
|
||||
|
||||
Add corresponding translations for all new keys in both locale files.
|
||||
|
||||
## Step 8: Add User Documentation
|
||||
|
||||
Create setup guides in `docs/usage/channels/`:
|
||||
|
||||
- `<platform>.mdx` — English guide
|
||||
- `<platform>.zh-CN.mdx` — Chinese guide
|
||||
|
||||
Follow the structure of existing docs (e.g., `discord.mdx`): Prerequisites → Create App → Configure in LobeHub → Configure Webhooks → Test Connection → Configuration Reference → Troubleshooting.
|
||||
|
||||
## Frontend: Automatic UI Generation
|
||||
|
||||
The frontend automatically generates the configuration form from the schema. No frontend code changes are needed unless your platform requires a custom icon. The icon resolution works by matching the platform `name` against known icons in `@lobehub/ui/icons`:
|
||||
|
||||
```
|
||||
// src/routes/(main)/agent/channel/const.ts
|
||||
const ICON_NAMES = ['Discord', 'GoogleChat', 'Lark', 'Slack', 'Telegram', ...];
|
||||
```
|
||||
|
||||
If your platform's `name` matches an icon name (case-insensitive), the icon is used automatically. Otherwise, add an alias in `ICON_ALIASES`.
|
||||
|
||||
## Webhook URL Pattern
|
||||
|
||||
All platforms share the same webhook route:
|
||||
|
||||
```
|
||||
POST /api/agent/webhooks/[platform]/[appId]
|
||||
```
|
||||
|
||||
The `BotMessageRouter` handles routing, on-demand bot loading, and Chat SDK integration automatically.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Ensure a Chat SDK adapter exists (`@chat-adapter/*` on npm or custom `packages/chat-adapter-<platform>`)
|
||||
- [ ] Create `src/server/services/bot/platforms/<platform>/`
|
||||
- [ ] `schema.ts` — Field definitions for credentials and settings
|
||||
- [ ] `api.ts` — Outbound API client
|
||||
- [ ] `client.ts` — `ClientFactory` + `PlatformClient`
|
||||
- [ ] `definition.ts` — `PlatformDefinition` export
|
||||
- [ ] Register in `src/server/services/bot/platforms/index.ts`
|
||||
- [ ] Add i18n keys in `src/locales/default/agent.ts`
|
||||
- [ ] Add translations in `locales/zh-CN/agent.json` and `locales/en-US/agent.json`
|
||||
- [ ] Add setup docs in `docs/usage/channels/<platform>.mdx` (en + zh-CN)
|
||||
- [ ] Verify icon resolves in `const.ts` (or add alias)
|
||||
425
docs/development/basic/add-new-bot-platform.zh-CN.mdx
Normal file
425
docs/development/basic/add-new-bot-platform.zh-CN.mdx
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
---
|
||||
title: 添加新的 Bot 平台
|
||||
description: 了解如何向 LobeHub 的渠道系统添加新的 Bot 平台(如 Slack、WhatsApp),包括 Schema 定义、客户端实现和平台注册。
|
||||
tags:
|
||||
- Bot 平台
|
||||
- 消息渠道
|
||||
- 集成
|
||||
- 开发指南
|
||||
---
|
||||
|
||||
# 添加新的 Bot 平台
|
||||
|
||||
本指南介绍如何向 LobeHub 的渠道系统添加新的 Bot 平台。平台架构是模块化的 —— 每个平台是 `src/server/services/bot/platforms/` 下的一个独立目录。
|
||||
|
||||
## 架构概览
|
||||
|
||||
```
|
||||
src/server/services/bot/platforms/
|
||||
├── types.ts # 核心接口(FieldSchema、PlatformClient、ClientFactory 等)
|
||||
├── registry.ts # PlatformRegistry 类
|
||||
├── index.ts # 单例注册表 + 平台注册
|
||||
├── utils.ts # 共享工具函数
|
||||
├── discord/ # 示例:Discord 平台
|
||||
│ ├── definition.ts # PlatformDefinition 导出
|
||||
│ ├── schema.ts # 凭据和设置的 FieldSchema[]
|
||||
│ ├── client.ts # ClientFactory + PlatformClient 实现
|
||||
│ └── api.ts # 平台 API 辅助类
|
||||
└── <your-platform>/ # 你的新平台
|
||||
```
|
||||
|
||||
**核心概念:**
|
||||
|
||||
- **FieldSchema** — 声明式 Schema,同时驱动服务端校验和前端表单自动生成
|
||||
- **PlatformClient** — 与平台交互的运行时接口(消息收发、生命周期管理)
|
||||
- **ClientFactory** — 创建 PlatformClient 实例并验证凭据
|
||||
- **PlatformDefinition** — 元数据 + Schema + 工厂,注册到全局注册表
|
||||
- **Chat SDK Adapter** — 将平台的 Webhook / 事件桥接到统一的 Chat SDK
|
||||
|
||||
## 前置条件:Chat SDK Adapter
|
||||
|
||||
每个平台都需要一个 **Chat SDK Adapter**,用于将平台的 Webhook 事件桥接到统一的 [Vercel Chat SDK](https://github.com/vercel/chat)(`chat` npm 包)。在实现平台之前,需要确定使用哪个 Adapter:
|
||||
|
||||
### 方案 A:使用已有的 npm Adapter
|
||||
|
||||
部分平台已有官方 Adapter 发布在 `@chat-adapter/*` 下:
|
||||
|
||||
- `@chat-adapter/discord` — Discord
|
||||
- `@chat-adapter/slack` — Slack
|
||||
- `@chat-adapter/telegram` — Telegram
|
||||
|
||||
可以通过 `npm view @chat-adapter/<platform>` 检查是否存在。
|
||||
|
||||
### 方案 B:在 `packages/` 中开发自定义 Adapter
|
||||
|
||||
如果没有现成的 npm Adapter,你需要在工作区中创建一个 Adapter 包。可参考现有实现:
|
||||
|
||||
- `packages/chat-adapter-feishu` — 飞书 / Lark Adapter(`@lobechat/chat-adapter-feishu`)
|
||||
- `packages/chat-adapter-qq` — QQ Adapter(`@lobechat/chat-adapter-qq`)
|
||||
|
||||
每个 Adapter 包遵循以下结构:
|
||||
|
||||
```
|
||||
packages/chat-adapter-<platform>/
|
||||
├── package.json # name: @lobechat/chat-adapter-<platform>
|
||||
├── tsconfig.json
|
||||
├── tsup.config.ts
|
||||
└── src/
|
||||
├── index.ts # 公共导出:createXxxAdapter、XxxApiClient 等
|
||||
├── adapter.ts # 实现 chat SDK 的 Adapter 接口的适配器类
|
||||
├── api.ts # 平台 API 客户端(Webhook 验证、消息解析)
|
||||
├── crypto.ts # 请求签名验证
|
||||
├── format-converter.ts # 消息格式转换(平台格式 ↔ Chat SDK AST)
|
||||
└── types.ts # 平台特定的类型定义
|
||||
```
|
||||
|
||||
开发自定义 Adapter 的要点:
|
||||
|
||||
- Adapter 必须实现 `chat` 包中的 `Adapter` 接口
|
||||
- 需要处理 Webhook 请求验证、事件解析和消息格式转换
|
||||
- `createXxxAdapter(config)` 工厂函数是 `PlatformClient.createAdapter()` 调用的入口
|
||||
- 在 `package.json` 中添加 `"chat": "^4.14.0"` 作为依赖
|
||||
|
||||
## 第一步:创建平台目录
|
||||
|
||||
```bash
|
||||
mkdir src/server/services/bot/platforms/<platform-name>
|
||||
```
|
||||
|
||||
需要创建四个文件:
|
||||
|
||||
| 文件 | 用途 |
|
||||
| --------------- | ------------------------------------- |
|
||||
| `schema.ts` | 凭据和设置的字段定义 |
|
||||
| `api.ts` | 用于出站消息的轻量 API 客户端 |
|
||||
| `client.ts` | `ClientFactory` + `PlatformClient` 实现 |
|
||||
| `definition.ts` | `PlatformDefinition` 导出 |
|
||||
|
||||
## 第二步:定义 Schema(`schema.ts`)
|
||||
|
||||
Schema 是一个 `FieldSchema` 对象数组,包含两个顶层部分:`credentials`(凭据)和 `settings`(设置)。
|
||||
|
||||
```ts
|
||||
import type { FieldSchema } from '../types';
|
||||
|
||||
export const schema: FieldSchema[] = [
|
||||
{
|
||||
key: 'credentials',
|
||||
label: 'channel.credentials',
|
||||
properties: [
|
||||
{
|
||||
key: 'applicationId',
|
||||
description: 'channel.applicationIdHint',
|
||||
label: 'channel.applicationId',
|
||||
required: true,
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
key: 'botToken',
|
||||
description: 'channel.botTokenEncryptedHint',
|
||||
label: 'channel.botToken',
|
||||
required: true,
|
||||
type: 'password', // 存储时加密,UI 中遮蔽显示
|
||||
},
|
||||
],
|
||||
type: 'object',
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
label: 'channel.settings',
|
||||
properties: [
|
||||
{
|
||||
key: 'charLimit',
|
||||
default: 4000,
|
||||
description: 'channel.charLimitHint',
|
||||
label: 'channel.charLimit',
|
||||
minimum: 100,
|
||||
type: 'number',
|
||||
},
|
||||
// 添加平台特定设置...
|
||||
],
|
||||
type: 'object',
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
**Schema 约定:**
|
||||
|
||||
- `type: 'password'` 字段会被加密存储,在表单中以密码形式显示
|
||||
- 共享字段使用已有的 i18n 键(如 `channel.botToken`、`channel.charLimit`)
|
||||
- 平台特有字段使用 `channel.<platform>.<key>` 命名
|
||||
- `devOnly: true` 的字段仅在 `NODE_ENV === 'development'` 时显示
|
||||
- 凭据中必须包含一个能解析为 `applicationId` 的字段 —— 可以是显式的 `applicationId` 字段、`appId` 字段,或从 `botToken` 中提取(参见渠道详情页的 `resolveApplicationId`)
|
||||
|
||||
## 第三步:创建 API 客户端(`api.ts`)
|
||||
|
||||
用于回调服务(Chat SDK Adapter 之外)的出站消息操作的轻量类:
|
||||
|
||||
```ts
|
||||
import debug from 'debug';
|
||||
|
||||
const log = debug('bot-platform:<platform>:client');
|
||||
|
||||
export const API_BASE = 'https://api.example.com';
|
||||
|
||||
export class PlatformApi {
|
||||
private readonly token: string;
|
||||
|
||||
constructor(token: string) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
async sendMessage(channelId: string, text: string): Promise<{ id: string }> {
|
||||
log('sendMessage: channel=%s', channelId);
|
||||
return this.call('messages.send', { channel: channelId, text });
|
||||
}
|
||||
|
||||
async editMessage(channelId: string, messageId: string, text: string): Promise<void> {
|
||||
log('editMessage: channel=%s, message=%s', channelId, messageId);
|
||||
await this.call('messages.update', { channel: channelId, id: messageId, text });
|
||||
}
|
||||
|
||||
// ... 其他操作(输入指示器、表情回应等)
|
||||
|
||||
private async call(method: string, body: Record<string, unknown>): Promise<any> {
|
||||
const response = await fetch(`${API_BASE}/${method}`, {
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
log('API error: method=%s, status=%d, body=%s', method, response.status, text);
|
||||
throw new Error(`API ${method} failed: ${response.status} ${text}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 第四步:实现客户端(`client.ts`)
|
||||
|
||||
实现 `PlatformClient` 并继承 `ClientFactory`:
|
||||
|
||||
```ts
|
||||
import { createPlatformAdapter } from '@chat-adapter/<platform>';
|
||||
import debug from 'debug';
|
||||
|
||||
import {
|
||||
type BotPlatformRuntimeContext,
|
||||
type BotProviderConfig,
|
||||
ClientFactory,
|
||||
type PlatformClient,
|
||||
type PlatformMessenger,
|
||||
type ValidationResult,
|
||||
} from '../types';
|
||||
import { PlatformApi } from './api';
|
||||
|
||||
const log = debug('bot-platform:<platform>:bot');
|
||||
|
||||
class MyPlatformClient implements PlatformClient {
|
||||
readonly id = '<platform>';
|
||||
readonly applicationId: string;
|
||||
|
||||
private config: BotProviderConfig;
|
||||
private context: BotPlatformRuntimeContext;
|
||||
|
||||
constructor(config: BotProviderConfig, context: BotPlatformRuntimeContext) {
|
||||
this.config = config;
|
||||
this.context = context;
|
||||
this.applicationId = config.applicationId;
|
||||
}
|
||||
|
||||
// --- 生命周期 ---
|
||||
|
||||
async start(): Promise<void> {
|
||||
// 注册 webhook 或开始监听
|
||||
// Webhook 平台:通过平台 API 配置 webhook URL
|
||||
// 网关平台:打开持久连接
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
// 清理:移除 webhook 注册或关闭连接
|
||||
}
|
||||
|
||||
// --- 运行时操作 ---
|
||||
|
||||
createAdapter(): Record<string, any> {
|
||||
// 返回 Chat SDK adapter 实例用于入站消息处理
|
||||
return {
|
||||
'<platform>': createPlatformAdapter({
|
||||
botToken: this.config.credentials.botToken,
|
||||
// ... adapter 特定配置
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
getMessenger(platformThreadId: string): PlatformMessenger {
|
||||
const api = new PlatformApi(this.config.credentials.botToken);
|
||||
const channelId = platformThreadId.split(':')[1];
|
||||
|
||||
return {
|
||||
createMessage: (content) => api.sendMessage(channelId, content).then(() => {}),
|
||||
editMessage: (messageId, content) => api.editMessage(channelId, messageId, content),
|
||||
removeReaction: (messageId, emoji) => api.removeReaction(channelId, messageId, emoji),
|
||||
triggerTyping: () => Promise.resolve(),
|
||||
};
|
||||
}
|
||||
|
||||
extractChatId(platformThreadId: string): string {
|
||||
return platformThreadId.split(':')[1];
|
||||
}
|
||||
|
||||
parseMessageId(compositeId: string): string {
|
||||
return compositeId;
|
||||
}
|
||||
|
||||
// --- 可选方法 ---
|
||||
|
||||
// sanitizeUserInput(text: string): string { ... }
|
||||
// shouldSubscribe(threadId: string): boolean { ... }
|
||||
// formatReply(body: string, stats?: UsageStats): string { ... }
|
||||
}
|
||||
|
||||
export class MyPlatformClientFactory extends ClientFactory {
|
||||
createClient(config: BotProviderConfig, context: BotPlatformRuntimeContext): PlatformClient {
|
||||
return new MyPlatformClient(config, context);
|
||||
}
|
||||
|
||||
async validateCredentials(credentials: Record<string, string>): Promise<ValidationResult> {
|
||||
// 调用平台 API 验证凭据有效性
|
||||
try {
|
||||
const res = await fetch('https://api.example.com/auth.test', {
|
||||
headers: { Authorization: `Bearer ${credentials.botToken}` },
|
||||
method: 'POST',
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return { valid: true };
|
||||
} catch {
|
||||
return {
|
||||
errors: [{ field: 'botToken', message: 'Failed to authenticate' }],
|
||||
valid: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**需要实现的关键接口:**
|
||||
|
||||
| 方法 | 用途 |
|
||||
| --------------------- | ---------------------------- |
|
||||
| `start()` | 注册 webhook 或启动网关监听 |
|
||||
| `stop()` | 关闭时清理资源 |
|
||||
| `createAdapter()` | 返回 Chat SDK adapter 用于入站事件处理 |
|
||||
| `getMessenger()` | 返回指定会话的出站消息接口 |
|
||||
| `extractChatId()` | 从复合会话 ID 中解析平台频道 ID |
|
||||
| `parseMessageId()` | 将复合消息 ID 转换为平台原生格式 |
|
||||
| `sanitizeUserInput()` | \*(可选)\* 去除用户输入中的 Bot 提及标记 |
|
||||
| `shouldSubscribe()` | \*(可选)\* 控制会话自动订阅行为 |
|
||||
| `formatReply()` | \*(可选)\* 在回复中追加平台特定的格式化内容 |
|
||||
|
||||
## 第五步:导出定义(`definition.ts`)
|
||||
|
||||
```ts
|
||||
import type { PlatformDefinition } from '../types';
|
||||
import { MyPlatformClientFactory } from './client';
|
||||
import { schema } from './schema';
|
||||
|
||||
export const myPlatform: PlatformDefinition = {
|
||||
id: '<platform>',
|
||||
name: 'Platform Name',
|
||||
description: 'Connect a Platform bot',
|
||||
documentation: {
|
||||
portalUrl: 'https://developers.example.com',
|
||||
setupGuideUrl: 'https://lobehub.com/docs/usage/channels/<platform>',
|
||||
},
|
||||
schema,
|
||||
showWebhookUrl: true, // 如果用户需要手动复制 webhook URL 则设为 true
|
||||
clientFactory: new MyPlatformClientFactory(),
|
||||
};
|
||||
```
|
||||
|
||||
**`showWebhookUrl`:** 对于需要用户手动粘贴 webhook URL 的平台(如 Slack、飞书)设为 `true`。对于通过 API 自动注册 webhook 的平台(如 Telegram)设为 `false` 或省略。
|
||||
|
||||
## 第六步:注册平台
|
||||
|
||||
编辑 `src/server/services/bot/platforms/index.ts`:
|
||||
|
||||
```ts
|
||||
import { myPlatform } from './<platform>/definition';
|
||||
|
||||
// 添加到导出
|
||||
export { myPlatform } from './<platform>/definition';
|
||||
|
||||
// 注册
|
||||
platformRegistry.register(myPlatform);
|
||||
```
|
||||
|
||||
## 第七步:添加 i18n 键
|
||||
|
||||
### 默认键(`src/locales/default/agent.ts`)
|
||||
|
||||
添加平台特有键。尽量复用通用键:
|
||||
|
||||
```ts
|
||||
// 可复用(已存在):
|
||||
// 'channel.botToken'、'channel.applicationId'、'channel.charLimit' 等
|
||||
|
||||
// 平台特有:
|
||||
'channel.<platform>.description': 'Connect this assistant to Platform for ...',
|
||||
'channel.<platform>.someFieldHint': 'Description of this field.',
|
||||
```
|
||||
|
||||
### 翻译文件(`locales/zh-CN/agent.json`、`locales/en-US/agent.json`)
|
||||
|
||||
在两个语言文件中添加所有新键的对应翻译。
|
||||
|
||||
## 第八步:添加用户文档
|
||||
|
||||
在 `docs/usage/channels/` 下创建配置教程:
|
||||
|
||||
- `<platform>.mdx` — 英文教程
|
||||
- `<platform>.zh-CN.mdx` — 中文教程
|
||||
|
||||
参考现有文档的结构(如 `discord.mdx`):前置条件 → 创建应用 → 在 LobeHub 中配置 → 配置 Webhook → 测试连接 → 配置参考 → 故障排除。
|
||||
|
||||
## 前端:自动 UI 生成
|
||||
|
||||
前端会根据 Schema 自动生成配置表单,无需修改前端代码(除非你的平台需要自定义图标)。图标解析通过将平台 `name` 与 `@lobehub/ui/icons` 中的已知图标匹配来实现:
|
||||
|
||||
```
|
||||
// src/routes/(main)/agent/channel/const.ts
|
||||
const ICON_NAMES = ['Discord', 'GoogleChat', 'Lark', 'Slack', 'Telegram', ...];
|
||||
```
|
||||
|
||||
如果你的平台 `name` 与图标名称匹配(不区分大小写),图标会自动使用。否则需要在 `ICON_ALIASES` 中添加别名。
|
||||
|
||||
## Webhook URL 模式
|
||||
|
||||
所有平台共享同一个 Webhook 路由:
|
||||
|
||||
```
|
||||
POST /api/agent/webhooks/[platform]/[appId]
|
||||
```
|
||||
|
||||
`BotMessageRouter` 会自动处理路由分发、按需加载 Bot 和 Chat SDK 集成。
|
||||
|
||||
## 检查清单
|
||||
|
||||
- [ ] 确保 Chat SDK Adapter 可用(npm 上的 `@chat-adapter/*` 或自定义的 `packages/chat-adapter-<platform>`)
|
||||
- [ ] 创建 `src/server/services/bot/platforms/<platform>/`
|
||||
- [ ] `schema.ts` — 凭据和设置的字段定义
|
||||
- [ ] `api.ts` — 出站 API 客户端
|
||||
- [ ] `client.ts` — `ClientFactory` + `PlatformClient`
|
||||
- [ ] `definition.ts` — `PlatformDefinition` 导出
|
||||
- [ ] 在 `src/server/services/bot/platforms/index.ts` 中注册
|
||||
- [ ] 在 `src/locales/default/agent.ts` 中添加 i18n 键
|
||||
- [ ] 在 `locales/zh-CN/agent.json` 和 `locales/en-US/agent.json` 中添加翻译
|
||||
- [ ] 在 `docs/usage/channels/<platform>.mdx` 中添加配置教程(中英文)
|
||||
- [ ] 验证图标在 `const.ts` 中能正确解析(或添加别名)
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
---
|
||||
title: Code Style and Contribution Guidelines
|
||||
description: >-
|
||||
Learn about LobeHub's code style and contribution process for consistent coding.
|
||||
|
||||
Learn about LobeHub's code style and contribution process for consistent
|
||||
coding.
|
||||
tags:
|
||||
- Code Style
|
||||
- Contribution Guidelines
|
||||
|
|
@ -95,12 +95,12 @@ Use the following emojis to prefix your commit messages:
|
|||
|
||||
| Emoji | Code | Type | Description | Triggers Release? |
|
||||
| ----- | ------------------------ | -------- | ------------------------ | ----------------- |
|
||||
| ✨ | `:sparkles:` | feat | New feature | Yes |
|
||||
| ✨ | `:sparkles:` | feat | New feature | Yes |
|
||||
| 🐛 | `:bug:` | fix | Bug fix | Yes |
|
||||
| 📝 | `:memo:` | docs | Documentation | No |
|
||||
| 💄 | `:lipstick:` | style | UI/styling changes | No |
|
||||
| ♻️ | `:recycle:` | refactor | Code refactoring | No |
|
||||
| ✅ | `:white_check_mark:` | test | Tests | No |
|
||||
| ✅ | `:white_check_mark:` | test | Tests | No |
|
||||
| 🔨 | `:hammer:` | chore | Maintenance tasks | No |
|
||||
| 🚀 | `:rocket:` | perf | Performance improvements | No |
|
||||
| 🌐 | `:globe_with_meridians:` | i18n | Internationalization | No |
|
||||
|
|
|
|||
|
|
@ -2029,4 +2029,4 @@ ref: topic_documents.document_id > documents.id
|
|||
|
||||
ref: topic_documents.topic_id > topics.id
|
||||
|
||||
ref: topics.session_id - sessions.id
|
||||
ref: topics.session_id - sessions.id
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ We need to configure an S3-compatible storage service in the server-side databas
|
|||
|
||||
Click `Object Storage` in the left sidebar, then the `Create Bucket` button in the top-right corner to create a new bucket. This example uses the name `lobe`. Leave Versioning and Object Lock disabled (default settings).
|
||||
|
||||
<Image alt={"Create Bucket"} src={'https://github.com/user-attachments/assets/27c37617-a813-4de5-b0bf-c7167999c856'} />
|
||||
<Image alt={"Create Bucket"} src={'/blog/assetsc958eae64465451c4374cdee8f6fd596.webp'} />
|
||||
|
||||
Go to the bucket and click `Settings`, choose `Custom` for the policy, and paste the following JSON to make the bucket public-read/private-write:
|
||||
|
||||
|
|
@ -108,9 +108,9 @@ We need to configure an S3-compatible storage service in the server-side databas
|
|||
|
||||
Copy the generated Access Key and Secret Key (the `Export` button lets you save the JSON locally). The English labels in the UI are confusing, but remember the shorter string is the Access Key and the longer string is the Secret Key (the exported JSON is correct).
|
||||
|
||||
<Image alt={"Add Key"} src={'https://github.com/user-attachments/assets/81f18b20-3918-4f77-8571-07d0c4a79aec'} />
|
||||
<Image alt={"Add Key"} src={'/blog/assets43d66c62b79a027895b5a6127b2f2de2.webp'} />
|
||||
|
||||
<Image alt={"Export Key"} src={'https://github.com/user-attachments/assets/4dde41ec-985b-4781-8c77-aac65555a32f'} />
|
||||
<Image alt={"Export Key"} src={'/blog/assets04fecea4e5f4ce3490bf11bec66ff477.webp'} />
|
||||
|
||||
### Configure Reverse Proxy
|
||||
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ tags:
|
|||
|
||||
点击左侧边栏的 `对象存储` 菜单,右上角 `创建存储桶` 按钮,创建一个新的存储桶(Bucket)。创建存储桶时将指定其名称,下文以 `lobe` 为例。版本、对象锁依照默认配置不开启。
|
||||
|
||||
<Image alt={"Create Bucket"} src={'https://github.com/user-attachments/assets/27c37617-a813-4de5-b0bf-c7167999c856'} />
|
||||
<Image alt={"Create Bucket"} src={'/blog/assetsc958eae64465451c4374cdee8f6fd596.webp'} />
|
||||
|
||||
点击存储桶 - `配置` 按钮,选择策略为 `自定义`,然后填入如下 JSON,设置存储桶的权限为 `公有读私有写`:
|
||||
|
||||
|
|
@ -108,9 +108,9 @@ tags:
|
|||
|
||||
记录好得到的访问密钥和密钥(你可以点击 `导出` 按钮以在本地保存)。这里 RustFS 的翻译有点迷惑,但你只需要记住上面那个短的是 `Access Key`,长的是 `Secret Key` 即可(导出的 JSON 中是对的)。
|
||||
|
||||
<Image alt={"Add Key"} src={'https://github.com/user-attachments/assets/81f18b20-3918-4f77-8571-07d0c4a79aec'} />
|
||||
<Image alt={"Add Key"} src={'/blog/assets43d66c62b79a027895b5a6127b2f2de2.webp'} />
|
||||
|
||||
<Image alt={"Export Key"} src={'https://github.com/user-attachments/assets/4dde41ec-985b-4781-8c77-aac65555a32f'} />
|
||||
<Image alt={"Export Key"} src={'/blog/assets04fecea4e5f4ce3490bf11bec66ff477.webp'} />
|
||||
|
||||
### 配置反向代理
|
||||
|
||||
|
|
|
|||
|
|
@ -1,129 +1,63 @@
|
|||
---
|
||||
title: Scheduled Tasks
|
||||
description: >-
|
||||
Schedule agents to run tasks automatically at specified times — recurring
|
||||
reports, monitoring, content generation, and time-based workflows.
|
||||
Learn how to use scheduled tasks, including creating, editing, and deleting
|
||||
them.
|
||||
tags:
|
||||
- LobeHub
|
||||
- CronJob
|
||||
- Scheduled Tasks
|
||||
- Automation
|
||||
- Task Scheduling
|
||||
- Create
|
||||
- Edit
|
||||
- Delete
|
||||
---
|
||||
|
||||
# Scheduled Tasks
|
||||
|
||||
Scheduled tasks are jobs that run periodically in the cloud. Configure an Agent to execute tasks based on your prompt at regular intervals — daily, weekly, or hourly. Instead of manually triggering the same workflow repeatedly, schedule it once and let it run automatically.
|
||||
|
||||
## What Are Scheduled Tasks?
|
||||
|
||||
A scheduled task is an automated agent run that:
|
||||
|
||||
- **Runs automatically**: Executes at your specified time without manual triggering
|
||||
- **Follows a schedule**: Daily, weekly, hourly, or custom patterns
|
||||
- **Maintains context**: Each run creates a conversation with full agent context
|
||||
- **Works while you're away**: Runs even when you're not logged in
|
||||
- **Sends notifications**: Alerts you when tasks complete (if configured)
|
||||
|
||||
## Why Use Scheduled Tasks?
|
||||
|
||||
### Recurring Tasks
|
||||
|
||||
Automate tasks that need to happen regularly:
|
||||
|
||||
- Daily market research summaries
|
||||
- Weekly competitive analysis reports
|
||||
- Monthly performance reviews
|
||||
- Hourly monitoring and alerts
|
||||
|
||||
### Time-Based Workflows
|
||||
|
||||
Execute tasks at optimal times:
|
||||
|
||||
- Generate reports first thing Monday morning
|
||||
- Send summaries at end of business day
|
||||
- Run analysis during off-peak hours
|
||||
|
||||
### Consistency and Reliability
|
||||
|
||||
- Never forget routine tasks
|
||||
- Maintain regular cadence for important workflows
|
||||
- Reduce manual overhead
|
||||
Scheduled tasks are jobs that run periodically in the cloud. In short, you can have an Agent run on your prompt on a schedule — for example, checking social media regularly and sending notifications. Instead of manually triggering the same workflow over and over, set it once and let it run automatically — daily, weekly, or hourly.
|
||||
|
||||
## Creating a Task
|
||||
|
||||
Find Scheduled Tasks in the left panel of the Agent conversation page, and click `Add Scheduled Task` to start creating a task.
|
||||
Find **Scheduled Tasks** in the left panel of the Agent conversation page, and click `Add Scheduled Task` to start creating a task.
|
||||
|
||||

|
||||

|
||||
|
||||
<Steps>
|
||||
### Select an Agent
|
||||
### Configuration fields
|
||||
|
||||
Navigate to the agent you want to schedule. Open the agent profile or settings panel.
|
||||
**Task name** — Give the task a descriptive name so you can recognize it at a glance:
|
||||
|
||||
### Access Scheduling
|
||||
- ✅ "Daily Market Summary - 9am"
|
||||
- ✅ "Weekly Competitor Analysis"
|
||||
- ❌ "Task 1"
|
||||
|
||||
Look for the **Scheduled Tasks** section and click **Add Scheduled Task**.
|
||||
**Task content** — Enter the prompt or instructions the Agent should run each time the task fires. Be specific and complete — this exact prompt runs on every scheduled execution. For example:
|
||||
|
||||
### Configure the Task
|
||||
```
|
||||
Analyze today's top tech news and summarize:
|
||||
1. Major product launches
|
||||
2. Funding announcements
|
||||
3. Industry trends
|
||||
Format as a brief executive summary.
|
||||
```
|
||||
|
||||
#### Task Name
|
||||
**Frequency** — Choose how often the task runs:
|
||||
|
||||
Give your task a descriptive name so you can identify it at a glance:
|
||||
- **Daily** — Every day at a specified time
|
||||
- **Weekly** — On selected weekdays at a specified time (you can pick multiple days)
|
||||
- **Hourly** — Every 1, 2, 6, 12, or 24 hours
|
||||
|
||||
- ✅ "Daily Market Summary - 9am EST"
|
||||
- ✅ "Weekly Competitor Analysis"
|
||||
- ❌ "Task 1"
|
||||
**Time and timezone** — Set the exact time and timezone so the task runs at the correct local time. Times use 24-hour format. For distributed teams, getting the timezone right matters.
|
||||
|
||||
#### Task Content
|
||||
**Max executions** — Optionally cap how many times the task runs in total. Ongoing tasks often need no limit; for time-boxed campaigns (e.g. 30 days), you might set 30 — the task disables itself after reaching the limit.
|
||||
|
||||
Enter the prompt or instructions for the Agent to execute each time the task runs. Be specific and complete — this exact prompt runs every scheduled execution:
|
||||
After you create a task, you can change its configuration at any time.
|
||||
|
||||
```
|
||||
Analyze today's top tech news and summarize:
|
||||
1. Major product launches
|
||||
2. Funding announcements
|
||||
3. Industry trends
|
||||
Format as a brief executive summary.
|
||||
```
|
||||
|
||||
#### Frequency
|
||||
|
||||
Choose how often the task runs:
|
||||
|
||||
- **Daily** — Every day at a specified time
|
||||
- **Weekly** — Specific days of the week at a specified time (you can select multiple days)
|
||||
- **Hourly** — Every 1, 2, 6, 12, or 24 hours
|
||||
|
||||
### Set the Time
|
||||
|
||||
Specify the exact time of day and your timezone so the task runs at the correct local time. Times are in 24-hour format.
|
||||
|
||||
For **weekly** schedules, select which days of the week to run. You can select multiple days (e.g., Monday, Wednesday, Friday).
|
||||
|
||||
For **hourly** schedules, set the interval and the minute when it runs.
|
||||
|
||||
### Configure Advanced Options
|
||||
|
||||
#### Timezone
|
||||
|
||||
Select your timezone so tasks run at the correct local time. Especially important for teams across multiple regions.
|
||||
|
||||
#### Max Executions
|
||||
|
||||
Optionally limit how many times the task runs total. Leave unlimited for ongoing tasks. Set a number (e.g., 30) for time-limited campaigns — the task disables automatically after reaching the limit.
|
||||
|
||||
### Save and Enable
|
||||
|
||||
Click **Save** to create the scheduled task. New tasks are typically enabled by default. After creation, you can modify the configuration at any time.
|
||||
</Steps>
|
||||
|
||||
## Schedule Configuration Examples
|
||||
## Schedule configuration examples
|
||||
|
||||
**Daily morning report:**
|
||||
|
||||
- Frequency: Daily at 08:00 in your timezone
|
||||
- Prompt: "Generate a summary of yesterday's key metrics and action items for today."
|
||||
- Prompt: "Summarize yesterday's key metrics and list today's priorities."
|
||||
|
||||
**Weekly planning session:**
|
||||
|
||||
|
|
@ -137,157 +71,67 @@ Find Scheduled Tasks in the left panel of the Agent conversation page, and click
|
|||
|
||||
**End-of-month review:**
|
||||
|
||||
- Frequency: Monthly — set Max Executions to 1 per month, or use day-of-month scheduling
|
||||
- Frequency: Monthly — set Max Executions to once per month, or combine with a specific day
|
||||
- Prompt: "Analyze this month's performance data and generate an executive report."
|
||||
|
||||
## Managing Tasks
|
||||
## Managing tasks
|
||||
|
||||
### Viewing Run History
|
||||
### Viewing run history
|
||||
|
||||
Each scheduled run creates a conversation in the agent's conversation history, labeled with the task name and timestamp. Review outputs, check for errors, and track results over time.
|
||||
Each scheduled run creates an entry in that Agent's conversation history, labeled with the task name and timestamp. You can review outputs, check for errors, and track past results.
|
||||
|
||||
### Editing a Schedule
|
||||
### Editing a schedule
|
||||
|
||||
Click on a scheduled task to modify it — update the prompt, change the frequency or time, or adjust the timezone. Changes take effect on the next scheduled execution.
|
||||
Click a scheduled task to edit it — update the prompt, change frequency or time, or adjust the timezone. Changes apply from the next scheduled run onward.
|
||||
|
||||
### Pausing a Task
|
||||
### Pausing a task
|
||||
|
||||
If you temporarily don't need a scheduled task, you can disable it. After disabling, the task will no longer execute automatically, but the task's execution plan and prompt configuration will be preserved. The task resumes after re-enabling.
|
||||
If you temporarily don't need a scheduled task, turn off its enabled state. While off, it won't run automatically; the schedule and prompt stay saved. When you turn it back on, the task continues as configured.
|
||||
|
||||

|
||||

|
||||
|
||||
### Deleting a Task
|
||||
### Deleting a task
|
||||
|
||||
If you no longer need a scheduled task, you can delete it. After deletion, the task's execution plan and prompt configuration are removed, and the system will no longer trigger any subsequent executions. Past conversation history is preserved.
|
||||
If you no longer need a scheduled task, you can delete it. Deletion removes the schedule and prompt configuration; the system will not trigger further runs. Past conversation history is kept.
|
||||
|
||||
## Use Cases
|
||||
## Best practices
|
||||
|
||||
<Tabs>
|
||||
<Tab title="News & Research">
|
||||
- **Daily tech news digest**: Summarize top stories every morning
|
||||
- **Competitor tracking**: Weekly analysis of competitor announcements
|
||||
- **Industry trends**: Monthly deep-dive into emerging trends
|
||||
- **Academic monitoring**: Track new papers in your field
|
||||
</Tab>
|
||||
|
||||
<Tab title="Content Generation">
|
||||
- **Social media drafts**: Daily post ideas based on current events
|
||||
- **Newsletter content**: Weekly roundup of relevant topics
|
||||
- **Blog post outlines**: Bi-weekly topic suggestions
|
||||
- **Report drafts**: Auto-generate periodic report templates
|
||||
</Tab>
|
||||
|
||||
<Tab title="Reporting & Analytics">
|
||||
- **Daily metrics summary**: KPI updates each morning
|
||||
- **Weekly performance review**: Analyze data and surface insights
|
||||
- **Monthly executive summary**: High-level overview for leadership
|
||||
- **Anomaly detection**: Flag unusual patterns in data
|
||||
</Tab>
|
||||
|
||||
<Tab title="Personal Productivity">
|
||||
- **Morning briefing**: Weather, calendar, priorities at 7am
|
||||
- **End-of-day review**: Summarize accomplishments at 5pm
|
||||
- **Weekly planning**: Sunday evening prep for the week ahead
|
||||
- **Reminder notifications**: Important milestones and check tasks
|
||||
</Tab>
|
||||
|
||||
<Tab title="Monitoring & Alerts">
|
||||
- **Hourly health checks**: Monitor systems or metrics
|
||||
- **Social media monitoring**: Track brand mentions and sentiment
|
||||
- **Price tracking**: Watch for changes in competitors or markets
|
||||
- **Security alerts**: High-frequency checks for critical issues
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## Best Practices
|
||||
|
||||
**Write clear, self-contained prompts** — The task prompt runs without any prior conversation context. Every detail the Agent needs must be in the prompt itself:
|
||||
**Write clear, self-contained prompts** — The scheduled task prompt runs with no prior conversation context. Everything the Agent needs must be in the prompt:
|
||||
|
||||
- ✅ "Search for news about electric vehicles published in the last 24 hours and summarize the top 3 developments."
|
||||
- ❌ "Check the news like we discussed." (Agent has no conversation context when scheduled)
|
||||
- ❌ "Check the news like we discussed." (The Agent has no access to earlier chats when the schedule runs.)
|
||||
|
||||
**Choose appropriate frequency** — Match the schedule to the actual cadence of the information you're monitoring. Hourly monitoring for daily news is unnecessary overhead; weekly reports for real-time metrics miss the point.
|
||||
**Choose appropriate frequency** — Match the schedule to how fast the information actually changes. Hourly checks for daily news add unnecessary load; weekly reports for real-time metrics miss important updates.
|
||||
|
||||
**Use descriptive task names** — Include the purpose and schedule in the name: "Weekly Competitor Analysis - Monday 9am" is far more useful than "Task 2".
|
||||
**Use descriptive task names** — Put purpose and timing in the name: "Weekly Competitor Analysis - Monday 9am" beats "Task 2".
|
||||
|
||||
**Set max executions for experiments** — When testing a new scheduled task, set a max execution count of 5–10 so it doesn't run indefinitely if the prompt doesn't work as expected.
|
||||
**Set max executions while experimenting** — When testing a new scheduled task, use a max execution count of 5–10 so it doesn't run forever if the prompt needs tuning.
|
||||
|
||||
**Timezone awareness** — Always set the correct timezone. A task scheduled for "9:00 AM" defaults to the server timezone, which may differ from your local time. Account for daylight saving time changes.
|
||||
**Timezone awareness** — Always set the correct timezone. "09:00" is interpreted in the configured timezone, which may differ from your local clock. Wrong timezone is a common cause of unexpected run times.
|
||||
|
||||
**Monitor results regularly** — Review scheduled run outputs to check if the agent is producing useful results and refine prompts based on actual outputs.
|
||||
## Use cases
|
||||
|
||||
## Advanced Scheduling
|
||||
### Regularly check social media and notify you
|
||||
|
||||
### Custom Cron Patterns
|
||||
Schedule a task to periodically check social content for given platforms or keywords. It can fetch recent activity, filter what matters, and summarize when there's something important — useful for brand monitoring, competitor tracking, or creator update alerts.
|
||||
|
||||
For advanced users, some interfaces support custom cron expressions:
|
||||
### Periodic summaries and reports
|
||||
|
||||
```
|
||||
0 9 * * 1-5 # Monday-Friday at 9:00am
|
||||
0 */6 * * * # Every 6 hours
|
||||
0 0 1 * * # First day of every month at midnight
|
||||
```
|
||||
For work that needs regular review — analytics, project status, or content performance — a scheduled task can gather information on a cadence and produce structured takeaways so you keep sight of trends.
|
||||
|
||||
### Chaining Scheduled Tasks
|
||||
### Timed reminders
|
||||
|
||||
Create workflows by scheduling multiple agents in sequence:
|
||||
|
||||
1. **Agent A** (8am): Gather data
|
||||
2. **Agent B** (9am): Analyze data from Agent A
|
||||
3. **Agent C** (10am): Generate report from Agent B's analysis
|
||||
|
||||
Coordinate timing so each task has inputs ready.
|
||||
|
||||
### Conditional Execution
|
||||
|
||||
Advanced setups may support conditions:
|
||||
|
||||
- Only run if certain criteria are met
|
||||
- Skip runs on holidays
|
||||
- Adjust frequency based on results
|
||||
|
||||
## Notifications and Integrations
|
||||
|
||||
Depending on your workspace configuration:
|
||||
|
||||
- **Email notifications**: Get alerts when runs complete
|
||||
- **Webhook integrations**: Send results to other tools
|
||||
- **Slack/Discord bots**: Post summaries to team channels
|
||||
- **Export options**: Download or share run outputs
|
||||
|
||||
Check your workspace settings for available integration options.
|
||||
Set reminders for milestones, recurring checks, or follow-ups. LobeHub can generate reminder messages and notify you (for example by email) without you triggering the flow manually.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Task Didn't Run at Expected Time">
|
||||
**Check if the task is enabled** — Disabled tasks won't execute. Toggle it back on if needed.
|
||||
**Task didn't run when expected** — Check the timezone. Scheduled times are relative to the configured timezone, not necessarily "now" on your device. Also confirm the task is enabled.
|
||||
|
||||
**Verify the schedule configuration** — Is the time correct in your timezone? For weekly schedules, are the right days selected? Has it reached max executions?
|
||||
**Runs at surprising times** — Double-check 24-hour time (e.g. 17:00 is 5:00 PM, not 5:00 AM).
|
||||
|
||||
**Check for errors** — Look at the conversation history for failed runs.
|
||||
</Accordion>
|
||||
**Poor output quality** — Scheduled prompts run without chat history. Rewrite the prompt so it is fully self-contained, with background, data sources, and format requirements spelled out.
|
||||
|
||||
<Accordion title="Unexpected Run Times">
|
||||
**Timezone mismatch** — Ensure the task timezone matches your expectations. Verify you haven't confused AM/PM in 24-hour format (e.g., 17:00 = 5:00 PM).
|
||||
|
||||
**Daylight Saving Time** — Some timezones shift with DST. Tasks may run an hour earlier/later after DST changes.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Poor Quality Outputs">
|
||||
**Refine your prompt** — Be more specific about what you want. Add examples of good outputs. Specify format and length.
|
||||
|
||||
**Wrong agent** — Ensure the agent is properly configured for the task and has necessary plugins or knowledge bases.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Too Many Runs">
|
||||
**Reduce frequency** — Change from hourly to daily, or daily to weekly.
|
||||
|
||||
**Set max executions** — Limit total runs to avoid runaway tasks.
|
||||
|
||||
**Disable temporarily** — Turn off the task while you reassess.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
**Too many runs** — While experimenting, set a **Max executions** cap. If a task has already run more than intended, delete it and create a new one with the right limits.
|
||||
|
||||
<Cards>
|
||||
<Card href={'/docs/usage/agent/web-search'} title={'Web Search'} />
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ tags:
|
|||
|
||||
在 Agent 会话页面左侧面板找到定时任务,点击 `添加定时任务` 开始创建任务。
|
||||
|
||||

|
||||

|
||||
|
||||
### 配置字段说明
|
||||
|
||||
|
|
@ -86,7 +86,7 @@ tags:
|
|||
|
||||
如果暂时不需要某个定时任务,可以关闭启用状态。关闭后,任务不再自动执行,执行计划和 Prompt 配置会保留。恢复启用后,该任务将继续执行。
|
||||
|
||||

|
||||

|
||||
|
||||
### 删除任务
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ tags:
|
|||
# Connect LobeHub to Discord
|
||||
|
||||
<Callout type={'info'}>
|
||||
This feature is currently in development and may not be fully stable. You can enable it by turning on **Developer Mode** in **Settings** → **Advanced Settings** → **Developer Mode**.
|
||||
This feature is currently in development and may not be fully stable. You can enable it by turning
|
||||
on **Developer Mode** in **Settings** → **Advanced Settings** → **Developer Mode**.
|
||||
</Callout>
|
||||
|
||||
By connecting a Discord channel to your LobeHub agent, users can interact with the AI assistant directly through Discord server channels and direct messages.
|
||||
|
|
@ -29,6 +30,8 @@ By connecting a Discord channel to your LobeHub agent, users can interact with t
|
|||
<Steps>
|
||||
### Go to the Discord Developer Portal
|
||||
|
||||

|
||||
|
||||
Visit the [Discord Developer Portal](https://discord.com/developers/applications) and click **New Application**. Give your application a name (e.g., "LobeHub Assistant") and click **Create**.
|
||||
|
||||
### Create a Bot
|
||||
|
|
@ -37,6 +40,8 @@ By connecting a Discord channel to your LobeHub agent, users can interact with t
|
|||
|
||||
### Enable Privileged Gateway Intents
|
||||
|
||||

|
||||
|
||||
On the Bot settings page, scroll down to **Privileged Gateway Intents** and enable:
|
||||
|
||||
- **Message Content Intent** — Required for the bot to read message content
|
||||
|
|
@ -47,12 +52,16 @@ By connecting a Discord channel to your LobeHub agent, users can interact with t
|
|||
|
||||
### Copy the Bot Token
|
||||
|
||||

|
||||
|
||||
On the **Bot** page, click **Reset Token** to generate your bot token. Copy and save it securely.
|
||||
|
||||
> **Important:** Treat your bot token like a password. Never share it publicly or commit it to version control.
|
||||
|
||||
### Copy the Application ID and Public Key
|
||||
|
||||

|
||||
|
||||
Go to **General Information** in the left sidebar. Copy and save:
|
||||
|
||||
- **Application ID**
|
||||
|
|
@ -70,6 +79,8 @@ By connecting a Discord channel to your LobeHub agent, users can interact with t
|
|||
|
||||
### Fill in the Credentials
|
||||
|
||||

|
||||
|
||||
Enter the following fields:
|
||||
|
||||
- **Application ID** — The Application ID from your Discord app's General Information page
|
||||
|
|
@ -88,6 +99,8 @@ By connecting a Discord channel to your LobeHub agent, users can interact with t
|
|||
<Steps>
|
||||
### Generate an Invite URL
|
||||
|
||||

|
||||
|
||||
In the Discord Developer Portal, go to **OAuth2** → **URL Generator**. Select the following scopes:
|
||||
|
||||
- `bot`
|
||||
|
|
@ -104,6 +117,8 @@ By connecting a Discord channel to your LobeHub agent, users can interact with t
|
|||
|
||||
### Authorize the Bot
|
||||
|
||||

|
||||
|
||||
Copy the generated URL, open it in your browser, select the server you want to add the bot to, and click **Authorize**.
|
||||
</Steps>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,45 +1,42 @@
|
|||
---
|
||||
title: Connect LobeHub to Feishu / Lark
|
||||
title: Connect LobeHub to Feishu (飞书)
|
||||
description: >-
|
||||
Learn how to create a Feishu (Lark) custom app and connect it to your LobeHub
|
||||
agent as a message channel, enabling your AI assistant to interact with team
|
||||
members in Feishu or Lark chats.
|
||||
Learn how to create a Feishu custom app and connect it to your LobeHub agent
|
||||
as a message channel, enabling your AI assistant to interact with team members
|
||||
in Feishu chats.
|
||||
tags:
|
||||
- Feishu
|
||||
- Lark
|
||||
- 飞书
|
||||
- Message Channels
|
||||
- Bot Setup
|
||||
- Integration
|
||||
---
|
||||
|
||||
# Connect LobeHub to Feishu / Lark
|
||||
# Connect LobeHub to Feishu (飞书)
|
||||
|
||||
<Callout type={'info'}>
|
||||
This feature is currently in development and may not be fully stable. You can enable it by turning on **Developer Mode** in **Settings** → **Advanced Settings** → **Developer Mode**.
|
||||
</Callout>
|
||||
|
||||
By connecting a Feishu (or Lark) channel to your LobeHub agent, team members can interact with the AI assistant directly in Feishu private chats and group conversations.
|
||||
By connecting a Feishu channel to your LobeHub agent, team members can interact with the AI assistant directly in Feishu private chats and group conversations.
|
||||
|
||||
> Feishu is the Chinese version, and Lark is the international version. The setup process is identical — just use the corresponding platform portal.
|
||||
> If you are using the international version (Lark), please refer to the [Lark setup guide](/docs/usage/channels/lark).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A LobeHub account with an active subscription
|
||||
- A Feishu or Lark account with permissions to create enterprise apps
|
||||
- A Feishu account with permissions to create enterprise apps
|
||||
|
||||
## Step 1: Create a Feishu / Lark App
|
||||
## Step 1: Create a Feishu App
|
||||
|
||||
<Steps>
|
||||
### Open the Developer Portal
|
||||
|
||||
- **Feishu:** Visit [open.feishu.cn/app](https://open.feishu.cn/app)
|
||||
- **Lark:** Visit [open.larksuite.com/app](https://open.larksuite.com/app)
|
||||
|
||||
Sign in with your account.
|
||||
Visit [open.feishu.cn/app](https://open.feishu.cn/app) and sign in with your account.
|
||||
|
||||
### Create an Enterprise App
|
||||
|
||||
Click **Create Enterprise App**. Fill in the app name (e.g., "LobeHub Assistant"), description, and icon, then submit the form.
|
||||
Click **Create Enterprise App**. Fill in the app name (e.g., "LobeHub 助手"), description, and icon, then submit the form.
|
||||
|
||||
### Copy App Credentials
|
||||
|
||||
|
|
@ -90,28 +87,24 @@ By connecting a Feishu (or Lark) channel to your LobeHub agent, team members can
|
|||
}
|
||||
```
|
||||
|
||||
<Callout type={'warning'}>
|
||||
The JSON above is for **Feishu (飞书)**. If you are using **Lark (international)**, some scopes may not be available (e.g. `aily:*`, `corehr:*`, `im:chat.access_event.bot_p2p_chat:read`). Remove any scopes that the batch import rejects.
|
||||
</Callout>
|
||||
|
||||
### Enable Bot Capability
|
||||
|
||||
Go to **App Capability** → **Bot**. Toggle the bot capability on and set your preferred bot name.
|
||||
</Steps>
|
||||
|
||||
## Step 3: Configure Feishu / Lark in LobeHub
|
||||
## Step 3: Configure Feishu in LobeHub
|
||||
|
||||
<Steps>
|
||||
### Open Channel Settings
|
||||
|
||||
In LobeHub, navigate to your agent's settings, then select the **Channels** tab. Click **飞书** (Feishu) or **Lark** from the platform list.
|
||||
In LobeHub, navigate to your agent's settings, then select the **Channels** tab. Click **飞书** (Feishu) from the platform list.
|
||||
|
||||
### Fill in App Credentials
|
||||
|
||||
Enter the following fields:
|
||||
|
||||
- **App ID** — The App ID from your Feishu/Lark app
|
||||
- **App Secret** — The App Secret from your Feishu/Lark app
|
||||
- **App ID** — The App ID from your Feishu app
|
||||
- **App Secret** — The App Secret from your Feishu app
|
||||
|
||||
> You don't need to fill in **Verification Token** or **Encrypt Key** at this point — you can set them up after configuring the Event Subscription in Step 4.
|
||||
|
||||
|
|
@ -120,12 +113,12 @@ By connecting a Feishu (or Lark) channel to your LobeHub agent, team members can
|
|||
Click **Save Configuration**. After saving, an **Event Subscription URL** will be displayed. Copy this URL — you will need it in the next step.
|
||||
</Steps>
|
||||
|
||||
## Step 4: Set Up Event Subscription in Feishu / Lark
|
||||
## Step 4: Set Up Event Subscription in Feishu
|
||||
|
||||
<Steps>
|
||||
### Open Event Subscription Settings
|
||||
|
||||
Go back to your app in the Feishu/Lark Developer Portal. Navigate to **Event Subscription**.
|
||||
Go back to your app in the Feishu Developer Portal. Navigate to **Event Subscription**.
|
||||
|
||||
### Configure the Request URL
|
||||
|
||||
|
|
@ -145,7 +138,7 @@ By connecting a Feishu (or Lark) channel to your LobeHub agent, team members can
|
|||
|
||||
Go back to LobeHub's channel settings and fill in:
|
||||
|
||||
- **Verification Token** — Used to verify that webhook events originate from Feishu/Lark
|
||||
- **Verification Token** — Used to verify that webhook events originate from Feishu
|
||||
- **Encrypt Key** (optional) — Used to decrypt encrypted event payloads
|
||||
|
||||
Click **Save Configuration** again to apply.
|
||||
|
|
@ -165,21 +158,21 @@ By connecting a Feishu (or Lark) channel to your LobeHub agent, team members can
|
|||
|
||||
## Step 6: Test the Connection
|
||||
|
||||
Back in LobeHub's channel settings, click **Test Connection** to verify the credentials. Then find your bot in Feishu/Lark by searching its name and send it a message to confirm it responds.
|
||||
Back in LobeHub's channel settings, click **Test Connection** to verify the credentials. Then find your bot in Feishu by searching its name and send it a message to confirm it responds.
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
| Field | Required | Description |
|
||||
| -------------------------- | -------- | -------------------------------------------------------------------- |
|
||||
| **App ID** | Yes | Your Feishu/Lark app's App ID (`cli_xxx`) |
|
||||
| **App Secret** | Yes | Your Feishu/Lark app's App Secret |
|
||||
| **Verification Token** | No | Verifies webhook event source (recommended) |
|
||||
| **Encrypt Key** | No | Decrypts encrypted event payloads |
|
||||
| **Event Subscription URL** | — | Auto-generated after saving; paste into Feishu/Lark Developer Portal |
|
||||
| Field | Required | Description |
|
||||
| -------------------------- | -------- | --------------------------------------------------------------- |
|
||||
| **App ID** | Yes | Your Feishu app's App ID (`cli_xxx`) |
|
||||
| **App Secret** | Yes | Your Feishu app's App Secret |
|
||||
| **Verification Token** | No | Verifies webhook event source (recommended) |
|
||||
| **Encrypt Key** | No | Decrypts encrypted event payloads |
|
||||
| **Event Subscription URL** | — | Auto-generated after saving; paste into Feishu Developer Portal |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Event Subscription URL verification failed:** Ensure you saved the configuration in LobeHub first, and the URL was copied correctly.
|
||||
- **Bot not responding:** Verify the app is published and approved, the bot capability is enabled, and the `im.message.receive_v1` event is subscribed.
|
||||
- **Permission errors:** Confirm all required permissions are added and approved in the Developer Portal.
|
||||
- **Test Connection failed:** Double-check the App ID and App Secret. For Lark, ensure you selected "Lark" (not "飞书") in LobeHub's channel settings.
|
||||
- **Test Connection failed:** Double-check the App ID and App Secret.
|
||||
|
|
|
|||
|
|
@ -1,39 +1,35 @@
|
|||
---
|
||||
title: 将 LobeHub 连接到飞书 / Lark
|
||||
description: 了解如何创建飞书(Lark)自定义应用并将其连接到您的 LobeHub 代理作为消息渠道,使您的 AI 助手能够在飞书或 Lark 聊天中与团队成员互动。
|
||||
title: 将 LobeHub 连接到飞书
|
||||
description: 了解如何创建飞书自定义应用并将其连接到您的 LobeHub 代理作为消息渠道,使您的 AI 助手能够在飞书聊天中与团队成员互动。
|
||||
tags:
|
||||
- 飞书
|
||||
- Lark
|
||||
- 消息渠道
|
||||
- 机器人设置
|
||||
- 集成
|
||||
---
|
||||
|
||||
# 将 LobeHub 连接到飞书 / Lark
|
||||
# 将 LobeHub 连接到飞书
|
||||
|
||||
<Callout type={'info'}>
|
||||
此功能目前正在开发中,可能尚未完全稳定。您可以通过在 **设置** → **高级设置** → **开发者模式**
|
||||
中启用 **开发者模式** 来使用此功能。
|
||||
</Callout>
|
||||
|
||||
通过将飞书(或 Lark)渠道连接到您的 LobeHub 代理,团队成员可以直接在飞书的私聊和群组对话中与 AI 助手互动。
|
||||
通过将飞书渠道连接到您的 LobeHub 代理,团队成员可以直接在飞书的私聊和群组对话中与 AI 助手互动。
|
||||
|
||||
> 飞书是中国版本,Lark 是国际版本。设置过程完全相同 —— 只需使用对应的平台门户即可。
|
||||
> 如果您使用的是国际版(Lark),请参阅 [Lark 设置指南](/docs/usage/channels/lark)。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 一个拥有有效订阅的 LobeHub 账户
|
||||
- 一个拥有创建企业应用权限的飞书或 Lark 账户
|
||||
- 一个拥有创建企业应用权限的飞书账户
|
||||
|
||||
## 第一步:创建飞书 / Lark 应用
|
||||
## 第一步:创建飞书应用
|
||||
|
||||
<Steps>
|
||||
### 打开开发者门户
|
||||
|
||||
- **飞书:** 访问 [open.feishu.cn/app](https://open.feishu.cn/app)
|
||||
- **Lark:** 访问 [open.larksuite.com/app](https://open.larksuite.com/app)
|
||||
|
||||
使用您的账户登录。
|
||||
访问 [open.feishu.cn/app](https://open.feishu.cn/app) 并使用您的账户登录。
|
||||
|
||||
### 创建企业应用
|
||||
|
||||
|
|
@ -88,47 +84,38 @@ tags:
|
|||
}
|
||||
```
|
||||
|
||||
<Callout type={'warning'}>
|
||||
以上 JSON 适用于**飞书**。如果您使用的是 **Lark(国际版)**,部分权限码可能不可用(如 `aily:*`、`corehr:*`、`im:chat.access_event.bot_p2p_chat:read`)。请移除批量导入时提示无效的权限码。
|
||||
</Callout>
|
||||
|
||||
### 启用机器人功能
|
||||
|
||||
进入 **应用能力** → **机器人**。开启机器人功能并设置您喜欢的机器人名称。
|
||||
</Steps>
|
||||
|
||||
## 第三步:在 LobeHub 中配置飞书 / Lark
|
||||
## 第三步:在 LobeHub 中配置飞书
|
||||
|
||||
<Steps>
|
||||
### 打开渠道设置
|
||||
|
||||
在 LobeHub 中,导航到您的代理设置,然后选择 **渠道** 标签。点击平台列表中的 **飞书** 或 **Lark**。
|
||||
在 LobeHub 中,导航到您的代理设置,然后选择 **渠道** 标签。点击平台列表中的 **飞书**。
|
||||
|
||||
### 填写应用凭证
|
||||
|
||||
输入以下字段:
|
||||
|
||||
- **应用 ID** — 来自飞书 / Lark 应用的应用 ID
|
||||
- **应用密钥** — 来自飞书 / Lark 应用的应用密钥
|
||||
- **Verification Token** — 用于验证 webhook 事件是否来自飞书 / Lark
|
||||
- **应用 ID** — 来自飞书应用的应用 ID
|
||||
- **应用密钥** — 来自飞书应用的应用密钥
|
||||
|
||||
您还可以选择配置以下内容:
|
||||
|
||||
- **Encrypt Key** — 用于解密飞书 / Lark 的加密事件负载
|
||||
|
||||
> Verification Token 和 Encrypt Key 可以在飞书 / Lark 开发者门户的 **事件订阅** → **加密策略** 中找到(位于页面顶部)。如果您还没有打开过事件订阅页面,可以在完成第四步后再回来填写 Verification Token。
|
||||
> 此时您不需要填写 **Verification Token** 或 **Encrypt Key** —— 可以在完成第四步配置事件订阅后再设置。
|
||||
|
||||
### 保存并复制 Webhook URL
|
||||
|
||||
点击 **保存配置**。保存后,将显示一个 **事件订阅 URL**。复制此 URL—— 您将在下一步中需要它。
|
||||
</Steps>
|
||||
|
||||
## 第四步:在飞书 / Lark 中设置事件订阅
|
||||
## 第四步:在飞书中设置事件订阅
|
||||
|
||||
<Steps>
|
||||
### 打开事件订阅设置
|
||||
|
||||
返回飞书 / Lark 开发者门户中的应用。导航到 **事件订阅**。
|
||||
返回飞书开发者门户中的应用。导航到 **事件订阅**。
|
||||
|
||||
### 配置请求 URL
|
||||
|
||||
|
|
@ -141,6 +128,17 @@ tags:
|
|||
- `im.message.receive_v1` — 当收到消息时触发
|
||||
|
||||
这将使您的应用能够接收消息并将其转发到 LobeHub。
|
||||
|
||||
### (推荐)填写 Verification Token 和 Encrypt Key
|
||||
|
||||
配置事件订阅后,您可以在事件订阅页面顶部的 **加密策略** 中找到 **Verification Token** 和 **Encrypt Key**。
|
||||
|
||||
返回 LobeHub 的渠道设置,填写:
|
||||
|
||||
- **Verification Token** — 用于验证 webhook 事件是否来自飞书
|
||||
- **Encrypt Key**(可选)— 用于解密加密事件负载
|
||||
|
||||
再次点击 **保存配置** 以应用。
|
||||
</Steps>
|
||||
|
||||
## 第五步:发布应用
|
||||
|
|
@ -157,21 +155,21 @@ tags:
|
|||
|
||||
## 第六步:测试连接
|
||||
|
||||
回到 LobeHub 的渠道设置,点击 **测试连接** 以验证凭证。然后在飞书 / Lark 中搜索您的机器人名称并发送消息,确认其是否响应。
|
||||
回到 LobeHub 的渠道设置,点击 **测试连接** 以验证凭证。然后在飞书中搜索您的机器人名称并发送消息,确认其是否响应。
|
||||
|
||||
## 配置参考
|
||||
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| ---------------------- | ---- | ------------------------------- |
|
||||
| **应用 ID** | 是 | 您的飞书 / Lark 应用的应用 ID(`cli_xxx`) |
|
||||
| **应用密钥** | 是 | 您的飞书 / Lark 应用的应用密钥 |
|
||||
| **Verification Token** | 是 | 验证 webhook 事件来源 |
|
||||
| **Encrypt Key** | 否 | 解密加密事件负载 |
|
||||
| **事件订阅 URL** | — | 保存后自动生成;粘贴到飞书 / Lark 开发者门户 |
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| ---------------------- | ---- | ----------------------- |
|
||||
| **应用 ID** | 是 | 您的飞书应用的应用 ID(`cli_xxx`) |
|
||||
| **应用密钥** | 是 | 您的飞书应用的应用密钥 |
|
||||
| **Verification Token** | 否 | 验证 webhook 事件来源(推荐) |
|
||||
| **Encrypt Key** | 否 | 解密加密事件负载 |
|
||||
| **事件订阅 URL** | — | 保存后自动生成;粘贴到飞书开发者门户 |
|
||||
|
||||
## 故障排除
|
||||
|
||||
- **事件订阅 URL 验证失败:** 确保您已在 LobeHub 中保存配置,并正确复制了 URL。
|
||||
- **机器人未响应:** 验证应用已发布并获得批准,机器人功能已启用,并订阅了 `im.message.receive_v1` 事件。
|
||||
- **权限错误:** 确保所有所需权限已在开发者门户中添加并获得批准。
|
||||
- **测试连接失败:** 仔细检查应用 ID 和应用密钥。对于 Lark,请确保您在 LobeHub 的渠道设置中选择了 "Lark"(而不是 "飞书")。
|
||||
- **测试连接失败:** 仔细检查应用 ID 和应用密钥。
|
||||
|
|
|
|||
173
docs/usage/channels/lark.mdx
Normal file
173
docs/usage/channels/lark.mdx
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
---
|
||||
title: Connect LobeHub to Lark
|
||||
description: >-
|
||||
Learn how to create a Lark custom app and connect it to your LobeHub agent as
|
||||
a message channel, enabling your AI assistant to interact with team members in
|
||||
Lark chats.
|
||||
tags:
|
||||
- Lark
|
||||
- Message Channels
|
||||
- Bot Setup
|
||||
- Integration
|
||||
---
|
||||
|
||||
# Connect LobeHub to Lark
|
||||
|
||||
<Callout type={'info'}>
|
||||
This feature is currently in development and may not be fully stable. You can enable it by turning on **Developer Mode** in **Settings** → **Advanced Settings** → **Developer Mode**.
|
||||
</Callout>
|
||||
|
||||
By connecting a Lark channel to your LobeHub agent, team members can interact with the AI assistant directly in Lark private chats and group conversations.
|
||||
|
||||
> If you are using the Chinese version (飞书), please refer to the [Feishu setup guide](/docs/usage/channels/feishu).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A LobeHub account with an active subscription
|
||||
- A Lark account with permissions to create enterprise apps
|
||||
|
||||
## Step 1: Create a Lark App
|
||||
|
||||
<Steps>
|
||||
### Open the Developer Portal
|
||||
|
||||
Visit [open.larksuite.com/app](https://open.larksuite.com/app) and sign in with your account.
|
||||
|
||||
### Create an Enterprise App
|
||||
|
||||
Click **Create Enterprise App**. Fill in the app name (e.g., "LobeHub Assistant"), description, and icon, then submit the form.
|
||||
|
||||
### Copy App Credentials
|
||||
|
||||
Go to **Credentials & Basic Info** and copy:
|
||||
|
||||
- **App ID** (format: `cli_xxx`)
|
||||
- **App Secret**
|
||||
|
||||
> **Important:** Keep your App Secret confidential. Never share it publicly.
|
||||
</Steps>
|
||||
|
||||
## Step 2: Configure App Permissions and Bot
|
||||
|
||||
<Steps>
|
||||
### Import Required Permissions
|
||||
|
||||
In your app settings, go to **Permissions & Scopes**, click **Batch Import**, and paste the JSON below to grant the bot all necessary permissions.
|
||||
|
||||
```json
|
||||
{
|
||||
"scopes": {
|
||||
"tenant": [
|
||||
"application:application.app_message_stats.overview:readonly",
|
||||
"application:application:self_manage",
|
||||
"application:bot.menu:write",
|
||||
"cardkit:card:read",
|
||||
"cardkit:card:write",
|
||||
"contact:user.employee_id:readonly",
|
||||
"event:ip_list",
|
||||
"im:chat.members:bot_access",
|
||||
"im:message",
|
||||
"im:message.group_at_msg:readonly",
|
||||
"im:message.p2p_msg:readonly",
|
||||
"im:message:readonly",
|
||||
"im:message:send_as_bot",
|
||||
"im:resource"
|
||||
],
|
||||
"user": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<Callout type={'info'}>
|
||||
The scopes above are tailored for Lark (international). Some Feishu-specific scopes (e.g. `aily:*`, `corehr:*`, `im:chat.access_event.bot_p2p_chat:read`) are not available on Lark and have been excluded.
|
||||
</Callout>
|
||||
|
||||
### Enable Bot Capability
|
||||
|
||||
Go to **App Capability** → **Bot**. Toggle the bot capability on and set your preferred bot name.
|
||||
</Steps>
|
||||
|
||||
## Step 3: Configure Lark in LobeHub
|
||||
|
||||
<Steps>
|
||||
### Open Channel Settings
|
||||
|
||||
In LobeHub, navigate to your agent's settings, then select the **Channels** tab. Click **Lark** from the platform list.
|
||||
|
||||
### Fill in App Credentials
|
||||
|
||||
Enter the following fields:
|
||||
|
||||
- **App ID** — The App ID from your Lark app
|
||||
- **App Secret** — The App Secret from your Lark app
|
||||
|
||||
> You don't need to fill in **Verification Token** or **Encrypt Key** at this point — you can set them up after configuring the Event Subscription in Step 4.
|
||||
|
||||
### Save and Copy the Webhook URL
|
||||
|
||||
Click **Save Configuration**. After saving, an **Event Subscription URL** will be displayed. Copy this URL — you will need it in the next step.
|
||||
</Steps>
|
||||
|
||||
## Step 4: Set Up Event Subscription in Lark
|
||||
|
||||
<Steps>
|
||||
### Open Event Subscription Settings
|
||||
|
||||
Go back to your app in the Lark Developer Portal. Navigate to **Event Subscription**.
|
||||
|
||||
### Configure the Request URL
|
||||
|
||||
Paste the **Event Subscription URL** you copied from LobeHub into the **Request URL** field. The platform will verify the endpoint automatically.
|
||||
|
||||
### Add the Message Event
|
||||
|
||||
Add the following event:
|
||||
|
||||
- `im.message.receive_v1` — Triggered when a message is received
|
||||
|
||||
This allows your app to receive messages and forward them to LobeHub.
|
||||
|
||||
### (Recommended) Fill in Verification Token and Encrypt Key
|
||||
|
||||
After configuring Event Subscription, you can find the **Verification Token** and **Encrypt Key** at the top of the Event Subscription page under **Encryption Strategy**.
|
||||
|
||||
Go back to LobeHub's channel settings and fill in:
|
||||
|
||||
- **Verification Token** — Used to verify that webhook events originate from Lark
|
||||
- **Encrypt Key** (optional) — Used to decrypt encrypted event payloads
|
||||
|
||||
Click **Save Configuration** again to apply.
|
||||
</Steps>
|
||||
|
||||
## Step 5: Publish the App
|
||||
|
||||
<Steps>
|
||||
### Create a Version
|
||||
|
||||
In your app settings, go to **Version Management & Release**. Create a new version with release notes.
|
||||
|
||||
### Submit for Review
|
||||
|
||||
Submit the version for review and publish. For enterprise self-managed apps, approval is typically automatic.
|
||||
</Steps>
|
||||
|
||||
## Step 6: Test the Connection
|
||||
|
||||
Back in LobeHub's channel settings, click **Test Connection** to verify the credentials. Then find your bot in Lark by searching its name and send it a message to confirm it responds.
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
| Field | Required | Description |
|
||||
| -------------------------- | -------- | ------------------------------------------------------------- |
|
||||
| **App ID** | Yes | Your Lark app's App ID (`cli_xxx`) |
|
||||
| **App Secret** | Yes | Your Lark app's App Secret |
|
||||
| **Verification Token** | No | Verifies webhook event source (recommended) |
|
||||
| **Encrypt Key** | No | Decrypts encrypted event payloads |
|
||||
| **Event Subscription URL** | — | Auto-generated after saving; paste into Lark Developer Portal |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Event Subscription URL verification failed:** Ensure you saved the configuration in LobeHub first, and the URL was copied correctly.
|
||||
- **Bot not responding:** Verify the app is published and approved, the bot capability is enabled, and the `im.message.receive_v1` event is subscribed.
|
||||
- **Permission errors:** Confirm all required permissions are added and approved in the Developer Portal.
|
||||
- **Test Connection failed:** Double-check the App ID and App Secret. Make sure you selected "Lark" (not "飞书") in LobeHub's channel settings.
|
||||
171
docs/usage/channels/lark.zh-CN.mdx
Normal file
171
docs/usage/channels/lark.zh-CN.mdx
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
---
|
||||
title: 将 LobeHub 连接到 Lark
|
||||
description: 了解如何创建 Lark 自定义应用并将其连接到您的 LobeHub 代理作为消息渠道,使您的 AI 助手能够在 Lark 聊天中与团队成员互动。
|
||||
tags:
|
||||
- Lark
|
||||
- 消息渠道
|
||||
- 机器人设置
|
||||
- 集成
|
||||
---
|
||||
|
||||
# 将 LobeHub 连接到 Lark
|
||||
|
||||
<Callout type={'info'}>
|
||||
此功能目前正在开发中,可能尚未完全稳定。您可以通过在 **设置** → **高级设置** → **开发者模式**
|
||||
中启用 **开发者模式** 来使用此功能。
|
||||
</Callout>
|
||||
|
||||
通过将 Lark 渠道连接到您的 LobeHub 代理,团队成员可以直接在 Lark 的私聊和群组对话中与 AI 助手互动。
|
||||
|
||||
> 如果您使用的是中国版(飞书),请参阅[飞书设置指南](/docs/usage/channels/feishu)。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 一个拥有有效订阅的 LobeHub 账户
|
||||
- 一个拥有创建企业应用权限的 Lark 账户
|
||||
|
||||
## 第一步:创建 Lark 应用
|
||||
|
||||
<Steps>
|
||||
### 打开开发者门户
|
||||
|
||||
访问 [open.larksuite.com/app](https://open.larksuite.com/app) 并使用您的账户登录。
|
||||
|
||||
### 创建企业应用
|
||||
|
||||
点击 **Create Enterprise App**。填写应用名称(例如 "LobeHub Assistant")、描述和图标,然后提交表单。
|
||||
|
||||
### 复制应用凭证
|
||||
|
||||
进入 **Credentials & Basic Info**,复制以下内容:
|
||||
|
||||
- **App ID**(格式:`cli_xxx`)
|
||||
- **App Secret**
|
||||
|
||||
> **重要提示:** 请妥善保管您的 App Secret。切勿公开分享。
|
||||
</Steps>
|
||||
|
||||
## 第二步:配置应用权限和机器人功能
|
||||
|
||||
<Steps>
|
||||
### 导入所需权限
|
||||
|
||||
在您的应用设置中,进入 **Permissions & Scopes**,点击 **Batch Import**,然后粘贴以下 JSON 以授予机器人所需的所有权限。
|
||||
|
||||
```json
|
||||
{
|
||||
"scopes": {
|
||||
"tenant": [
|
||||
"application:application.app_message_stats.overview:readonly",
|
||||
"application:application:self_manage",
|
||||
"application:bot.menu:write",
|
||||
"cardkit:card:read",
|
||||
"cardkit:card:write",
|
||||
"contact:user.employee_id:readonly",
|
||||
"event:ip_list",
|
||||
"im:chat.members:bot_access",
|
||||
"im:message",
|
||||
"im:message.group_at_msg:readonly",
|
||||
"im:message.p2p_msg:readonly",
|
||||
"im:message:readonly",
|
||||
"im:message:send_as_bot",
|
||||
"im:resource"
|
||||
],
|
||||
"user": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<Callout type={'info'}>
|
||||
以上权限码已针对 Lark(国际版)进行调整。部分飞书特有的权限码(如 `aily:*`、`corehr:*`、`im:chat.access_event.bot_p2p_chat:read`)在 Lark 上不可用,已被排除。
|
||||
</Callout>
|
||||
|
||||
### 启用机器人功能
|
||||
|
||||
进入 **App Capability** → **Bot**。开启机器人功能并设置您喜欢的机器人名称。
|
||||
</Steps>
|
||||
|
||||
## 第三步:在 LobeHub 中配置 Lark
|
||||
|
||||
<Steps>
|
||||
### 打开渠道设置
|
||||
|
||||
在 LobeHub 中,导航到您的代理设置,然后选择 **渠道** 标签。点击平台列表中的 **Lark**。
|
||||
|
||||
### 填写应用凭证
|
||||
|
||||
输入以下字段:
|
||||
|
||||
- **App ID** — 来自 Lark 应用的 App ID
|
||||
- **App Secret** — 来自 Lark 应用的 App Secret
|
||||
|
||||
> 此时您不需要填写 **Verification Token** 或 **Encrypt Key** —— 可以在完成第四步配置事件订阅后再设置。
|
||||
|
||||
### 保存并复制 Webhook URL
|
||||
|
||||
点击 **Save Configuration**。保存后,将显示一个 **Event Subscription URL**。复制此 URL —— 您将在下一步中需要它。
|
||||
</Steps>
|
||||
|
||||
## 第四步:在 Lark 中设置事件订阅
|
||||
|
||||
<Steps>
|
||||
### 打开事件订阅设置
|
||||
|
||||
返回 Lark 开发者门户中的应用。导航到 **Event Subscription**。
|
||||
|
||||
### 配置请求 URL
|
||||
|
||||
将您从 LobeHub 复制的 **Event Subscription URL** 粘贴到 **Request URL** 字段中。平台会自动验证端点。
|
||||
|
||||
### 添加消息事件
|
||||
|
||||
添加以下事件:
|
||||
|
||||
- `im.message.receive_v1` — 当收到消息时触发
|
||||
|
||||
这将使您的应用能够接收消息并将其转发到 LobeHub。
|
||||
|
||||
### (推荐)填写 Verification Token 和 Encrypt Key
|
||||
|
||||
配置事件订阅后,您可以在事件订阅页面顶部的 **Encryption Strategy** 中找到 **Verification Token** 和 **Encrypt Key**。
|
||||
|
||||
返回 LobeHub 的渠道设置,填写:
|
||||
|
||||
- **Verification Token** — 用于验证 webhook 事件是否来自 Lark
|
||||
- **Encrypt Key**(可选)— 用于解密加密事件负载
|
||||
|
||||
再次点击 **Save Configuration** 以应用。
|
||||
</Steps>
|
||||
|
||||
## 第五步:发布应用
|
||||
|
||||
<Steps>
|
||||
### 创建版本
|
||||
|
||||
在您的应用设置中,进入 **Version Management & Release**。创建一个新版本并填写发布说明。
|
||||
|
||||
### 提交审核
|
||||
|
||||
提交版本进行审核并发布。对于企业自管理应用,通常会自动批准。
|
||||
</Steps>
|
||||
|
||||
## 第六步:测试连接
|
||||
|
||||
回到 LobeHub 的渠道设置,点击 **Test Connection** 以验证凭证。然后在 Lark 中搜索您的机器人名称并发送消息,确认其是否响应。
|
||||
|
||||
## 配置参考
|
||||
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| -------------------------- | ---- | ----------------------------- |
|
||||
| **App ID** | 是 | 您的 Lark 应用的 App ID(`cli_xxx`) |
|
||||
| **App Secret** | 是 | 您的 Lark 应用的 App Secret |
|
||||
| **Verification Token** | 否 | 验证 webhook 事件来源(推荐) |
|
||||
| **Encrypt Key** | 否 | 解密加密事件负载 |
|
||||
| **Event Subscription URL** | — | 保存后自动生成;粘贴到 Lark 开发者门户 |
|
||||
|
||||
## 故障排除
|
||||
|
||||
- **Event Subscription URL 验证失败:** 确保您已在 LobeHub 中保存配置,并正确复制了 URL。
|
||||
- **机器人未响应:** 验证应用已发布并获得批准,机器人功能已启用,并订阅了 `im.message.receive_v1` 事件。
|
||||
- **权限错误:** 确保所有所需权限已在开发者门户中添加并获得批准。
|
||||
- **测试连接失败:** 仔细检查 App ID 和 App Secret。确保您在 LobeHub 的渠道设置中选择了 "Lark"(而不是 "飞书")。
|
||||
|
|
@ -2,14 +2,17 @@
|
|||
title: Channels Overview
|
||||
description: >-
|
||||
Connect your LobeHub agents to external messaging platforms like Discord,
|
||||
Telegram, and Feishu/Lark, allowing users to interact with AI assistants
|
||||
directly in their favorite chat apps.
|
||||
Slack, Telegram, QQ, WeChat, Feishu, and Lark, allowing users to interact with
|
||||
AI assistants directly in their favorite chat apps.
|
||||
tags:
|
||||
- Channels
|
||||
- Message Channels
|
||||
- Integration
|
||||
- Discord
|
||||
- Slack
|
||||
- Telegram
|
||||
- QQ
|
||||
- WeChat
|
||||
- Feishu
|
||||
- Lark
|
||||
---
|
||||
|
|
@ -24,18 +27,22 @@ Channels allow you to connect your LobeHub agents to external messaging platform
|
|||
|
||||
## Supported Platforms
|
||||
|
||||
| Platform | Description |
|
||||
| -------------------------------------------- | --------------------------------------------------------------- |
|
||||
| [Discord](/docs/usage/channels/discord) | Connect to Discord servers for channel chat and direct messages |
|
||||
| [Telegram](/docs/usage/channels/telegram) | Connect to Telegram for private and group conversations |
|
||||
| [Feishu / Lark](/docs/usage/channels/feishu) | Connect to Feishu (飞书) or Lark for team collaboration |
|
||||
| Platform | Description |
|
||||
| ------------------------------------------ | --------------------------------------------------------------- |
|
||||
| [Discord](/docs/usage/channels/discord) | Connect to Discord servers for channel chat and direct messages |
|
||||
| [Slack](/docs/usage/channels/slack) | Connect to Slack for channel and direct message conversations |
|
||||
| [Telegram](/docs/usage/channels/telegram) | Connect to Telegram for private and group conversations |
|
||||
| [QQ](/docs/usage/channels/qq) | Connect to QQ for group chats and direct messages |
|
||||
| [WeChat (微信)](/docs/usage/channels/wechat) | Connect to WeChat via iLink Bot for private and group chats |
|
||||
| [Feishu (飞书)](/docs/usage/channels/feishu) | Connect to Feishu for team collaboration (Chinese version) |
|
||||
| [Lark](/docs/usage/channels/lark) | Connect to Lark for team collaboration (international version) |
|
||||
|
||||
## How It Works
|
||||
|
||||
Each channel integration works by linking a bot account on the target platform to a LobeHub agent. When a user sends a message to the bot, LobeHub processes it through the agent and sends the response back to the same conversation.
|
||||
|
||||
- **Per-agent configuration** — Each agent can have its own set of channel connections, so different agents can serve different platforms or communities.
|
||||
- **Multiple channels simultaneously** — A single agent can be connected to Discord, Telegram, and Feishu/Lark at the same time. LobeHub routes messages to the correct agent automatically.
|
||||
- **Multiple channels simultaneously** — A single agent can be connected to Discord, Slack, Telegram, QQ, WeChat, Feishu, and Lark at the same time. LobeHub routes messages to the correct agent automatically.
|
||||
- **Secure credential storage** — All bot tokens and app secrets are encrypted before being stored.
|
||||
|
||||
## Getting Started
|
||||
|
|
@ -44,17 +51,21 @@ Each channel integration works by linking a bot account on the target platform t
|
|||
2. Navigate to your agent's settings and select the **Channels** tab
|
||||
3. Choose a platform and follow the setup guide:
|
||||
- [Discord](/docs/usage/channels/discord)
|
||||
- [Slack](/docs/usage/channels/slack)
|
||||
- [Telegram](/docs/usage/channels/telegram)
|
||||
- [Feishu / Lark](/docs/usage/channels/feishu)
|
||||
- [QQ](/docs/usage/channels/qq)
|
||||
- [WeChat (微信)](/docs/usage/channels/wechat)
|
||||
- [Feishu (飞书)](/docs/usage/channels/feishu)
|
||||
- [Lark](/docs/usage/channels/lark)
|
||||
|
||||
## Feature Support
|
||||
|
||||
Text messages are supported across all platforms. Some features vary by platform:
|
||||
|
||||
| Feature | Discord | Telegram | Feishu / Lark |
|
||||
| ---------------------- | ------- | -------- | ------------- |
|
||||
| Text messages | Yes | Yes | Yes |
|
||||
| Direct messages | Yes | Yes | Yes |
|
||||
| Group chats | Yes | Yes | Yes |
|
||||
| Reactions | Yes | Yes | Partial |
|
||||
| Image/file attachments | Yes | Yes | Yes |
|
||||
| Feature | Discord | Slack | Telegram | QQ | WeChat | Feishu | Lark |
|
||||
| ---------------------- | ------- | ----- | -------- | --- | ------ | ------- | ------- |
|
||||
| Text messages | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
|
||||
| Direct messages | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
|
||||
| Group chats | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
|
||||
| Reactions | Yes | Yes | Yes | No | No | Partial | Partial |
|
||||
| Image/file attachments | Yes | Yes | Yes | Yes | No | Yes | Yes |
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
---
|
||||
title: 渠道概览
|
||||
description: 将 LobeHub 代理连接到外部消息平台,如 Discord、Telegram 和飞书/Lark,让用户可以直接在他们喜欢的聊天应用中与 AI 助手互动。
|
||||
description: >-
|
||||
将 LobeHub 代理连接到外部消息平台,如 Discord、Slack、Telegram、QQ、微信、飞书和
|
||||
Lark,让用户可以直接在他们喜欢的聊天应用中与 AI 助手互动。
|
||||
tags:
|
||||
- 渠道
|
||||
- 消息渠道
|
||||
- 集成
|
||||
- Discord
|
||||
- Slack
|
||||
- Telegram
|
||||
- QQ
|
||||
- 微信
|
||||
- 飞书
|
||||
- Lark
|
||||
---
|
||||
|
|
@ -24,15 +29,19 @@ tags:
|
|||
| 平台 | 描述 |
|
||||
| ----------------------------------------- | -------------------------- |
|
||||
| [Discord](/docs/usage/channels/discord) | 连接到 Discord 服务器,用于频道聊天和私信 |
|
||||
| [Slack](/docs/usage/channels/slack) | 连接到 Slack,用于频道和私信对话 |
|
||||
| [Telegram](/docs/usage/channels/telegram) | 连接到 Telegram,用于私人和群组对话 |
|
||||
| [飞书 / Lark](/docs/usage/channels/feishu) | 连接到飞书(Feishu)或 Lark,用于团队协作 |
|
||||
| [QQ](/docs/usage/channels/qq) | 连接到 QQ,用于群聊和私信 |
|
||||
| [微信](/docs/usage/channels/wechat) | 通过 iLink Bot 连接到微信,用于私聊和群聊 |
|
||||
| [飞书](/docs/usage/channels/feishu) | 连接到飞书,用于团队协作(中国版) |
|
||||
| [Lark](/docs/usage/channels/lark) | 连接到 Lark,用于团队协作(国际版) |
|
||||
|
||||
## 工作原理
|
||||
|
||||
每个渠道集成都通过将目标平台上的机器人账户与 LobeHub 代理连接来实现。当用户向机器人发送消息时,LobeHub 会通过代理处理消息并将响应发送回同一对话。
|
||||
|
||||
- **按代理配置** — 每个代理可以拥有自己的一组渠道连接,因此不同的代理可以服务于不同的平台或社区。
|
||||
- **同时支持多个渠道** — 单个代理可以同时连接到 Discord、Telegram 和飞书 / Lark。LobeHub 会自动将消息路由到正确的代理。
|
||||
- **同时支持多个渠道** — 单个代理可以同时连接到 Discord、Slack、Telegram、QQ、微信、飞书和 Lark。LobeHub 会自动将消息路由到正确的代理。
|
||||
- **安全的凭据存储** — 所有机器人令牌和应用密钥在存储前都会被加密。
|
||||
|
||||
## 快速开始
|
||||
|
|
@ -41,17 +50,21 @@ tags:
|
|||
2. 前往您的代理设置页面,选择 **渠道** 标签
|
||||
3. 选择一个平台并按照设置指南操作:
|
||||
- [Discord](/docs/usage/channels/discord)
|
||||
- [Slack](/docs/usage/channels/slack)
|
||||
- [Telegram](/docs/usage/channels/telegram)
|
||||
- [飞书 / Lark](/docs/usage/channels/feishu)
|
||||
- [QQ](/docs/usage/channels/qq)
|
||||
- [微信](/docs/usage/channels/wechat)
|
||||
- [飞书](/docs/usage/channels/feishu)
|
||||
- [Lark](/docs/usage/channels/lark)
|
||||
|
||||
## 功能支持
|
||||
|
||||
所有平台均支持文本消息。某些功能因平台而异:
|
||||
|
||||
| 功能 | Discord | Telegram | 飞书 / Lark |
|
||||
| --------- | ------- | -------- | --------- |
|
||||
| 文本消息 | 是 | 是 | 是 |
|
||||
| 私人消息 | 是 | 是 | 是 |
|
||||
| 群组聊天 | 是 | 是 | 是 |
|
||||
| 表情反应 | 是 | 是 | 部分支持 |
|
||||
| 图片 / 文件附件 | 是 | 是 | 是 |
|
||||
| 功能 | Discord | Slack | Telegram | QQ | 微信 | 飞书 | Lark |
|
||||
| --------- | ------- | ----- | -------- | -- | -- | ---- | ---- |
|
||||
| 文本消息 | 是 | 是 | 是 | 是 | 是 | 是 | 是 |
|
||||
| 私人消息 | 是 | 是 | 是 | 是 | 是 | 是 | 是 |
|
||||
| 群组聊天 | 是 | 是 | 是 | 是 | 是 | 是 | 是 |
|
||||
| 表情反应 | 是 | 是 | 是 | 否 | 否 | 部分支持 | 部分支持 |
|
||||
| 图片 / 文件附件 | 是 | 是 | 是 | 是 | 否 | 是 | 是 |
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
---
|
||||
title: Connect LobeHub to QQ
|
||||
description: >-
|
||||
Learn how to create a QQ bot and connect it to your LobeHub agent as a
|
||||
message channel, enabling your AI assistant to chat with users in QQ
|
||||
group chats and direct messages.
|
||||
Learn how to create a QQ bot and connect it to your LobeHub agent as a message
|
||||
channel, enabling your AI assistant to chat with users in QQ group chats and
|
||||
direct messages.
|
||||
tags:
|
||||
- QQ
|
||||
- Message Channels
|
||||
|
|
|
|||
145
docs/usage/channels/slack.mdx
Normal file
145
docs/usage/channels/slack.mdx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
---
|
||||
title: Connect LobeHub to Slack
|
||||
description: >-
|
||||
Learn how to create a Slack app and connect it to your LobeHub agent as a
|
||||
message channel, enabling your AI assistant to interact with users in Slack
|
||||
channels and direct messages.
|
||||
tags:
|
||||
- Slack
|
||||
- Message Channels
|
||||
- Bot Setup
|
||||
- Integration
|
||||
---
|
||||
|
||||
# Connect LobeHub to Slack
|
||||
|
||||
<Callout type={'info'}>
|
||||
This feature is currently in development and may not be fully stable. You can enable it by turning
|
||||
on **Developer Mode** in **Settings** → **Advanced Settings** → **Developer Mode**.
|
||||
</Callout>
|
||||
|
||||
By connecting a Slack channel to your LobeHub agent, users can interact with the AI assistant directly through Slack channels and direct messages.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A LobeHub account with an active subscription
|
||||
- A Slack workspace where you have permission to install apps
|
||||
|
||||
## Step 1: Create a Slack App
|
||||
|
||||
<Steps>
|
||||
### Go to the Slack API Dashboard
|
||||
|
||||
Visit [Slack API Apps](https://api.slack.com/apps) and click **Create New App**. Choose **From scratch**, give your app a name (e.g., "LobeHub Assistant"), select the workspace to install it in, and click **Create App**.
|
||||
|
||||
### Copy the App ID and Signing Secret
|
||||
|
||||
On the **Basic Information** page, copy and save:
|
||||
|
||||
- **App ID** — displayed at the top of the page
|
||||
- **Signing Secret** — under the **App Credentials** section
|
||||
|
||||
### Add Bot Token Scopes
|
||||
|
||||
In the left sidebar, go to **OAuth & Permissions**. Scroll down to **Scopes** → **Bot Token Scopes** and add the following:
|
||||
|
||||
- `app_mentions:read` — Detect when the bot is mentioned
|
||||
- `channels:history` — Read messages in public channels
|
||||
- `channels:read` — Read channel info
|
||||
- `chat:write` — Send messages
|
||||
- `groups:history` — Read messages in private channels
|
||||
- `groups:read` — Read private channel info
|
||||
- `im:history` — Read direct messages
|
||||
- `im:read` — Read DM channel info
|
||||
- `mpim:history` — Read group DM messages
|
||||
- `mpim:read` — Read group DM channel info
|
||||
- `reactions:read` — Read reactions
|
||||
- `reactions:write` — Add reactions
|
||||
- `users:read` — Look up user info
|
||||
|
||||
**Optional scopes** (for Slack Assistants API support):
|
||||
|
||||
- `assistant:write` — Enable the Slack Assistants API features
|
||||
|
||||
### Install the App to Your Workspace
|
||||
|
||||
Still on the **OAuth & Permissions** page, click **Install to Workspace** and authorize the app. After installation, copy the **Bot User OAuth Token** (starts with `xoxb-`).
|
||||
|
||||
> **Important:** Treat your bot token like a password. Never share it publicly or commit it to version control.
|
||||
</Steps>
|
||||
|
||||
## Step 2: Configure Slack in LobeHub
|
||||
|
||||
<Steps>
|
||||
### Open Channel Settings
|
||||
|
||||
In LobeHub, navigate to your agent's settings, then select the **Channels** tab. Click **Slack** from the platform list.
|
||||
|
||||
### Fill in the Credentials
|
||||
|
||||
Enter the following fields:
|
||||
|
||||
- **Application ID** — The App ID from your Slack app's Basic Information page
|
||||
- **Bot Token** — The Bot User OAuth Token (xoxb-...) from OAuth & Permissions
|
||||
- **Signing Secret** — The Signing Secret from your Slack app's Basic Information page
|
||||
|
||||
Your token will be encrypted and stored securely.
|
||||
|
||||
### Save Configuration
|
||||
|
||||
Click **Save Configuration**. LobeHub will save your credentials and display a **Webhook URL**.
|
||||
|
||||
### Copy the Webhook URL
|
||||
|
||||
Copy the displayed Webhook URL — you will need it in the next step to configure Slack's Event Subscriptions.
|
||||
</Steps>
|
||||
|
||||
## Step 3: Configure Event Subscriptions
|
||||
|
||||
<Steps>
|
||||
### Enable Events
|
||||
|
||||
Back in the [Slack API Dashboard](https://api.slack.com/apps), go to **Event Subscriptions** and toggle **Enable Events** to **On**.
|
||||
|
||||
### Set the Request URL
|
||||
|
||||
Paste the **Webhook URL** you copied from LobeHub into the **Request URL** field. Slack will send a verification challenge — LobeHub will respond automatically.
|
||||
|
||||
### Subscribe to Bot Events
|
||||
|
||||
Under **Subscribe to bot events**, add:
|
||||
|
||||
- `app_mention` — Triggered when someone mentions the bot
|
||||
- `message.channels` — Messages in public channels
|
||||
- `message.groups` — Messages in private channels
|
||||
- `message.im` — Direct messages to the bot
|
||||
- `message.mpim` — Messages in group DMs
|
||||
- `member_joined_channel` — When a user joins a channel
|
||||
|
||||
**Optional events** (for Slack Assistants API support):
|
||||
|
||||
- `assistant_thread_started` — When a user opens a new assistant thread
|
||||
- `assistant_thread_context_changed` — When a user navigates to a different channel with the assistant panel open
|
||||
|
||||
### Save Changes
|
||||
|
||||
Click **Save Changes** at the bottom of the page.
|
||||
</Steps>
|
||||
|
||||
## Step 4: Test the Connection
|
||||
|
||||
Back in LobeHub's channel settings for Slack, click **Test Connection** to verify the integration. Then go to your Slack workspace, invite the bot to a channel, and mention it with `@YourBotName` to confirm it responds.
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
| Field | Required | Description |
|
||||
| ------------------ | -------- | ------------------------------------------ |
|
||||
| **Application ID** | Yes | Your Slack app's ID |
|
||||
| **Bot Token** | Yes | Bot User OAuth Token (xoxb-...) |
|
||||
| **Signing Secret** | Yes | Used to verify webhook requests from Slack |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Bot not responding:** Confirm the bot has been invited to the channel and the Event Subscriptions are correctly configured with the right webhook URL.
|
||||
- **Test Connection failed:** Double-check the Application ID and Bot Token are correct. Ensure the app is installed to the workspace.
|
||||
- **Webhook verification failed:** Make sure the Signing Secret matches the one in your Slack app's Basic Information page.
|
||||
141
docs/usage/channels/slack.zh-CN.mdx
Normal file
141
docs/usage/channels/slack.zh-CN.mdx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
---
|
||||
title: 将 LobeHub 连接到 Slack
|
||||
description: 了解如何创建一个 Slack 应用并将其连接到您的 LobeHub 代理作为消息渠道,使您的 AI 助手能够直接在 Slack 频道和私信中与用户互动。
|
||||
tags:
|
||||
- Slack
|
||||
- 消息渠道
|
||||
- 机器人设置
|
||||
- 集成
|
||||
---
|
||||
|
||||
# 将 LobeHub 连接到 Slack
|
||||
|
||||
<Callout type={'info'}>
|
||||
此功能目前正在开发中,可能尚未完全稳定。您可以通过在 **设置** → **高级设置** → **开发者模式** 中启用 **开发者模式** 来使用此功能。
|
||||
</Callout>
|
||||
|
||||
通过将 Slack 渠道连接到您的 LobeHub 代理,用户可以直接通过 Slack 频道和私信与 AI 助手互动。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 一个拥有有效订阅的 LobeHub 账户
|
||||
- 一个拥有安装应用权限的 Slack 工作区
|
||||
|
||||
## 第一步:创建 Slack 应用
|
||||
|
||||
<Steps>
|
||||
### 访问 Slack API 控制台
|
||||
|
||||
访问 [Slack API Apps](https://api.slack.com/apps),点击 **Create New App**。选择 **From scratch**,为您的应用命名(例如 "LobeHub 助手"),选择要安装到的工作区,然后点击 **Create App**。
|
||||
|
||||
### 复制 App ID 和 Signing Secret
|
||||
|
||||
在 **Basic Information** 页面,复制并保存:
|
||||
|
||||
- **App ID** — 显示在页面顶部
|
||||
- **Signing Secret** — 在 **App Credentials** 部分下
|
||||
|
||||
### 添加 Bot Token 权限范围
|
||||
|
||||
在左侧菜单中,进入 **OAuth & Permissions**。向下滚动到 **Scopes** → **Bot Token Scopes**,添加以下权限:
|
||||
|
||||
- `app_mentions:read` — 检测机器人被提及
|
||||
- `channels:history` — 读取公共频道中的消息
|
||||
- `channels:read` — 读取频道信息
|
||||
- `chat:write` — 发送消息
|
||||
- `groups:history` — 读取私有频道中的消息
|
||||
- `groups:read` — 读取私有频道信息
|
||||
- `im:history` — 读取私信
|
||||
- `im:read` — 读取私信频道信息
|
||||
- `mpim:history` — 读取群组私信消息
|
||||
- `mpim:read` — 读取群组私信信息
|
||||
- `reactions:read` — 读取表情回应
|
||||
- `reactions:write` — 添加表情回应
|
||||
- `users:read` — 查询用户信息
|
||||
|
||||
**可选权限**(用于 Slack Assistants API):
|
||||
|
||||
- `assistant:write` — 启用 Slack Assistants API 功能
|
||||
|
||||
### 安装应用到工作区
|
||||
|
||||
仍然在 **OAuth & Permissions** 页面,点击 **Install to Workspace** 并授权应用。安装完成后,复制 **Bot User OAuth Token**(以 `xoxb-` 开头)。
|
||||
|
||||
> **重要提示:** 请将您的 Bot Token 视为密码。切勿公开分享或提交到版本控制系统。
|
||||
</Steps>
|
||||
|
||||
## 第二步:在 LobeHub 中配置 Slack
|
||||
|
||||
<Steps>
|
||||
### 打开渠道设置
|
||||
|
||||
在 LobeHub 中,导航到您的代理设置,然后选择 **渠道** 标签。点击平台列表中的 **Slack**。
|
||||
|
||||
### 填写凭据
|
||||
|
||||
输入以下字段:
|
||||
|
||||
- **应用 ID** — 来自 Slack 应用 Basic Information 页面的 App ID
|
||||
- **Bot Token** — 来自 OAuth & Permissions 页面的 Bot User OAuth Token(xoxb-...)
|
||||
- **签名密钥** — 来自 Slack 应用 Basic Information 页面的 Signing Secret
|
||||
|
||||
您的令牌将被加密并安全存储。
|
||||
|
||||
### 保存配置
|
||||
|
||||
点击 **保存配置**。LobeHub 将保存您的凭据并显示一个 **Webhook URL**。
|
||||
|
||||
### 复制 Webhook URL
|
||||
|
||||
复制显示的 Webhook URL —— 您将在下一步中使用它来配置 Slack 的事件订阅。
|
||||
</Steps>
|
||||
|
||||
## 第三步:配置事件订阅
|
||||
|
||||
<Steps>
|
||||
### 启用事件
|
||||
|
||||
返回 [Slack API 控制台](https://api.slack.com/apps),进入 **Event Subscriptions**,将 **Enable Events** 切换为 **On**。
|
||||
|
||||
### 设置请求 URL
|
||||
|
||||
将您从 LobeHub 复制的 **Webhook URL** 粘贴到 **Request URL** 字段中。Slack 将发送一个验证请求 —— LobeHub 会自动响应。
|
||||
|
||||
### 订阅机器人事件
|
||||
|
||||
在 **Subscribe to bot events** 下,添加:
|
||||
|
||||
- `app_mention` — 当有人提及机器人时触发
|
||||
- `message.channels` — 公共频道中的消息
|
||||
- `message.groups` — 私有频道中的消息
|
||||
- `message.im` — 发送给机器人的私信
|
||||
- `message.mpim` — 群组私信中的消息
|
||||
- `member_joined_channel` — 当用户加入频道时触发
|
||||
|
||||
**可选事件**(用于 Slack Assistants API):
|
||||
|
||||
- `assistant_thread_started` — 当用户打开新的助手会话时触发
|
||||
- `assistant_thread_context_changed` — 当用户在助手面板打开时切换到不同频道时触发
|
||||
|
||||
### 保存更改
|
||||
|
||||
点击页面底部的 **Save Changes**。
|
||||
</Steps>
|
||||
|
||||
## 第四步:测试连接
|
||||
|
||||
返回 LobeHub 的 Slack 渠道设置,点击 **测试连接** 以验证集成是否正确。然后进入您的 Slack 工作区,将机器人邀请到一个频道,通过 `@你的机器人名称` 提及它,确认其是否响应。
|
||||
|
||||
## 配置参考
|
||||
|
||||
| 字段 | 是否必需 | 描述 |
|
||||
| ------------- | ---- | ------------------------------ |
|
||||
| **应用 ID** | 是 | 您的 Slack 应用的 ID |
|
||||
| **Bot Token** | 是 | Bot User OAuth Token(xoxb-...) |
|
||||
| **签名密钥** | 是 | 用于验证来自 Slack 的 Webhook 请求 |
|
||||
|
||||
## 故障排除
|
||||
|
||||
- **机器人未响应:** 确认机器人已被邀请到频道,且事件订阅已正确配置了正确的 Webhook URL。
|
||||
- **测试连接失败:** 仔细检查应用 ID 和 Bot Token 是否正确。确保应用已安装到工作区。
|
||||
- **Webhook 验证失败:** 确保签名密钥与 Slack 应用 Basic Information 页面中的一致。
|
||||
96
docs/usage/channels/wechat.mdx
Normal file
96
docs/usage/channels/wechat.mdx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
---
|
||||
title: Connect LobeHub to WeChat
|
||||
description: >-
|
||||
Learn how to connect a WeChat bot to your LobeHub agent via the iLink Bot API,
|
||||
enabling your AI assistant to chat with users in WeChat private and group
|
||||
conversations.
|
||||
tags:
|
||||
- WeChat
|
||||
- Message Channels
|
||||
- Bot Setup
|
||||
- Integration
|
||||
---
|
||||
|
||||
# Connect LobeHub to WeChat
|
||||
|
||||
<Callout type={'info'}>
|
||||
This feature is currently in development and may not be fully stable. You can enable it by turning
|
||||
on **Developer Mode** in **Settings** → **Advanced Settings** → **Developer Mode**.
|
||||
</Callout>
|
||||
|
||||
By connecting a WeChat channel to your LobeHub agent, users can interact with the AI assistant through WeChat private chats and group conversations.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A LobeHub account with an active subscription
|
||||
- A WeChat account
|
||||
|
||||
## Step 1: Open Channel Settings
|
||||
|
||||
In LobeHub, navigate to your agent's settings, then select the **Channels** tab. Click **WeChat** from the platform list.
|
||||
|
||||
## Step 2: Scan QR Code to Connect
|
||||
|
||||
<Steps>
|
||||
### Click "Scan QR Code to Connect"
|
||||
|
||||
On the WeChat channel page, click the **Scan QR Code to Connect** button. A modal dialog will appear displaying a QR code.
|
||||
|
||||
### Scan with WeChat
|
||||
|
||||
Open WeChat on your phone, go to **Scan** (via the + button in the top right), and scan the QR code displayed in LobeHub.
|
||||
|
||||
### Confirm Login
|
||||
|
||||
After scanning, a confirmation prompt will appear in WeChat. Tap **Confirm** to authorize the connection.
|
||||
|
||||
### Connection Complete
|
||||
|
||||
Once confirmed, LobeHub will automatically save your credentials and connect the bot. You should see a success message in the channel settings.
|
||||
</Steps>
|
||||
|
||||
## Step 3: Test the Bot
|
||||
|
||||
Open WeChat, find your bot contact, and send a message. The bot should respond through your LobeHub agent.
|
||||
|
||||
## Adding the Bot to Group Chats
|
||||
|
||||
To use the bot in WeChat groups:
|
||||
|
||||
1. Add the bot to a WeChat group
|
||||
2. @mention the bot or send a message in the group to trigger a response
|
||||
3. The bot will reply in the group conversation
|
||||
|
||||
## Advanced Settings
|
||||
|
||||
| Setting | Default | Description |
|
||||
| ------------------------ | ------- | -------------------------------------------------------- |
|
||||
| **Character Limit** | 2000 | Maximum characters per message (range: 100–2000) |
|
||||
| **Message Merge Window** | 2000 ms | How long to wait for additional messages before replying |
|
||||
| **Show Usage Stats** | Off | Display token/cost stats in replies |
|
||||
|
||||
## How It Works
|
||||
|
||||
Unlike webhook-based platforms (Telegram, Slack), WeChat uses a **long-polling** mechanism via the iLink Bot API:
|
||||
|
||||
1. When you scan the QR code, LobeHub obtains a bot token from WeChat's iLink API
|
||||
2. LobeHub continuously polls the iLink API for new messages (\~35 second intervals)
|
||||
3. When a message arrives, it is routed through the LobeHub agent for processing
|
||||
4. The agent's response is sent back to WeChat via the iLink API
|
||||
|
||||
This polling is managed by a background cron job, so the connection is maintained automatically.
|
||||
|
||||
## Limitations
|
||||
|
||||
- **No message editing** — WeChat does not support editing sent messages. Updated responses will be sent as new messages.
|
||||
- **No reactions** — WeChat iLink Bot API does not support emoji reactions.
|
||||
- **Text only** — Only text messages are currently supported. Image and file attachments are not yet available.
|
||||
- **Message length limit** — Messages exceeding 2000 characters will be automatically split into multiple messages.
|
||||
- **Session expiration** — The bot session may expire and require re-authentication by scanning a new QR code.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **QR code expired:** Click **Refresh QR Code** in the modal to generate a new one.
|
||||
- **Bot not responding:** The session may have expired. Go to the WeChat channel settings and re-scan the QR code to reconnect.
|
||||
- **Delayed responses:** Long-polling has a natural delay of up to 35 seconds between polls. This is expected behavior.
|
||||
- **Connection lost after some time:** WeChat sessions expire periodically. Re-authenticate by clicking "Scan QR Code to Connect" again.
|
||||
93
docs/usage/channels/wechat.zh-CN.mdx
Normal file
93
docs/usage/channels/wechat.zh-CN.mdx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
---
|
||||
title: 将 LobeHub 连接到微信
|
||||
description: 了解如何通过 iLink Bot API 将微信机器人连接到您的 LobeHub 代理,使您的 AI 助手能够在微信私聊和群聊中与用户互动。
|
||||
tags:
|
||||
- 微信
|
||||
- 消息渠道
|
||||
- 机器人设置
|
||||
- 集成
|
||||
---
|
||||
|
||||
# 将 LobeHub 连接到微信
|
||||
|
||||
<Callout type={'info'}>
|
||||
此功能目前正在开发中,可能尚未完全稳定。您可以通过在 **设置** → **高级设置** → **开发者模式**
|
||||
中启用 **开发者模式** 来使用此功能。
|
||||
</Callout>
|
||||
|
||||
通过将微信渠道连接到您的 LobeHub 代理,用户可以通过微信私聊和群聊与 AI 助手互动。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 一个拥有有效订阅的 LobeHub 账户
|
||||
- 一个微信账户
|
||||
|
||||
## 第一步:打开渠道设置
|
||||
|
||||
在 LobeHub 中,导航到您的代理设置,然后选择 **渠道** 标签页。从平台列表中点击 **微信**。
|
||||
|
||||
## 第二步:扫码连接
|
||||
|
||||
<Steps>
|
||||
### 点击 "扫码连接"
|
||||
|
||||
在微信渠道页面中,点击 **扫码连接** 按钮。将弹出一个显示二维码的对话框。
|
||||
|
||||
### 使用微信扫码
|
||||
|
||||
打开手机微信,点击右上角的 **+** 按钮,选择 **扫一扫**,扫描 LobeHub 中显示的二维码。
|
||||
|
||||
### 确认登录
|
||||
|
||||
扫码后,微信中会出现确认提示。点击 **确认** 授权连接。
|
||||
|
||||
### 连接完成
|
||||
|
||||
确认后,LobeHub 将自动保存凭证并连接机器人。您应该会在渠道设置中看到成功消息。
|
||||
</Steps>
|
||||
|
||||
## 第三步:测试机器人
|
||||
|
||||
打开微信,找到您的机器人联系人,发送一条消息。机器人应通过您的 LobeHub 代理进行响应。
|
||||
|
||||
## 将机器人添加到群聊
|
||||
|
||||
要在微信群聊中使用机器人:
|
||||
|
||||
1. 将机器人添加到微信群聊中
|
||||
2. @提及机器人或在群中发送消息以触发响应
|
||||
3. 机器人将在群聊中回复
|
||||
|
||||
## 高级设置
|
||||
|
||||
| 设置 | 默认值 | 描述 |
|
||||
| ---------- | ------- | ----------------------- |
|
||||
| **字符限制** | 2000 | 每条消息的最大字符数(范围:100–2000) |
|
||||
| **消息合并窗口** | 2000 毫秒 | 等待更多消息再回复的时间 |
|
||||
| **显示使用统计** | 关闭 | 在回复中显示 Token 用量 / 成本统计 |
|
||||
|
||||
## 工作原理
|
||||
|
||||
与基于 Webhook 的平台(Telegram、Slack)不同,微信使用 iLink Bot API 的 **长轮询** 机制:
|
||||
|
||||
1. 当您扫描二维码时,LobeHub 从微信 iLink API 获取 bot token
|
||||
2. LobeHub 持续轮询 iLink API 获取新消息(约 35 秒间隔)
|
||||
3. 当消息到达时,通过 LobeHub 代理进行处理
|
||||
4. 代理的响应通过 iLink API 发送回微信
|
||||
|
||||
此轮询由后台定时任务管理,连接会自动维护。
|
||||
|
||||
## 功能限制
|
||||
|
||||
- **不支持消息编辑** — 微信不支持编辑已发送的消息。更新的回复将作为新消息发送。
|
||||
- **不支持表情回应** — 微信 iLink Bot API 不支持表情回应功能。
|
||||
- **仅支持文本** — 目前仅支持文本消息。图片和文件附件暂不可用。
|
||||
- **消息长度限制** — 超过 2000 个字符的消息将被自动拆分为多条消息发送。
|
||||
- **会话过期** — 机器人会话可能会过期,需要重新扫码认证。
|
||||
|
||||
## 故障排除
|
||||
|
||||
- **二维码已过期:** 在弹窗中点击 **刷新二维码** 生成新的二维码。
|
||||
- **机器人未响应:** 会话可能已过期。前往微信渠道设置,重新扫码连接。
|
||||
- **响应延迟:** 长轮询在两次轮询之间有最多 35 秒的自然延迟。这是预期行为。
|
||||
- **一段时间后连接断开:** 微信会话会定期过期。再次点击 "扫码连接" 重新认证。
|
||||
|
|
@ -24,7 +24,7 @@ The Command Menu is LobeHub's quick action center. Press `⌘ + K` (Mac) or `Ctr
|
|||
|
||||
The menu appears as an overlay in the center of the screen.
|
||||
|
||||
<Image alt={'Command Menu'} src={'https://file.rene.wang/clipboard-1769137275089-21cf7ab42d52b.png'} />
|
||||
<Image alt={'Command Menu'} src={'/blog/assets095af3a0a0f850fc206fc3bbc19a4095.webp'} />
|
||||
|
||||
## What You Can Search
|
||||
|
||||
|
|
@ -38,7 +38,7 @@ The menu appears as an overlay in the center of the screen.
|
|||
|
||||
**Keyboard navigation:** Use `↑` and `↓` to move through results, `Enter` to execute, `Esc` to close. `Tab` switches between result categories when you're typing a message.
|
||||
|
||||
<Image alt={'Command Menu Search and Navigation'} src={'https://file.rene.wang/clipboard-1769137300488-0b894cc8c7a67.png'} />
|
||||
<Image alt={'Command Menu Search and Navigation'} src={'/blog/assetsebc1ebe8330d982f6a0b757aafb3f4a1.webp'} />
|
||||
|
||||
## Ask an Agent
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ tags:
|
|||
|
||||
菜单会以浮层形式出现在屏幕中央。
|
||||
|
||||
<Image alt={'命令菜单'} src={'https://file.rene.wang/clipboard-1769137275089-21cf7ab42d52b.png'} />
|
||||
<Image alt={'命令菜单'} src={'/blog/assets095af3a0a0f850fc206fc3bbc19a4095.webp'} />
|
||||
|
||||
## 可以搜索什么
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ tags:
|
|||
|
||||
**键盘导航:** 用 `↑` 和 `↓` 在结果间移动,`Enter` 执行,`Esc` 关闭。输入消息时,`Tab` 可在结果类别间切换。
|
||||
|
||||
<Image alt={'命令菜单搜索和导航'} src={'https://file.rene.wang/clipboard-1769137300488-0b894cc8c7a67.png'} />
|
||||
<Image alt={'命令菜单搜索和导航'} src={'/blog/assetsebc1ebe8330d982f6a0b757aafb3f4a1.webp'} />
|
||||
|
||||
## 向助理提问
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue