mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-21 06:29:59 +00:00
Compare commits
124 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c6bd5fb03b | |||
| dfc9ec2c05 | |||
| cd8ae7f976 | |||
| 91cac37bc3 | |||
| 7eecd7bff1 | |||
| cc2890766f | |||
| 6ecae1bbd1 | |||
| 60a59e89f6 | |||
| 7fd6d67fe3 | |||
| 453db9f165 | |||
| 19f90e3d9a | |||
| fee0fe5699 | |||
| 88246e5719 | |||
| aaefe6c0d2 | |||
| cbc9bfccaa | |||
| 3e056ad37a | |||
| 46bac5b540 | |||
| 57ed8f8541 | |||
| 132893549a | |||
| d717d5da20 | |||
| 58fa4f869d | |||
| 32e36e330a | |||
| ee8cab8305 | |||
| 393653e20c | |||
| 560f598789 | |||
| 993dfe1bb0 | |||
| 967302269e | |||
| 674c849254 | |||
| f327e377a6 | |||
| e7be5b1928 | |||
| b54a41968d | |||
| f39f5e9fd6 | |||
| 7be18092d3 | |||
| c60c02bcfe | |||
| ec3443d1db | |||
| e76ab1f990 | |||
| c59c066330 | |||
| 7097167613 | |||
| 2c2795e73a | |||
| 965fc929e1 | |||
| 491aba4dbd | |||
| 6402656ec7 | |||
| f6314cc673 | |||
| cded932f1a | |||
| e7c496352f | |||
| 296c6f3cb3 | |||
| 53d0ee9ca5 | |||
| 689d5a51e8 | |||
| 23eab8769b | |||
| 0e57fd9955 | |||
| 2f5a31fc99 | |||
| 143a15fdb9 | |||
| 9c08fa5cdf | |||
| 59d8d878a2 | |||
| 0439a29189 | |||
| 4a63ea3dcc | |||
| 91b2653c71 | |||
| 8c8e7dd992 | |||
| a9cd2f7301 | |||
| b6c66dbdd7 | |||
| 5e1738ad4b | |||
| 4dc3c4ea1d | |||
| bc9ae6b4e5 | |||
| 70091935ba | |||
| 50e373ad1c | |||
| 966f943175 | |||
| c7c2b56f3b | |||
| 841c1d2ef2 | |||
| 26449e522a | |||
| f4c4ba7db5 | |||
| 83f8f0319c | |||
| 197a0cc8f1 | |||
| 6b4046eb17 | |||
| 9e27bef8fa | |||
| aaff9af3b7 | |||
| feb50e7007 | |||
| dc9adf8f10 | |||
| 3d592ca70d | |||
| 8d0ac45476 | |||
| 953033355b | |||
| 48b5927024 | |||
| 6e86912e7f | |||
| 4576059f4f | |||
| 9e9ba3e6c3 | |||
| 46602be0b3 | |||
| 14b278fba8 | |||
| 53c5708c9f | |||
| edc8920703 | |||
| 926de076d9 | |||
| 9b7beca85e | |||
| 0724d8ca60 | |||
| 9f36fe95ac | |||
| 3f148005e4 | |||
| 4e60d87514 | |||
| d2a16d0714 | |||
| 0f04463708 | |||
| 093fa7bcae | |||
| ac8a9ec0f8 | |||
| aa48b856fb | |||
| b4d27c7232 | |||
| dd192eda3e | |||
| c6b0f868ef | |||
| 3bea920193 | |||
| ca16a40a44 | |||
| 59e19310fe | |||
| b005a9c73b | |||
| 2c657670fe | |||
| 4dd271c968 | |||
| b76db6bcbd | |||
| 84674b1e10 | |||
| 1cb13d9f93 | |||
| 169f11b63b | |||
| 2c7a3f934d | |||
| a1e91ab30d | |||
| 4a7c89ec25 | |||
| 684a186e3b | |||
| e8a948cfaf | |||
| 11daf645e9 | |||
| a4a03eadc4 | |||
| 04ddb992d1 | |||
| 991de25b97 | |||
| 056f390abc | |||
| 9b9949befa | |||
| b53abaa3b2 |
@@ -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?
|
||||
|
||||
@@ -0,0 +1,935 @@
|
||||
---
|
||||
name: local-testing
|
||||
description: >
|
||||
Local app and bot testing. Uses agent-browser CLI for Electron/web app UI testing,
|
||||
and osascript (AppleScript) for controlling native macOS apps (WeChat, Discord, Telegram, Slack, Lark/飞书, QQ)
|
||||
to test bots. Triggers on 'local test', 'test in electron', 'test desktop', 'test bot',
|
||||
'bot test', 'test in discord', 'test in telegram', 'test in slack', 'test in weixin',
|
||||
'test in wechat', 'test in lark', 'test in feishu', 'test in qq',
|
||||
'manual test', 'osascript', or UI/bot verification tasks.
|
||||
---
|
||||
|
||||
# Local App & Bot Testing
|
||||
|
||||
Two approaches for local testing on macOS:
|
||||
|
||||
| Approach | Tool | Best For |
|
||||
| --------------------------- | ------------------- | ---------------------------------------------------- |
|
||||
| **agent-browser + CDP** | `agent-browser` CLI | Electron apps, web apps (DOM access, JS eval) |
|
||||
| **osascript (AppleScript)** | `osascript -e` | Native macOS apps (WeChat, Discord, Telegram, Slack) |
|
||||
|
||||
---
|
||||
|
||||
# Part 1: agent-browser (Electron / Web Apps)
|
||||
|
||||
Use `agent-browser` to automate Chromium-based apps via Chrome DevTools Protocol.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `agent-browser` CLI installed globally (`agent-browser --version`)
|
||||
|
||||
## Core Workflow
|
||||
|
||||
### 1. Snapshot → Find Elements
|
||||
|
||||
```bash
|
||||
agent-browser --cdp -i < PORT > snapshot # Interactive elements only
|
||||
agent-browser --cdp -i -C < PORT > snapshot # Include contenteditable elements
|
||||
```
|
||||
|
||||
Returns element refs like `@e1`, `@e2`. **Refs are ephemeral** — re-snapshot after any page change.
|
||||
|
||||
### 2. Interact
|
||||
|
||||
```bash
|
||||
agent-browser --cdp @e5 < PORT > click
|
||||
agent-browser --cdp @e3 "text" < PORT > type # Character by character (contenteditable)
|
||||
agent-browser --cdp @e3 "text" < PORT > fill # Bulk fill (regular inputs)
|
||||
agent-browser --cdp Enter < PORT > press
|
||||
agent-browser --cdp down 500 < PORT > scroll
|
||||
```
|
||||
|
||||
### 3. Wait
|
||||
|
||||
```bash
|
||||
agent-browser --cdp 2000 < PORT > wait # Wait ms
|
||||
agent-browser --cdp --load networkidle < PORT > wait # Wait for network
|
||||
```
|
||||
|
||||
For waits >30s, use `sleep N` in bash instead — `agent-browser wait` blocks the daemon.
|
||||
|
||||
### 4. Screenshot & Verify
|
||||
|
||||
```bash
|
||||
agent-browser --cdp < PORT > screenshot # Save to ~/.agent-browser/tmp/screenshots/
|
||||
agent-browser --cdp text @e1 < PORT > get # Get element text
|
||||
agent-browser --cdp url < PORT > get # Get current URL
|
||||
```
|
||||
|
||||
Read screenshots with the `Read` tool for visual verification.
|
||||
|
||||
### 5. Evaluate JavaScript
|
||||
|
||||
```bash
|
||||
agent-browser --cdp "document.title" < PORT > eval
|
||||
```
|
||||
|
||||
For multi-line JS, use `--stdin`:
|
||||
|
||||
```bash
|
||||
agent-browser --cdp --stdin < PORT > eval << 'EVALEOF'
|
||||
(function() {
|
||||
return JSON.stringify({ title: document.title, url: location.href });
|
||||
})()
|
||||
EVALEOF
|
||||
```
|
||||
|
||||
## Electron (LobeHub Desktop)
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# 1. Kill existing instances
|
||||
pkill -f "Electron" 2> /dev/null
|
||||
pkill -f "electron-vite" 2> /dev/null
|
||||
pkill -f "agent-browser" 2> /dev/null
|
||||
sleep 3
|
||||
|
||||
# 2. Start Electron with CDP (MUST cd to apps/desktop first)
|
||||
cd apps/desktop && ELECTRON_ENABLE_LOGGING=1 npx electron-vite dev -- --remote-debugging-port=9222 > /tmp/electron-dev.log 2>&1 &
|
||||
|
||||
# 3. Wait for startup
|
||||
for i in $(seq 1 12); do
|
||||
sleep 5
|
||||
if strings /tmp/electron-dev.log 2> /dev/null | grep -q "starting electron"; then
|
||||
echo "ready"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# 4. Wait for renderer, then connect
|
||||
sleep 15 && agent-browser --cdp 9222 wait 3000
|
||||
```
|
||||
|
||||
**Critical:** `npx electron-vite dev` MUST run from `apps/desktop/` directory, not project root.
|
||||
|
||||
### LobeHub-Specific Patterns
|
||||
|
||||
#### Access Zustand Store State
|
||||
|
||||
```bash
|
||||
agent-browser --cdp 9222 eval --stdin << 'EVALEOF'
|
||||
(function() {
|
||||
var chat = window.__LOBE_STORES.chat();
|
||||
var ops = Object.values(chat.operations);
|
||||
return JSON.stringify({
|
||||
ops: ops.map(function(o) { return { type: o.type, status: o.status }; }),
|
||||
activeAgent: chat.activeAgentId,
|
||||
activeTopic: chat.activeTopicId,
|
||||
});
|
||||
})()
|
||||
EVALEOF
|
||||
```
|
||||
|
||||
#### Find and Use the Chat Input
|
||||
|
||||
```bash
|
||||
# The chat input is contenteditable — must use -C flag
|
||||
agent-browser --cdp 9222 snapshot -i -C 2>&1 | grep "editable"
|
||||
|
||||
agent-browser --cdp 9222 click @e48
|
||||
agent-browser --cdp 9222 type @e48 "Hello world"
|
||||
agent-browser --cdp 9222 press Enter
|
||||
```
|
||||
|
||||
#### Wait for Agent to Complete
|
||||
|
||||
```bash
|
||||
agent-browser --cdp 9222 eval --stdin << 'EVALEOF'
|
||||
(function() {
|
||||
var chat = window.__LOBE_STORES.chat();
|
||||
var ops = Object.values(chat.operations);
|
||||
var running = ops.filter(function(o) { return o.status === 'running'; });
|
||||
return running.length === 0 ? 'done' : 'running: ' + running.length;
|
||||
})()
|
||||
EVALEOF
|
||||
```
|
||||
|
||||
#### Install Error Interceptor
|
||||
|
||||
```bash
|
||||
agent-browser --cdp 9222 eval --stdin << 'EVALEOF'
|
||||
(function() {
|
||||
window.__CAPTURED_ERRORS = [];
|
||||
var orig = console.error;
|
||||
console.error = function() {
|
||||
var msg = Array.from(arguments).map(function(a) {
|
||||
if (a instanceof Error) return a.message;
|
||||
return typeof a === 'object' ? JSON.stringify(a) : String(a);
|
||||
}).join(' ');
|
||||
window.__CAPTURED_ERRORS.push(msg);
|
||||
orig.apply(console, arguments);
|
||||
};
|
||||
return 'installed';
|
||||
})()
|
||||
EVALEOF
|
||||
|
||||
# Later, check captured errors:
|
||||
agent-browser --cdp 9222 eval "JSON.stringify(window.__CAPTURED_ERRORS)"
|
||||
```
|
||||
|
||||
## Chrome / Web Apps
|
||||
|
||||
```bash
|
||||
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
|
||||
--remote-debugging-port=9222 \
|
||||
--user-data-dir=/tmp/chrome-test-profile \
|
||||
"<URL>" &
|
||||
sleep 5
|
||||
agent-browser --cdp 9222 snapshot -i
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Part 2: osascript (Native macOS App Bot Testing)
|
||||
|
||||
Use AppleScript via `osascript` to control native macOS desktop apps for bot testing. This works with any app that supports macOS Accessibility, without needing CDP or Chromium.
|
||||
|
||||
## Core osascript Patterns
|
||||
|
||||
### Activate an App
|
||||
|
||||
```bash
|
||||
osascript -e 'tell application "Discord" to activate'
|
||||
```
|
||||
|
||||
### Type Text
|
||||
|
||||
```bash
|
||||
# Type character by character (reliable, but slow for long text)
|
||||
osascript -e 'tell application "System Events" to keystroke "Hello world"'
|
||||
|
||||
# Press Enter
|
||||
osascript -e 'tell application "System Events" to key code 36'
|
||||
|
||||
# Press Tab
|
||||
osascript -e 'tell application "System Events" to key code 48'
|
||||
|
||||
# Press Escape
|
||||
osascript -e 'tell application "System Events" to key code 53'
|
||||
```
|
||||
|
||||
### Paste from Clipboard (fast, for long text)
|
||||
|
||||
```bash
|
||||
# Set clipboard and paste — much faster than keystroke for long messages
|
||||
osascript -e 'set the clipboard to "Your long message here"'
|
||||
osascript -e 'tell application "System Events" to keystroke "v" using command down'
|
||||
```
|
||||
|
||||
Or in one shot:
|
||||
|
||||
```bash
|
||||
osascript -e '
|
||||
set the clipboard to "Your long message here"
|
||||
tell application "System Events" to keystroke "v" using command down
|
||||
'
|
||||
```
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
```bash
|
||||
# Cmd+K (quick switcher in Discord/Slack)
|
||||
osascript -e 'tell application "System Events" to keystroke "k" using command down'
|
||||
|
||||
# Cmd+F (search)
|
||||
osascript -e 'tell application "System Events" to keystroke "f" using command down'
|
||||
|
||||
# Cmd+N (new message/chat)
|
||||
osascript -e 'tell application "System Events" to keystroke "n" using command down'
|
||||
|
||||
# Cmd+Shift+K (example: multi-modifier)
|
||||
osascript -e 'tell application "System Events" to keystroke "k" using {command down, shift down}'
|
||||
```
|
||||
|
||||
### Click at Position
|
||||
|
||||
```bash
|
||||
# Click at absolute screen coordinates
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
click at {500, 300}
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
### Get Window Info
|
||||
|
||||
```bash
|
||||
# Get window position and size
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
tell process "Discord"
|
||||
get {position, size} of window 1
|
||||
end tell
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
### Screenshot
|
||||
|
||||
```bash
|
||||
# Full screen
|
||||
screencapture /tmp/screenshot.png
|
||||
|
||||
# Interactive region select
|
||||
screencapture -i /tmp/screenshot.png
|
||||
|
||||
# Specific window (by window ID from CGWindowList)
|
||||
screencapture -l < WINDOW_ID > /tmp/screenshot.png
|
||||
```
|
||||
|
||||
To get window ID for a specific app:
|
||||
|
||||
```bash
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
tell process "Discord"
|
||||
get id of window 1
|
||||
end tell
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
### Read Accessibility Elements
|
||||
|
||||
```bash
|
||||
# Get all UI elements of the frontmost window (can be slow/large)
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
tell process "Discord"
|
||||
entire contents of window 1
|
||||
end tell
|
||||
end tell
|
||||
'
|
||||
|
||||
# Get a specific element's value
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
tell process "Discord"
|
||||
get value of text field 1 of window 1
|
||||
end tell
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
> **Warning:** `entire contents` can be extremely slow on complex UIs. Prefer screenshots + `Read` tool for visual verification.
|
||||
|
||||
### Read Screen Text via Clipboard
|
||||
|
||||
For reading the latest message or response from an app:
|
||||
|
||||
```bash
|
||||
# Select all text in the focused area and copy
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
keystroke "a" using command down
|
||||
keystroke "c" using command down
|
||||
end tell
|
||||
'
|
||||
sleep 0.5
|
||||
# Read clipboard
|
||||
pbpaste
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Client: Discord
|
||||
|
||||
**App name:** `Discord` | **Process name:** `Discord`
|
||||
|
||||
### Activate & Navigate
|
||||
|
||||
```bash
|
||||
# Activate Discord
|
||||
osascript -e 'tell application "Discord" to activate'
|
||||
sleep 1
|
||||
|
||||
# Open Quick Switcher (Cmd+K) to navigate to a channel
|
||||
osascript -e 'tell application "System Events" to keystroke "k" using command down'
|
||||
sleep 0.5
|
||||
osascript -e 'tell application "System Events" to keystroke "bot-testing"'
|
||||
sleep 1
|
||||
osascript -e 'tell application "System Events" to key code 36' # Enter
|
||||
sleep 2
|
||||
```
|
||||
|
||||
### Send Message to Bot
|
||||
|
||||
```bash
|
||||
# The message input is focused after navigating to a channel
|
||||
# Type a message
|
||||
osascript -e 'tell application "System Events" to keystroke "/hello"'
|
||||
sleep 0.5
|
||||
osascript -e 'tell application "System Events" to key code 36' # Enter
|
||||
```
|
||||
|
||||
### Send Long Message (via clipboard)
|
||||
|
||||
```bash
|
||||
osascript -e '
|
||||
tell application "Discord" to activate
|
||||
delay 0.5
|
||||
set the clipboard to "Write a 3000 word essay about space exploration"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36 -- Enter
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
### Verify Bot Response
|
||||
|
||||
```bash
|
||||
# Wait for bot to respond, then screenshot
|
||||
sleep 10
|
||||
screencapture /tmp/discord-bot-response.png
|
||||
# Read with the Read tool for visual verification
|
||||
```
|
||||
|
||||
### Full Bot Test Example
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# test-discord-bot.sh — Send message and verify bot response
|
||||
|
||||
# 1. Activate Discord and navigate to channel
|
||||
osascript -e '
|
||||
tell application "Discord" to activate
|
||||
delay 1
|
||||
-- Quick Switcher
|
||||
tell application "System Events" to keystroke "k" using command down
|
||||
delay 0.5
|
||||
tell application "System Events" to keystroke "bot-testing"
|
||||
delay 1
|
||||
tell application "System Events" to key code 36
|
||||
delay 2
|
||||
'
|
||||
|
||||
# 2. Send test message
|
||||
osascript -e '
|
||||
set the clipboard to "!ping"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36
|
||||
end tell
|
||||
'
|
||||
|
||||
# 3. Wait for response and capture
|
||||
sleep 5
|
||||
screencapture /tmp/discord-test-result.png
|
||||
echo "Screenshot saved to /tmp/discord-test-result.png"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Client: Slack
|
||||
|
||||
**App name:** `Slack` | **Process name:** `Slack`
|
||||
|
||||
### Activate & Navigate
|
||||
|
||||
```bash
|
||||
# Activate Slack
|
||||
osascript -e 'tell application "Slack" to activate'
|
||||
sleep 1
|
||||
|
||||
# Quick Switcher (Cmd+K)
|
||||
osascript -e 'tell application "System Events" to keystroke "k" using command down'
|
||||
sleep 0.5
|
||||
osascript -e 'tell application "System Events" to keystroke "bot-testing"'
|
||||
sleep 1
|
||||
osascript -e 'tell application "System Events" to key code 36' # Enter
|
||||
sleep 2
|
||||
```
|
||||
|
||||
### Send Message to Bot
|
||||
|
||||
```bash
|
||||
# Direct message input (focused after channel nav)
|
||||
osascript -e 'tell application "System Events" to keystroke "@mybot hello"'
|
||||
sleep 0.3
|
||||
osascript -e 'tell application "System Events" to key code 36'
|
||||
```
|
||||
|
||||
### Send Long Message
|
||||
|
||||
```bash
|
||||
osascript -e '
|
||||
tell application "Slack" to activate
|
||||
delay 0.5
|
||||
set the clipboard to "A long test message for the bot..."
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
### Slash Command Test
|
||||
|
||||
```bash
|
||||
osascript -e '
|
||||
tell application "Slack" to activate
|
||||
delay 0.5
|
||||
tell application "System Events"
|
||||
keystroke "/ask What is the meaning of life?"
|
||||
delay 0.5
|
||||
key code 36
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
### Verify Response
|
||||
|
||||
```bash
|
||||
sleep 10
|
||||
screencapture /tmp/slack-bot-response.png
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Client: Telegram
|
||||
|
||||
**App name:** `Telegram` | **Process name:** `Telegram`
|
||||
|
||||
### Activate & Navigate
|
||||
|
||||
```bash
|
||||
# Activate Telegram
|
||||
osascript -e 'tell application "Telegram" to activate'
|
||||
sleep 1
|
||||
|
||||
# Search for a bot (Cmd+F or click search)
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
keystroke "f" using command down
|
||||
delay 0.5
|
||||
keystroke "MyTestBot"
|
||||
delay 1
|
||||
key code 36 -- Enter to select
|
||||
end tell
|
||||
'
|
||||
sleep 2
|
||||
```
|
||||
|
||||
### Send Message to Bot
|
||||
|
||||
```bash
|
||||
# After navigating to bot chat, input is focused
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
keystroke "/start"
|
||||
delay 0.3
|
||||
key code 36
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
### Send Long Message
|
||||
|
||||
```bash
|
||||
osascript -e '
|
||||
tell application "Telegram" to activate
|
||||
delay 0.5
|
||||
set the clipboard to "Tell me about quantum computing in detail"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
### Verify Response
|
||||
|
||||
```bash
|
||||
sleep 10
|
||||
screencapture /tmp/telegram-bot-response.png
|
||||
```
|
||||
|
||||
### Telegram Bot API (programmatic alternative)
|
||||
|
||||
For sending messages directly to the bot's chat without UI:
|
||||
|
||||
```bash
|
||||
# Send message as the bot (for testing webhooks/responses)
|
||||
curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage" \
|
||||
-d "chat_id=$CHAT_ID&text=test message"
|
||||
|
||||
# Get recent updates
|
||||
curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getUpdates?limit=5" | jq .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Client: WeChat / 微信
|
||||
|
||||
**App name:** `微信` or `WeChat` | **Process name:** `WeChat`
|
||||
|
||||
### Activate & Navigate
|
||||
|
||||
```bash
|
||||
# Activate WeChat
|
||||
osascript -e 'tell application "微信" to activate'
|
||||
sleep 1
|
||||
|
||||
# Search for a contact/bot (Cmd+F)
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
keystroke "f" using command down
|
||||
delay 0.5
|
||||
keystroke "TestBot"
|
||||
delay 1
|
||||
key code 36 -- Enter to select
|
||||
end tell
|
||||
'
|
||||
sleep 2
|
||||
```
|
||||
|
||||
### Send Message
|
||||
|
||||
```bash
|
||||
# After navigating to a chat, the input is focused
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
keystroke "Hello bot!"
|
||||
delay 0.3
|
||||
key code 36
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
### Send Long Message (clipboard)
|
||||
|
||||
```bash
|
||||
osascript -e '
|
||||
tell application "微信" to activate
|
||||
delay 0.5
|
||||
set the clipboard to "Please help me with this task..."
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
### Verify Response
|
||||
|
||||
```bash
|
||||
sleep 10
|
||||
screencapture /tmp/wechat-bot-response.png
|
||||
```
|
||||
|
||||
### WeChat-Specific Notes
|
||||
|
||||
- WeChat macOS app name can be `微信` or `WeChat` depending on system language. Try both:
|
||||
```bash
|
||||
osascript -e 'tell application "微信" to activate' 2> /dev/null \
|
||||
|| osascript -e 'tell application "WeChat" to activate'
|
||||
```
|
||||
- WeChat uses **Enter** to send (not Cmd+Enter by default, but configurable)
|
||||
- For multi-line messages without sending, use **Shift+Enter**:
|
||||
```bash
|
||||
osascript -e 'tell application "System Events" to key code 36 using shift down'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Client: Lark / 飞书
|
||||
|
||||
**App name:** `Lark` or `飞书` | **Process name:** `Lark` or `飞书`
|
||||
|
||||
### Activate & Navigate
|
||||
|
||||
```bash
|
||||
# Activate Lark (auto-detects Lark or 飞书)
|
||||
osascript -e 'tell application "Lark" to activate' 2> /dev/null \
|
||||
|| osascript -e 'tell application "飞书" to activate'
|
||||
sleep 1
|
||||
|
||||
# Quick Switcher / Search (Cmd+K)
|
||||
osascript -e 'tell application "System Events" to keystroke "k" using command down'
|
||||
sleep 0.5
|
||||
osascript -e '
|
||||
set the clipboard to "bot-testing"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 1.5
|
||||
key code 36 -- Enter
|
||||
end tell
|
||||
'
|
||||
sleep 2
|
||||
```
|
||||
|
||||
### Send Message to Bot
|
||||
|
||||
```bash
|
||||
osascript -e '
|
||||
set the clipboard to "@MyBot help me with this task"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36 -- Enter
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
### Verify Response
|
||||
|
||||
```bash
|
||||
sleep 10
|
||||
screencapture /tmp/lark-bot-response.png
|
||||
```
|
||||
|
||||
### Lark-Specific Notes
|
||||
|
||||
- App name varies: `Lark` (international) vs `飞书` (China mainland) — the script auto-detects
|
||||
- Uses `Cmd+K` for quick search (same as Discord/Slack)
|
||||
- Enter sends message by default
|
||||
|
||||
---
|
||||
|
||||
## Client: QQ
|
||||
|
||||
**App name:** `QQ` | **Process name:** `QQ`
|
||||
|
||||
### Activate & Navigate
|
||||
|
||||
```bash
|
||||
osascript -e 'tell application "QQ" to activate'
|
||||
sleep 1
|
||||
|
||||
# Search for contact/group (Cmd+F)
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
keystroke "f" using command down
|
||||
delay 0.8
|
||||
end tell
|
||||
'
|
||||
osascript -e '
|
||||
set the clipboard to "bot-testing"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 1.5
|
||||
key code 36 -- Enter
|
||||
end tell
|
||||
'
|
||||
sleep 2
|
||||
```
|
||||
|
||||
### Send Message to Bot
|
||||
|
||||
```bash
|
||||
osascript -e '
|
||||
set the clipboard to "Hello bot!"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36 -- Enter
|
||||
end tell
|
||||
'
|
||||
```
|
||||
|
||||
### Verify Response
|
||||
|
||||
```bash
|
||||
sleep 10
|
||||
screencapture /tmp/qq-bot-response.png
|
||||
```
|
||||
|
||||
### QQ-Specific Notes
|
||||
|
||||
- Enter sends message by default; Shift+Enter for newlines
|
||||
- Uses `Cmd+F` for search
|
||||
- Always use clipboard paste for CJK characters
|
||||
|
||||
---
|
||||
|
||||
## Common Bot Testing Workflow (osascript)
|
||||
|
||||
Regardless of platform, the pattern is:
|
||||
|
||||
```bash
|
||||
APP_NAME="Discord" # or "Slack", "Telegram", "微信"
|
||||
CHANNEL="bot-testing"
|
||||
MESSAGE="Hello bot!"
|
||||
WAIT_SECONDS=10
|
||||
|
||||
# 1. Activate
|
||||
osascript -e "tell application \"$APP_NAME\" to activate"
|
||||
sleep 1
|
||||
|
||||
# 2. Navigate to channel/chat (via Quick Switcher or Search)
|
||||
osascript -e 'tell application "System Events" to keystroke "k" using command down'
|
||||
sleep 0.5
|
||||
osascript -e "tell application \"System Events\" to keystroke \"$CHANNEL\""
|
||||
sleep 1
|
||||
osascript -e 'tell application "System Events" to key code 36'
|
||||
sleep 2
|
||||
|
||||
# 3. Send message
|
||||
osascript -e "set the clipboard to \"$MESSAGE\""
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36
|
||||
end tell
|
||||
'
|
||||
|
||||
# 4. Wait for bot response
|
||||
sleep "$WAIT_SECONDS"
|
||||
|
||||
# 5. Screenshot for verification
|
||||
screencapture /tmp/"${APP_NAME,,}"-bot-test.png
|
||||
echo "Result saved to /tmp/${APP_NAME,,}-bot-test.png"
|
||||
```
|
||||
|
||||
### Tips
|
||||
|
||||
- **Use clipboard paste** (`Cmd+V`) for messages containing special characters or long text — `keystroke` can mangle non-ASCII
|
||||
- **Add `delay`** between actions — apps need time to process UI events
|
||||
- **Screenshot for verification** — use `screencapture` + `Read` tool for visual checks
|
||||
- **Use a dedicated test channel/chat** — avoid polluting real conversations
|
||||
- **Check app name** — some apps have different names in different locales (e.g., `微信` vs `WeChat`)
|
||||
- **Accessibility permissions required** — System Events automation requires granting Accessibility access in System Preferences > Privacy & Security > Accessibility
|
||||
|
||||
---
|
||||
|
||||
# Scripts
|
||||
|
||||
Ready-to-use scripts in `.agents/skills/local-testing/scripts/`:
|
||||
|
||||
| Script | Usage |
|
||||
| ------------------------- | --------------------------------------------- |
|
||||
| `capture-app-window.sh` | Capture screenshot of a specific app window |
|
||||
| `record-electron-demo.sh` | Record Electron app demo with ffmpeg |
|
||||
| `test-discord-bot.sh` | Send message to Discord bot via osascript |
|
||||
| `test-slack-bot.sh` | Send message to Slack bot via osascript |
|
||||
| `test-telegram-bot.sh` | Send message to Telegram bot via osascript |
|
||||
| `test-wechat-bot.sh` | Send message to WeChat bot via osascript |
|
||||
| `test-lark-bot.sh` | Send message to Lark / 飞书 bot via osascript |
|
||||
| `test-qq-bot.sh` | Send message to QQ bot via osascript |
|
||||
|
||||
### Window Screenshot Utility
|
||||
|
||||
`capture-app-window.sh` captures a screenshot of a specific app window using `screencapture -l <windowID>`. It uses Swift + CGWindowList to find the window by process name, so screenshots work correctly even when the window is on an external monitor or behind other windows.
|
||||
|
||||
```bash
|
||||
# Standalone usage
|
||||
./.agents/skills/local-testing/scripts/capture-app-window.sh "Discord" /tmp/discord.png
|
||||
./.agents/skills/local-testing/scripts/capture-app-window.sh "Slack" /tmp/slack.png
|
||||
./.agents/skills/local-testing/scripts/capture-app-window.sh "WeChat" /tmp/wechat.png
|
||||
```
|
||||
|
||||
All bot test scripts use this utility automatically for their screenshots.
|
||||
|
||||
### Bot Test Scripts
|
||||
|
||||
All bot test scripts share the same interface:
|
||||
|
||||
```bash
|
||||
./scripts/test-<platform>-bot.sh <channel_or_contact> <message> [wait_seconds] [screenshot_path]
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
# Discord — test a bot in #bot-testing channel
|
||||
./.agents/skills/local-testing/scripts/test-discord-bot.sh "bot-testing" "!ping"
|
||||
./.agents/skills/local-testing/scripts/test-discord-bot.sh "bot-testing" "/ask Tell me a joke" 30
|
||||
|
||||
# Slack — test a bot in #bot-testing channel
|
||||
./.agents/skills/local-testing/scripts/test-slack-bot.sh "bot-testing" "@mybot hello"
|
||||
./.agents/skills/local-testing/scripts/test-slack-bot.sh "bot-testing" "/ask What is 2+2?" 20
|
||||
|
||||
# Telegram — test a bot by username
|
||||
./.agents/skills/local-testing/scripts/test-telegram-bot.sh "MyTestBot" "/start"
|
||||
./.agents/skills/local-testing/scripts/test-telegram-bot.sh "GPTBot" "Hello" 60
|
||||
|
||||
# WeChat — test a bot or send to a contact
|
||||
./.agents/skills/local-testing/scripts/test-wechat-bot.sh "文件传输助手" "test message" 5
|
||||
./.agents/skills/local-testing/scripts/test-wechat-bot.sh "MyBot" "Tell me a joke" 30
|
||||
|
||||
# Lark/飞书 — test a bot in a group chat
|
||||
./.agents/skills/local-testing/scripts/test-lark-bot.sh "bot-testing" "@MyBot hello"
|
||||
./.agents/skills/local-testing/scripts/test-lark-bot.sh "bot-testing" "Help me with this" 30
|
||||
|
||||
# QQ — test a bot in a group or direct chat
|
||||
./.agents/skills/local-testing/scripts/test-qq-bot.sh "bot-testing" "Hello bot" 15
|
||||
./.agents/skills/local-testing/scripts/test-qq-bot.sh "MyBot" "/help" 10
|
||||
```
|
||||
|
||||
Each script: activates the app, navigates to the channel/contact, pastes the message via clipboard, sends, waits, and takes a screenshot. Use the `Read` tool on the screenshot for visual verification.
|
||||
|
||||
---
|
||||
|
||||
# Screen Recording
|
||||
|
||||
Record automated demos by combining `ffmpeg` screen capture with `agent-browser` automation. The script `.agents/skills/local-testing/scripts/record-electron-demo.sh` handles the full lifecycle for Electron.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
# Run the built-in demo (queue-edit feature)
|
||||
./.agents/skills/local-testing/scripts/record-electron-demo.sh
|
||||
|
||||
# Run a custom automation script
|
||||
./.agents/skills/local-testing/scripts/record-electron-demo.sh ./my-demo.sh /tmp/my-demo.mp4
|
||||
```
|
||||
|
||||
The script automatically:
|
||||
|
||||
1. Starts Electron with CDP and waits for SPA to load
|
||||
2. Detects window position, screen, and Retina scale via Swift/CGWindowList
|
||||
3. Records only the Electron window region using `ffmpeg -f avfoundation` with crop
|
||||
4. Runs the demo (built-in or custom script receiving CDP port as `$1`)
|
||||
5. Stops recording and cleans up
|
||||
|
||||
---
|
||||
|
||||
# Gotchas
|
||||
|
||||
### agent-browser
|
||||
|
||||
- **Daemon can get stuck** — if commands hang, `pkill -f agent-browser` to reset
|
||||
- **`agent-browser wait` blocks the daemon** — for waits >30s, use bash `sleep`
|
||||
- **HMR invalidates everything** — after code changes, refs break. Re-snapshot or restart
|
||||
- **`snapshot -i` doesn't find contenteditable** — use `snapshot -i -C` for rich text editors
|
||||
- **`fill` doesn't work on contenteditable** — use `type` for chat inputs
|
||||
- **Screenshots go to `~/.agent-browser/tmp/screenshots/`** — read them with the `Read` tool
|
||||
|
||||
### Electron-specific
|
||||
|
||||
- **`npx electron-vite dev` must run from `apps/desktop/`** — running from project root fails silently
|
||||
- **Don't resize the Electron window after load** — resizing triggers full SPA reload
|
||||
- **Store is at `window.__LOBE_STORES`** not `window.__ZUSTAND_STORES__`
|
||||
|
||||
### osascript
|
||||
|
||||
- **Accessibility permission required** — first run will prompt for access; grant it in System Preferences > Privacy & Security > Accessibility for Terminal / iTerm / Claude Code
|
||||
- **`keystroke` is slow for long text** — always use clipboard paste (`Cmd+V`) for messages over \~20 characters
|
||||
- **`keystroke` can mangle non-ASCII** — use clipboard paste for Chinese, emoji, or special characters
|
||||
- **`key code 36` is Enter** — this is the hardware key code, works regardless of keyboard layout
|
||||
- **`entire contents` is extremely slow** — avoid for complex UIs; use screenshots instead
|
||||
- **App name varies by locale** — `微信` vs `WeChat`, `企业微信` vs `WeCom`; handle both
|
||||
- **WeChat Enter sends immediately** — use `Shift+Enter` for newlines within a message
|
||||
- **Rate limiting** — don't send messages too fast; platforms may throttle or flag automated input
|
||||
- **Lark / 飞书 app name varies** — `Lark` (international) vs `飞书` (China mainland); scripts auto-detect
|
||||
- **QQ uses `Cmd+F` for search** — not `Cmd+K` like Discord/Slack/Lark
|
||||
- **Bot response times vary** — AI-powered bots may take 10-60s; use generous sleep values
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# capture-app-window.sh — Capture a screenshot of a specific app window
|
||||
#
|
||||
# Uses CGWindowList via Swift to find the window by process name, then
|
||||
# screencapture -l <windowID> to capture only that window.
|
||||
# Falls back to full-screen capture if the window is not found.
|
||||
#
|
||||
# Usage:
|
||||
# ./capture-app-window.sh <process_name> <output_path>
|
||||
#
|
||||
# Arguments:
|
||||
# process_name — The process/owner name as shown in Activity Monitor
|
||||
# (e.g., "Discord", "Slack", "Telegram", "WeChat", "QQ", "Lark")
|
||||
# output_path — Path to save the screenshot (e.g., /tmp/screenshot.png)
|
||||
#
|
||||
# Examples:
|
||||
# ./capture-app-window.sh "Discord" /tmp/discord.png
|
||||
# ./capture-app-window.sh "Slack" /tmp/slack.png
|
||||
# ./capture-app-window.sh "微信" /tmp/wechat.png
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
PROCESS="${1:?Usage: capture-app-window.sh <process_name> <output_path>}"
|
||||
OUTPUT="${2:?Usage: capture-app-window.sh <process_name> <output_path>}"
|
||||
|
||||
# Find the CGWindowID for the target process using Swift + CGWindowList
|
||||
# Pass process name via environment variable (swift -e doesn't support -- args)
|
||||
WINDOW_ID=$(TARGET_PROCESS="$PROCESS" swift -e '
|
||||
import Cocoa
|
||||
import Foundation
|
||||
let target = ProcessInfo.processInfo.environment["TARGET_PROCESS"] ?? ""
|
||||
let windowList = CGWindowListCopyWindowInfo([.optionAll], kCGNullWindowID) as! [[String: Any]]
|
||||
for w in windowList {
|
||||
let owner = w["kCGWindowOwnerName"] as? String ?? ""
|
||||
let layer = w["kCGWindowLayer"] as? Int ?? -1
|
||||
let bounds = w["kCGWindowBounds"] as? [String: Any] ?? [:]
|
||||
let ww = bounds["Width"] as? Double ?? 0
|
||||
let wh = bounds["Height"] as? Double ?? 0
|
||||
let wid = w["kCGWindowNumber"] as? Int ?? 0
|
||||
// Match process name, normal window layer (0), and reasonable size
|
||||
if owner == target && layer == 0 && ww > 200 && wh > 200 {
|
||||
print(wid)
|
||||
break
|
||||
}
|
||||
}
|
||||
' 2>/dev/null || true)
|
||||
|
||||
if [ -n "$WINDOW_ID" ]; then
|
||||
screencapture -l "$WINDOW_ID" -x "$OUTPUT"
|
||||
else
|
||||
echo "[capture] Warning: Could not find window for '$PROCESS', falling back to full screen"
|
||||
screencapture -x "$OUTPUT"
|
||||
fi
|
||||
@@ -0,0 +1,353 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# record-electron-demo.sh — Record an automated demo of the Electron app
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/record-electron-demo.sh [script.sh] [output.mp4]
|
||||
#
|
||||
# script.sh — A shell script containing agent-browser commands to automate.
|
||||
# It receives the CDP port as $1. Defaults to a built-in queue-edit demo.
|
||||
# output.mp4 — Output file path. Defaults to /tmp/electron-demo.mp4
|
||||
#
|
||||
# Prerequisites:
|
||||
# - agent-browser CLI installed globally
|
||||
# - ffmpeg installed (brew install ffmpeg)
|
||||
# - Electron app NOT already running (script manages lifecycle)
|
||||
#
|
||||
# Examples:
|
||||
# # Run built-in demo
|
||||
# ./scripts/record-electron-demo.sh
|
||||
#
|
||||
# # Run custom automation script
|
||||
# ./scripts/record-electron-demo.sh ./my-demo.sh /tmp/my-demo.mp4
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
CDP_PORT=9222
|
||||
DEMO_SCRIPT="${1:-}"
|
||||
OUTPUT="${2:-/tmp/electron-demo.mp4}"
|
||||
ELECTRON_LOG="/tmp/electron-dev.log"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
RECORD_PID=""
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
cleanup() {
|
||||
echo "[cleanup] Stopping all processes..."
|
||||
[ -n "$RECORD_PID" ] && kill -INT "$RECORD_PID" 2>/dev/null && sleep 2
|
||||
pkill -f "electron-vite" 2>/dev/null || true
|
||||
pkill -f "Electron" 2>/dev/null || true
|
||||
pkill -f "agent-browser" 2>/dev/null || true
|
||||
echo "[cleanup] Done."
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
wait_for_electron() {
|
||||
echo "[wait] Waiting for Electron to start..."
|
||||
for i in $(seq 1 24); do
|
||||
sleep 5
|
||||
if strings "$ELECTRON_LOG" 2>/dev/null | grep -q "starting electron"; then
|
||||
echo "[wait] Electron process ready."
|
||||
return 0
|
||||
fi
|
||||
echo "[wait] Still waiting... (${i}/24)"
|
||||
done
|
||||
echo "[error] Electron failed to start within 120s"
|
||||
exit 1
|
||||
}
|
||||
|
||||
wait_for_renderer() {
|
||||
echo "[wait] Waiting for renderer to load..."
|
||||
sleep 15
|
||||
agent-browser --cdp "$CDP_PORT" wait 3000
|
||||
|
||||
# Poll until interactive elements appear (SPA may take extra time)
|
||||
for i in $(seq 1 12); do
|
||||
local snap
|
||||
snap=$(agent-browser --cdp "$CDP_PORT" snapshot -i 2>&1)
|
||||
if echo "$snap" | grep -q 'link "'; then
|
||||
echo "[wait] Renderer ready (interactive elements found)."
|
||||
return 0
|
||||
fi
|
||||
echo "[wait] SPA still loading... (${i}/12)"
|
||||
sleep 5
|
||||
done
|
||||
echo "[warn] Timed out waiting for interactive elements, proceeding anyway."
|
||||
}
|
||||
|
||||
get_window_and_screen_info() {
|
||||
# Returns: window_x window_y window_w window_h screen_index
|
||||
# Uses Swift to find the Electron window bounds and which screen it's on
|
||||
swift -e '
|
||||
import Cocoa
|
||||
let windowList = CGWindowListCopyWindowInfo([.optionAll], kCGNullWindowID) as! [[String: Any]]
|
||||
for w in windowList {
|
||||
let owner = w["kCGWindowOwnerName"] as? String ?? ""
|
||||
let name = w["kCGWindowName"] as? String ?? ""
|
||||
let layer = w["kCGWindowLayer"] as? Int ?? -1
|
||||
let bounds = w["kCGWindowBounds"] as? [String: Any] ?? [:]
|
||||
let wx = bounds["X"] as? Double ?? 0
|
||||
let wy = bounds["Y"] as? Double ?? 0
|
||||
let ww = bounds["Width"] as? Double ?? 0
|
||||
let wh = bounds["Height"] as? Double ?? 0
|
||||
if (owner == "Electron" || owner == "LobeHub") && layer == 0 && name == "LobeHub" && ww > 200 && wh > 200 {
|
||||
// Find which screen this window is on
|
||||
let screens = NSScreen.screens
|
||||
var screenIdx = 0
|
||||
let windowCenter = NSPoint(x: wx + ww / 2, y: wy + wh / 2)
|
||||
for (i, screen) in screens.enumerated() {
|
||||
let frame = screen.frame
|
||||
// Convert CG coords (top-left origin) to NSScreen coords (bottom-left origin)
|
||||
let mainHeight = screens[0].frame.height
|
||||
let screenTop = mainHeight - frame.origin.y - frame.height
|
||||
let screenBottom = screenTop + frame.height
|
||||
let screenLeft = frame.origin.x
|
||||
let screenRight = screenLeft + frame.width
|
||||
if windowCenter.x >= screenLeft && windowCenter.x <= screenRight &&
|
||||
windowCenter.y >= screenTop && windowCenter.y <= screenBottom {
|
||||
screenIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
// Compute window position relative to the screen it is on
|
||||
let screen = screens[screenIdx]
|
||||
let mainHeight = screens[0].frame.height
|
||||
let screenTop = mainHeight - screen.frame.origin.y - screen.frame.height
|
||||
let relX = wx - screen.frame.origin.x
|
||||
let relY = wy - screenTop
|
||||
let scale = Int(screen.backingScaleFactor)
|
||||
print("\(Int(relX)) \(Int(relY)) \(Int(ww)) \(Int(wh)) \(screenIdx) \(scale)")
|
||||
break
|
||||
}
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
start_recording() {
|
||||
local rel_x=$1 rel_y=$2 w=$3 h=$4 screen_idx=$5 scale=$6
|
||||
|
||||
# ffmpeg avfoundation device index for screens
|
||||
# List devices and find the one matching our screen index
|
||||
local device_idx
|
||||
device_idx=$(ffmpeg -f avfoundation -list_devices true -i "" 2>&1 \
|
||||
| grep "Capture screen ${screen_idx}" \
|
||||
| grep -oE '\[[0-9]+\]' | tr -d '[]' || true)
|
||||
|
||||
if [ -z "$device_idx" ]; then
|
||||
echo "[warn] Could not find capture device for screen $screen_idx, trying default (3)"
|
||||
device_idx=3
|
||||
fi
|
||||
|
||||
# Scale coordinates to native resolution
|
||||
local cx=$((rel_x * scale))
|
||||
local cy=$((rel_y * scale))
|
||||
local cw=$((w * scale))
|
||||
local ch=$((h * scale))
|
||||
|
||||
echo "[record] Window: ${rel_x},${rel_y} ${w}x${h} on screen ${screen_idx} (scale=${scale})"
|
||||
echo "[record] Crop: ${cx},${cy} ${cw}x${ch}, device: ${device_idx}"
|
||||
echo "[record] Output: $OUTPUT"
|
||||
|
||||
ffmpeg -y \
|
||||
-f avfoundation -framerate 30 -capture_cursor 1 -i "${device_idx}:" \
|
||||
-vf "crop=${cw}:${ch}:${cx}:${cy},scale=${w}:${h}" \
|
||||
-c:v libx264 -crf 23 -preset fast -an \
|
||||
"$OUTPUT" \
|
||||
> /tmp/ffmpeg-record.log 2>&1 &
|
||||
RECORD_PID=$!
|
||||
sleep 2
|
||||
|
||||
if ! kill -0 "$RECORD_PID" 2>/dev/null; then
|
||||
echo "[error] ffmpeg failed to start. Log:"
|
||||
cat /tmp/ffmpeg-record.log
|
||||
RECORD_PID=""
|
||||
return 1
|
||||
fi
|
||||
echo "[record] Recording started (PID=$RECORD_PID)"
|
||||
}
|
||||
|
||||
stop_recording() {
|
||||
if [ -n "$RECORD_PID" ]; then
|
||||
echo "[record] Stopping recording..."
|
||||
kill -INT "$RECORD_PID" 2>/dev/null || true
|
||||
wait "$RECORD_PID" 2>/dev/null || true
|
||||
RECORD_PID=""
|
||||
echo "[record] Saved to $OUTPUT"
|
||||
ls -lh "$OUTPUT"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Built-in demo: Queue Edit ────────────────────────────────────────
|
||||
|
||||
find_input_ref() {
|
||||
local port=$1
|
||||
agent-browser --cdp "$port" snapshot -i -C 2>&1 \
|
||||
| grep "editable" \
|
||||
| grep -oE 'ref=e[0-9]+' \
|
||||
| head -1 \
|
||||
| sed 's/ref=//'
|
||||
}
|
||||
|
||||
builtin_demo() {
|
||||
local port=$1
|
||||
|
||||
echo "[demo] Step 1: Navigate to first available agent"
|
||||
local snapshot agent_ref
|
||||
snapshot=$(agent-browser --cdp "$port" snapshot -i 2>&1)
|
||||
# Try Lobe AI first, then fall back to any agent link in the sidebar
|
||||
agent_ref=$(echo "$snapshot" | grep -oE 'link "Lobe AI" \[ref=e[0-9]+\]' | grep -oE 'e[0-9]+' || true)
|
||||
if [ -z "$agent_ref" ]; then
|
||||
# Pick the first agent-like link (skip nav links)
|
||||
agent_ref=$(echo "$snapshot" | grep 'link "' | grep -vE '"Home"|"Pages"|"Settings"|"Search"|"Resources"|"Marketplace"' | head -1 | grep -oE 'ref=e[0-9]+' | sed 's/ref=//' || true)
|
||||
fi
|
||||
if [ -z "$agent_ref" ]; then
|
||||
echo "[error] No agent link found in snapshot"
|
||||
echo "$snapshot" | head -30
|
||||
return 1
|
||||
fi
|
||||
echo "[demo] Clicking agent ref: @$agent_ref"
|
||||
agent-browser --cdp "$port" click "@$agent_ref"
|
||||
sleep 3
|
||||
|
||||
echo "[demo] Step 2: Send first message (triggers AI generation)"
|
||||
local input_ref
|
||||
input_ref=$(find_input_ref "$port")
|
||||
agent-browser --cdp "$port" click "@$input_ref"
|
||||
agent-browser --cdp "$port" type "@$input_ref" "Write a 3000 word essay about the complete history of space exploration from Sputnik to the James Webb Space Telescope"
|
||||
sleep 1
|
||||
agent-browser --cdp "$port" press Enter
|
||||
sleep 3
|
||||
|
||||
echo "[demo] Step 3: Queue message 1"
|
||||
input_ref=$(find_input_ref "$port")
|
||||
agent-browser --cdp "$port" click "@$input_ref"
|
||||
agent-browser --cdp "$port" type "@$input_ref" "This message should be edited"
|
||||
sleep 1
|
||||
agent-browser --cdp "$port" press Enter
|
||||
sleep 1
|
||||
|
||||
echo "[demo] Step 4: Queue message 2"
|
||||
input_ref=$(find_input_ref "$port")
|
||||
agent-browser --cdp "$port" click "@$input_ref"
|
||||
agent-browser --cdp "$port" type "@$input_ref" "Another queued message"
|
||||
sleep 1
|
||||
agent-browser --cdp "$port" press Enter
|
||||
sleep 1
|
||||
|
||||
echo "[demo] Step 5: Verify queue has messages"
|
||||
local queue_count
|
||||
queue_count=$(agent-browser --cdp "$port" eval --stdin << 'EVALEOF'
|
||||
(function() {
|
||||
var chat = window.__LOBE_STORES.chat();
|
||||
var total = 0;
|
||||
Object.keys(chat.queuedMessages).forEach(function(k) {
|
||||
total += chat.queuedMessages[k].length;
|
||||
});
|
||||
return String(total);
|
||||
})()
|
||||
EVALEOF
|
||||
)
|
||||
echo "[demo] Queue count: $queue_count"
|
||||
|
||||
if [ "$queue_count" = "0" ] || [ "$queue_count" = '"0"' ]; then
|
||||
echo "[demo] Queue was already drained. Retrying..."
|
||||
input_ref=$(find_input_ref "$port")
|
||||
agent-browser --cdp "$port" click "@$input_ref"
|
||||
agent-browser --cdp "$port" type "@$input_ref" "Now write another 3000 word essay about artificial intelligence from Turing to transformers covering every major breakthrough"
|
||||
sleep 1
|
||||
agent-browser --cdp "$port" press Enter
|
||||
sleep 2
|
||||
input_ref=$(find_input_ref "$port")
|
||||
agent-browser --cdp "$port" click "@$input_ref"
|
||||
agent-browser --cdp "$port" type "@$input_ref" "This message should be edited"
|
||||
sleep 1
|
||||
agent-browser --cdp "$port" press Enter
|
||||
sleep 1
|
||||
input_ref=$(find_input_ref "$port")
|
||||
agent-browser --cdp "$port" click "@$input_ref"
|
||||
agent-browser --cdp "$port" type "@$input_ref" "Another queued message"
|
||||
sleep 1
|
||||
agent-browser --cdp "$port" press Enter
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
echo "[demo] Step 6: Scroll to show queue tray"
|
||||
agent-browser --cdp "$port" scroll down 5000
|
||||
sleep 2
|
||||
|
||||
echo "[demo] Step 7: Click edit button on first queued message"
|
||||
agent-browser --cdp "$port" eval --stdin << 'EVALEOF'
|
||||
(function() {
|
||||
var chat = window.__LOBE_STORES.chat();
|
||||
var keys = Object.keys(chat.queuedMessages);
|
||||
for (var k = 0; k < keys.length; k++) {
|
||||
var queue = chat.queuedMessages[keys[k]];
|
||||
if (queue.length > 0) {
|
||||
var targetText = queue[0].content;
|
||||
var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null);
|
||||
while (walker.nextNode()) {
|
||||
var node = walker.currentNode;
|
||||
if (node.textContent.trim() === targetText) {
|
||||
var row = node.parentElement.parentElement;
|
||||
var buttons = row.querySelectorAll('[role="button"]');
|
||||
if (buttons.length >= 1) {
|
||||
buttons[0].click();
|
||||
return 'clicked edit on: ' + targetText;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 'edit button not found';
|
||||
})()
|
||||
EVALEOF
|
||||
sleep 3
|
||||
|
||||
echo "[demo] Step 8: Show result — content restored to input"
|
||||
sleep 3
|
||||
|
||||
echo "[demo] Complete!"
|
||||
}
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────
|
||||
|
||||
echo "=== Electron Demo Recorder ==="
|
||||
|
||||
# 1. Kill existing instances
|
||||
echo "[setup] Cleaning up existing processes..."
|
||||
pkill -f "Electron" 2>/dev/null || true
|
||||
pkill -f "electron-vite" 2>/dev/null || true
|
||||
pkill -f "agent-browser" 2>/dev/null || true
|
||||
sleep 3
|
||||
|
||||
# 2. Start Electron
|
||||
echo "[setup] Starting Electron..."
|
||||
cd "$PROJECT_ROOT/apps/desktop"
|
||||
ELECTRON_ENABLE_LOGGING=1 npx electron-vite dev -- --remote-debugging-port="$CDP_PORT" > "$ELECTRON_LOG" 2>&1 &
|
||||
|
||||
wait_for_electron
|
||||
wait_for_renderer
|
||||
|
||||
# 3. Get window position and start recording
|
||||
WIN_INFO=$(get_window_and_screen_info)
|
||||
if [ -z "$WIN_INFO" ]; then
|
||||
echo "[error] Could not find Electron window"
|
||||
exit 1
|
||||
fi
|
||||
read -r WIN_X WIN_Y WIN_W WIN_H SCREEN_IDX SCALE <<< "$WIN_INFO"
|
||||
start_recording "$WIN_X" "$WIN_Y" "$WIN_W" "$WIN_H" "$SCREEN_IDX" "$SCALE"
|
||||
|
||||
# 4. Run demo script
|
||||
if [ -n "$DEMO_SCRIPT" ] && [ -f "$DEMO_SCRIPT" ]; then
|
||||
echo "[demo] Running custom script: $DEMO_SCRIPT"
|
||||
bash "$DEMO_SCRIPT" "$CDP_PORT"
|
||||
else
|
||||
echo "[demo] Running built-in queue-edit demo"
|
||||
builtin_demo "$CDP_PORT"
|
||||
fi
|
||||
|
||||
# 5. Stop recording
|
||||
stop_recording
|
||||
|
||||
echo "=== Done! Output: $OUTPUT ==="
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# test-discord-bot.sh — Send a message to a Discord bot and capture the response
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/test-discord-bot.sh <channel> <message> [wait_seconds] [screenshot_path]
|
||||
#
|
||||
# channel — Channel name to navigate to via Quick Switcher (Cmd+K)
|
||||
# message — Message to send to the bot
|
||||
# wait_seconds — Seconds to wait for bot response (default: 10)
|
||||
# screenshot_path — Output screenshot path (default: /tmp/discord-bot-test.png)
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Discord desktop app installed and logged in
|
||||
# - Accessibility permission granted (System Preferences > Privacy > Accessibility)
|
||||
#
|
||||
# Examples:
|
||||
# ./scripts/test-discord-bot.sh "bot-testing" "!ping"
|
||||
# ./scripts/test-discord-bot.sh "bot-testing" "/ask Tell me a joke" 30
|
||||
# ./scripts/test-discord-bot.sh "general" "Hello bot" 15 /tmp/my-test.png
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
CHANNEL="${1:?Usage: test-discord-bot.sh <channel> <message> [wait_seconds] [screenshot_path]}"
|
||||
MESSAGE="${2:?Usage: test-discord-bot.sh <channel> <message> [wait_seconds] [screenshot_path]}"
|
||||
WAIT="${3:-10}"
|
||||
SCREENSHOT="${4:-/tmp/discord-bot-test.png}"
|
||||
|
||||
APP="Discord"
|
||||
|
||||
echo "[$APP] Activating..."
|
||||
osascript -e "tell application \"$APP\" to activate"
|
||||
sleep 1
|
||||
|
||||
echo "[$APP] Navigating to channel: $CHANNEL"
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
-- Quick Switcher
|
||||
keystroke "k" using command down
|
||||
delay 0.8
|
||||
keystroke "'"$CHANNEL"'"
|
||||
delay 1.5
|
||||
key code 36 -- Enter
|
||||
end tell
|
||||
'
|
||||
sleep 2
|
||||
|
||||
echo "[$APP] Sending message: $MESSAGE"
|
||||
osascript -e '
|
||||
set the clipboard to "'"$MESSAGE"'"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36 -- Enter
|
||||
end tell
|
||||
'
|
||||
|
||||
echo "[$APP] Waiting ${WAIT}s for bot response..."
|
||||
sleep "$WAIT"
|
||||
|
||||
echo "[$APP] Capturing screenshot..."
|
||||
"$SCRIPT_DIR/capture-app-window.sh" "$APP" "$SCREENSHOT"
|
||||
echo "[$APP] Done! Screenshot saved to $SCREENSHOT"
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# test-lark-bot.sh — Send a message to a Lark/Feishu bot and capture the response
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/test-lark-bot.sh <chat> <message> [wait_seconds] [screenshot_path]
|
||||
#
|
||||
# chat — Chat or contact name to search for
|
||||
# message — Message to send to the bot
|
||||
# wait_seconds — Seconds to wait for bot response (default: 10)
|
||||
# screenshot_path — Output screenshot path (default: /tmp/lark-bot-test.png)
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Lark (飞书) desktop app installed and logged in
|
||||
# - Accessibility permission granted (System Preferences > Privacy > Accessibility)
|
||||
#
|
||||
# Notes:
|
||||
# - The app name may be "Lark" or "飞书" depending on version/locale
|
||||
# - Uses Cmd+K to open search/quick switcher
|
||||
# - Enter sends message by default
|
||||
#
|
||||
# Examples:
|
||||
# ./scripts/test-lark-bot.sh "TestBot" "Hello"
|
||||
# ./scripts/test-lark-bot.sh "bot-testing" "/ask Tell me a joke" 30
|
||||
# ./scripts/test-lark-bot.sh "MyBot" "Help me summarize this" 60 /tmp/my-test.png
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
CHAT="${1:?Usage: test-lark-bot.sh <chat> <message> [wait_seconds] [screenshot_path]}"
|
||||
MESSAGE="${2:?Usage: test-lark-bot.sh <chat> <message> [wait_seconds] [screenshot_path]}"
|
||||
WAIT="${3:-10}"
|
||||
SCREENSHOT="${4:-/tmp/lark-bot-test.png}"
|
||||
|
||||
# Detect app name — "Lark" or "飞书"
|
||||
APP=""
|
||||
if osascript -e 'tell application "Lark" to name' &>/dev/null; then
|
||||
APP="Lark"
|
||||
elif osascript -e 'tell application "飞书" to name' &>/dev/null; then
|
||||
APP="飞书"
|
||||
else
|
||||
echo "[error] Lark/飞书 app not found. Install Lark or 飞书."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[$APP] Activating..."
|
||||
osascript -e "tell application \"$APP\" to activate"
|
||||
sleep 1
|
||||
|
||||
echo "[$APP] Searching for chat: $CHAT"
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
-- Quick Switcher / Search (Cmd+K)
|
||||
keystroke "k" using command down
|
||||
delay 0.8
|
||||
end tell
|
||||
'
|
||||
# Use clipboard for chat name (supports CJK characters)
|
||||
osascript -e '
|
||||
set the clipboard to "'"$CHAT"'"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 1.5
|
||||
key code 36 -- Enter to select first result
|
||||
end tell
|
||||
'
|
||||
sleep 2
|
||||
|
||||
echo "[$APP] Sending message: $MESSAGE"
|
||||
osascript -e '
|
||||
set the clipboard to "'"$MESSAGE"'"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36 -- Enter to send
|
||||
end tell
|
||||
'
|
||||
|
||||
echo "[$APP] Waiting ${WAIT}s for bot response..."
|
||||
sleep "$WAIT"
|
||||
|
||||
echo "[$APP] Capturing screenshot..."
|
||||
"$SCRIPT_DIR/capture-app-window.sh" "$APP" "$SCREENSHOT"
|
||||
echo "[$APP] Done! Screenshot saved to $SCREENSHOT"
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# test-qq-bot.sh — Send a message to a QQ bot and capture the response
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/test-qq-bot.sh <contact> <message> [wait_seconds] [screenshot_path]
|
||||
#
|
||||
# contact — Contact, group, or bot name to search for
|
||||
# message — Message to send
|
||||
# wait_seconds — Seconds to wait for bot response (default: 10)
|
||||
# screenshot_path — Output screenshot path (default: /tmp/qq-bot-test.png)
|
||||
#
|
||||
# Prerequisites:
|
||||
# - QQ desktop app installed and logged in
|
||||
# - Accessibility permission granted (System Preferences > Privacy > Accessibility)
|
||||
#
|
||||
# Notes:
|
||||
# - The app name is "QQ"
|
||||
# - Uses Cmd+F to open search
|
||||
# - Enter sends message by default; Shift+Enter for newlines
|
||||
# - Uses clipboard paste for CJK character support
|
||||
#
|
||||
# Examples:
|
||||
# ./scripts/test-qq-bot.sh "TestBot" "Hello"
|
||||
# ./scripts/test-qq-bot.sh "bot-testing" "Hello bot" 30
|
||||
# ./scripts/test-qq-bot.sh "MyBot" "/help" 15 /tmp/my-test.png
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
CONTACT="${1:?Usage: test-qq-bot.sh <contact> <message> [wait_seconds] [screenshot_path]}"
|
||||
MESSAGE="${2:?Usage: test-qq-bot.sh <contact> <message> [wait_seconds] [screenshot_path]}"
|
||||
WAIT="${3:-10}"
|
||||
SCREENSHOT="${4:-/tmp/qq-bot-test.png}"
|
||||
|
||||
APP="QQ"
|
||||
|
||||
echo "[$APP] Activating..."
|
||||
osascript -e "tell application \"$APP\" to activate"
|
||||
sleep 1
|
||||
|
||||
echo "[$APP] Searching for contact: $CONTACT"
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
-- Search (Cmd+F)
|
||||
keystroke "f" using command down
|
||||
delay 0.8
|
||||
end tell
|
||||
'
|
||||
# Use clipboard for contact name (supports CJK characters)
|
||||
osascript -e '
|
||||
set the clipboard to "'"$CONTACT"'"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 1.5
|
||||
key code 36 -- Enter to select first result
|
||||
end tell
|
||||
'
|
||||
sleep 2
|
||||
|
||||
echo "[$APP] Sending message: $MESSAGE"
|
||||
osascript -e '
|
||||
set the clipboard to "'"$MESSAGE"'"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36 -- Enter to send
|
||||
end tell
|
||||
'
|
||||
|
||||
echo "[$APP] Waiting ${WAIT}s for bot response..."
|
||||
sleep "$WAIT"
|
||||
|
||||
echo "[$APP] Capturing screenshot..."
|
||||
"$SCRIPT_DIR/capture-app-window.sh" "$APP" "$SCREENSHOT"
|
||||
echo "[$APP] Done! Screenshot saved to $SCREENSHOT"
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# test-slack-bot.sh — Send a message to a Slack bot and capture the response
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/test-slack-bot.sh <channel> <message> [wait_seconds] [screenshot_path]
|
||||
#
|
||||
# channel — Channel name to navigate to via Quick Switcher (Cmd+K)
|
||||
# message — Message to send (e.g., "@mybot hello" or "/ask question")
|
||||
# wait_seconds — Seconds to wait for bot response (default: 10)
|
||||
# screenshot_path — Output screenshot path (default: /tmp/slack-bot-test.png)
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Slack desktop app installed and logged in
|
||||
# - Accessibility permission granted (System Preferences > Privacy > Accessibility)
|
||||
#
|
||||
# Examples:
|
||||
# ./scripts/test-slack-bot.sh "bot-testing" "@mybot hello"
|
||||
# ./scripts/test-slack-bot.sh "bot-testing" "/ask What is 2+2?" 20
|
||||
# ./scripts/test-slack-bot.sh "general" "Hey bot" 15 /tmp/my-test.png
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
CHANNEL="${1:?Usage: test-slack-bot.sh <channel> <message> [wait_seconds] [screenshot_path]}"
|
||||
MESSAGE="${2:?Usage: test-slack-bot.sh <channel> <message> [wait_seconds] [screenshot_path]}"
|
||||
WAIT="${3:-10}"
|
||||
SCREENSHOT="${4:-/tmp/slack-bot-test.png}"
|
||||
|
||||
APP="Slack"
|
||||
|
||||
echo "[$APP] Activating..."
|
||||
osascript -e "tell application \"$APP\" to activate"
|
||||
sleep 1
|
||||
|
||||
echo "[$APP] Navigating to channel: $CHANNEL"
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
-- Quick Switcher
|
||||
keystroke "k" using command down
|
||||
delay 0.8
|
||||
keystroke "'"$CHANNEL"'"
|
||||
delay 1.5
|
||||
key code 36 -- Enter
|
||||
end tell
|
||||
'
|
||||
sleep 2
|
||||
|
||||
echo "[$APP] Sending message: $MESSAGE"
|
||||
osascript -e '
|
||||
set the clipboard to "'"$MESSAGE"'"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36 -- Enter
|
||||
end tell
|
||||
'
|
||||
|
||||
echo "[$APP] Waiting ${WAIT}s for bot response..."
|
||||
sleep "$WAIT"
|
||||
|
||||
echo "[$APP] Capturing screenshot..."
|
||||
"$SCRIPT_DIR/capture-app-window.sh" "$APP" "$SCREENSHOT"
|
||||
echo "[$APP] Done! Screenshot saved to $SCREENSHOT"
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# test-telegram-bot.sh — Send a message to a Telegram bot and capture the response
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/test-telegram-bot.sh <bot_or_chat> <message> [wait_seconds] [screenshot_path]
|
||||
#
|
||||
# bot_or_chat — Bot username or chat name to search for
|
||||
# message — Message to send to the bot
|
||||
# wait_seconds — Seconds to wait for bot response (default: 10)
|
||||
# screenshot_path — Output screenshot path (default: /tmp/telegram-bot-test.png)
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Telegram desktop app installed and logged in
|
||||
# - Accessibility permission granted (System Preferences > Privacy > Accessibility)
|
||||
#
|
||||
# Notes:
|
||||
# - The app name may be "Telegram" or "Telegram Desktop" depending on installation
|
||||
# - Uses Cmd+F to search for the bot, then Enter to open the chat
|
||||
#
|
||||
# Examples:
|
||||
# ./scripts/test-telegram-bot.sh "MyTestBot" "/start"
|
||||
# ./scripts/test-telegram-bot.sh "MyTestBot" "Hello bot" 30
|
||||
# ./scripts/test-telegram-bot.sh "GPTBot" "/ask What is AI?" 60 /tmp/my-test.png
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
BOT="${1:?Usage: test-telegram-bot.sh <bot_or_chat> <message> [wait_seconds] [screenshot_path]}"
|
||||
MESSAGE="${2:?Usage: test-telegram-bot.sh <bot_or_chat> <message> [wait_seconds] [screenshot_path]}"
|
||||
WAIT="${3:-10}"
|
||||
SCREENSHOT="${4:-/tmp/telegram-bot-test.png}"
|
||||
|
||||
# Detect app name — "Telegram" or "Telegram Desktop"
|
||||
APP=""
|
||||
if osascript -e 'tell application "Telegram" to name' &>/dev/null; then
|
||||
APP="Telegram"
|
||||
elif osascript -e 'tell application "Telegram Desktop" to name' &>/dev/null; then
|
||||
APP="Telegram Desktop"
|
||||
else
|
||||
echo "[error] Telegram app not found. Install Telegram or Telegram Desktop."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[$APP] Activating..."
|
||||
osascript -e "tell application \"$APP\" to activate"
|
||||
sleep 1
|
||||
|
||||
echo "[$APP] Searching for: $BOT"
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
-- Search (Escape first to clear any existing state)
|
||||
key code 53 -- Escape
|
||||
delay 0.3
|
||||
keystroke "f" using command down
|
||||
delay 0.8
|
||||
keystroke "'"$BOT"'"
|
||||
delay 2
|
||||
key code 36 -- Enter to select first result
|
||||
end tell
|
||||
'
|
||||
sleep 2
|
||||
|
||||
echo "[$APP] Sending message: $MESSAGE"
|
||||
osascript -e '
|
||||
set the clipboard to "'"$MESSAGE"'"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36 -- Enter
|
||||
end tell
|
||||
'
|
||||
|
||||
echo "[$APP] Waiting ${WAIT}s for bot response..."
|
||||
sleep "$WAIT"
|
||||
|
||||
echo "[$APP] Capturing screenshot..."
|
||||
"$SCRIPT_DIR/capture-app-window.sh" "$APP" "$SCREENSHOT"
|
||||
echo "[$APP] Done! Screenshot saved to $SCREENSHOT"
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# test-wechat-bot.sh — Send a message to a WeChat bot and capture the response
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/test-wechat-bot.sh <contact> <message> [wait_seconds] [screenshot_path]
|
||||
#
|
||||
# contact — Contact or bot name to search for
|
||||
# message — Message to send
|
||||
# wait_seconds — Seconds to wait for bot response (default: 10)
|
||||
# screenshot_path — Output screenshot path (default: /tmp/wechat-bot-test.png)
|
||||
#
|
||||
# Prerequisites:
|
||||
# - WeChat (微信) desktop app installed and logged in
|
||||
# - Accessibility permission granted (System Preferences > Privacy > Accessibility)
|
||||
#
|
||||
# Notes:
|
||||
# - The app name may be "微信" or "WeChat" depending on system language
|
||||
# - WeChat sends on Enter by default; use Shift+Enter for newlines
|
||||
# - For Chinese text, always uses clipboard paste (keystroke can't handle CJK)
|
||||
#
|
||||
# Examples:
|
||||
# ./scripts/test-wechat-bot.sh "TestBot" "Hello"
|
||||
# ./scripts/test-wechat-bot.sh "文件传输助手" "test message" 5
|
||||
# ./scripts/test-wechat-bot.sh "MyBot" "Tell me a joke" 30 /tmp/my-test.png
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
CONTACT="${1:?Usage: test-wechat-bot.sh <contact> <message> [wait_seconds] [screenshot_path]}"
|
||||
MESSAGE="${2:?Usage: test-wechat-bot.sh <contact> <message> [wait_seconds] [screenshot_path]}"
|
||||
WAIT="${3:-10}"
|
||||
SCREENSHOT="${4:-/tmp/wechat-bot-test.png}"
|
||||
|
||||
# Detect app name — "微信" or "WeChat"
|
||||
APP=""
|
||||
if osascript -e 'tell application "微信" to name' &>/dev/null; then
|
||||
APP="微信"
|
||||
elif osascript -e 'tell application "WeChat" to name' &>/dev/null; then
|
||||
APP="WeChat"
|
||||
else
|
||||
echo "[error] WeChat app not found. Install 微信 (WeChat)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[$APP] Activating..."
|
||||
osascript -e "tell application \"$APP\" to activate"
|
||||
sleep 1
|
||||
|
||||
echo "[$APP] Searching for contact: $CONTACT"
|
||||
osascript -e '
|
||||
tell application "System Events"
|
||||
-- Search (Cmd+F)
|
||||
keystroke "f" using command down
|
||||
delay 0.8
|
||||
end tell
|
||||
'
|
||||
# Use clipboard for contact name (supports CJK characters)
|
||||
osascript -e '
|
||||
set the clipboard to "'"$CONTACT"'"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 1.5
|
||||
key code 36 -- Enter to select first result
|
||||
end tell
|
||||
'
|
||||
sleep 2
|
||||
|
||||
echo "[$APP] Sending message: $MESSAGE"
|
||||
# Always use clipboard paste — keystroke can't handle CJK or special characters
|
||||
osascript -e '
|
||||
set the clipboard to "'"$MESSAGE"'"
|
||||
tell application "System Events"
|
||||
keystroke "v" using command down
|
||||
delay 0.3
|
||||
key code 36 -- Enter to send
|
||||
end tell
|
||||
'
|
||||
|
||||
echo "[$APP] Waiting ${WAIT}s for bot response..."
|
||||
sleep "$WAIT"
|
||||
|
||||
echo "[$APP] Capturing screenshot..."
|
||||
"$SCRIPT_DIR/capture-app-window.sh" "$APP" "$SCREENSHOT"
|
||||
echo "[$APP] Done! Screenshot saved to $SCREENSHOT"
|
||||
@@ -7,7 +7,10 @@ description: React component development guide. Use when working with React comp
|
||||
|
||||
- Use antd-style for complex styles; for simple cases, use inline `style` attribute
|
||||
- Use `Flexbox` and `Center` from `@lobehub/ui` for layouts (see `references/layout-kit.md`)
|
||||
- Component priority: `src/components` > installed packages > `@lobehub/ui` > antd
|
||||
- Component priority: `src/components` > `@lobehub/ui/base-ui` > `@lobehub/ui` > custom implementation
|
||||
- Always prefer `@lobehub/ui/base-ui` primitives (Select, Modal, DropdownMenu, Popover, Switch, ScrollArea…) over antd equivalents
|
||||
- Fall back to `@lobehub/ui` higher-level components when base-ui has no match
|
||||
- Only implement a custom component as a last resort — never reach for antd directly
|
||||
- Use selectors to access zustand store data
|
||||
|
||||
## @lobehub/ui Components
|
||||
@@ -29,18 +32,31 @@ Reference: `node_modules/@lobehub/ui/es/index.mjs` for all available components.
|
||||
|
||||
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` |
|
||||
| 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` + `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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
- **@canisminor1990**: Design, UI components, editor, markdown rendering
|
||||
- **@tjx666**: Image/video generation, vision, cloud version, documentation, TTS, auth, login/register
|
||||
- **@ONLY-yours**: Performance, streaming, settings, general bugs, web platform, marketplace
|
||||
- **@RiverTwilight**: Knowledge base, files (KB-related), group chat
|
||||
- **@Innei**: Knowledge base, files (KB-related), group chat
|
||||
- **@nekomeowww**: Memory, backend, deployment, DevOps
|
||||
- **@sudongyuer**: Mobile app (React Native)
|
||||
- **@sxjeru**: Model providers and configuration
|
||||
@@ -38,8 +38,8 @@ Quick reference for assigning issues based on labels.
|
||||
| `feature:image` | @tjx666 | AI image generation |
|
||||
| `feature:dalle` | @tjx666 | DALL-E related |
|
||||
| `feature:vision` | @tjx666 | Vision/multimodal generation |
|
||||
| `feature:knowledge-base` | @RiverTwilight | Knowledge base and RAG |
|
||||
| `feature:files` | @RiverTwilight | File upload/management (when KB-related)<br>@ONLY-yours (general files) |
|
||||
| `feature:knowledge-base` | @Innei | Knowledge base and RAG |
|
||||
| `feature:files` | @Innei | File upload/management (when KB-related)<br>@ONLY-yours (general files) |
|
||||
| `feature:editor` | @canisminor1990 | Lobe Editor |
|
||||
| `feature:markdown` | @canisminor1990 | Markdown rendering |
|
||||
| `feature:auth` | @tjx666 | Authentication/authorization |
|
||||
@@ -57,7 +57,7 @@ Quick reference for assigning issues based on labels.
|
||||
| `feature:search` | @ONLY-yours | Search functionality |
|
||||
| `feature:tts` | @tjx666 | Text-to-speech |
|
||||
| `feature:export` | @ONLY-yours | Export functionality |
|
||||
| `feature:group-chat` | @RiverTwilight | Group chat functionality |
|
||||
| `feature:group-chat` | @arvinxx | Group chat functionality |
|
||||
| `feature:memory` | @nekomeowww | Memory feature |
|
||||
| `feature:team-workspace` | @rdmclin2 | Team workspace application |
|
||||
| `feature:subscription` | @tcmonster | Subscription and billing |
|
||||
|
||||
@@ -9,16 +9,10 @@ inputs:
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
name: Setup Environment
|
||||
description: Setup Node.js, pnpm (install) and Bun (script runner) for workflows
|
||||
|
||||
inputs:
|
||||
node-version:
|
||||
description: Node.js version
|
||||
required: false
|
||||
default: '24.11.1'
|
||||
package-manager-cache:
|
||||
description: Pass-through to actions/setup-node package-manager-cache
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
package-manager-cache: ${{ inputs.package-manager-cache }}
|
||||
@@ -3,7 +3,7 @@ name: Daily i18n Update
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
workflow_dispatch:
|
||||
workflow_dispatch: {}
|
||||
|
||||
# Add permissions configuration
|
||||
permissions:
|
||||
@@ -25,13 +25,11 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ secrets.BUN_VERSION }}
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
run: pnpm install
|
||||
|
||||
- name: Update i18n
|
||||
run: bun run i18n
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -72,22 +74,13 @@ jobs:
|
||||
git checkout main
|
||||
git pull --rebase origin main
|
||||
|
||||
- name: Setup Node.js
|
||||
- name: Setup environment
|
||||
if: steps.release.outputs.should_tag == 'true' || steps.patch.outputs.should_tag == 'true'
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
if: steps.release.outputs.should_tag == 'true' || steps.patch.outputs.should_tag == 'true'
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install deps
|
||||
if: steps.release.outputs.should_tag == 'true' || steps.patch.outputs.should_tag == 'true'
|
||||
run: bun i
|
||||
run: pnpm install
|
||||
|
||||
- name: Resolve patch version (patch bump)
|
||||
id: patch-version
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: Bundle Analyzer
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -9,7 +9,6 @@ permissions:
|
||||
|
||||
env:
|
||||
NODE_VERSION: 24.11.1
|
||||
BUN_VERSION: 1.2.23
|
||||
|
||||
jobs:
|
||||
bundle-analyzer:
|
||||
@@ -20,19 +19,11 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
|
||||
|
||||
@@ -51,11 +51,11 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright browsers (with system deps)
|
||||
run: bunx playwright install --with-deps chromium
|
||||
|
||||
@@ -29,11 +29,11 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
|
||||
@@ -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(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:*)"
|
||||
--allowedTools "Bash(git:*),Bash(gh:*),Bash(bun run:*),Bash(bunx:*),Bash(pnpm:*),Bash(npm run:*),Bash(npx:*),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)"
|
||||
|
||||
@@ -61,13 +61,11 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install dependencies (bun)
|
||||
run: bun install
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Install Playwright browsers (with system deps)
|
||||
run: bunx playwright install --with-deps chromium
|
||||
|
||||
@@ -3,7 +3,7 @@ description: Auto-closes issues that are duplicates of existing issues
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * *'
|
||||
workflow_dispatch:
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
auto-close-duplicates:
|
||||
@@ -17,10 +17,11 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Auto-close duplicate issues
|
||||
run: bun run .github/scripts/auto-close-duplicates.ts
|
||||
|
||||
@@ -41,7 +41,6 @@ permissions:
|
||||
|
||||
env:
|
||||
NODE_VERSION: 24.11.1
|
||||
BUN_VERSION: 1.2.23
|
||||
|
||||
jobs:
|
||||
version:
|
||||
@@ -102,18 +101,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node & pnpm
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
- name: Setup build environment
|
||||
uses: ./.github/actions/desktop-build-setup
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
package-manager-cache: 'false'
|
||||
|
||||
# node-linker=hoisted 模式将可以确保 asar 压缩可用
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pnpm install --node-linker=hoisted &
|
||||
npm run install-isolated --prefix=./apps/desktop &
|
||||
wait
|
||||
|
||||
- name: Set package version
|
||||
run: npm run workflow:set-desktop-version ${{ needs.version.outputs.version }} ${{ inputs.channel }}
|
||||
@@ -222,17 +213,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node & pnpm
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
- name: Setup build environment
|
||||
uses: ./.github/actions/desktop-build-setup
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
package-manager-cache: 'false'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pnpm install --node-linker=hoisted &
|
||||
npm run install-isolated --prefix=./apps/desktop &
|
||||
wait
|
||||
|
||||
- name: Set package version
|
||||
run: npm run workflow:set-desktop-version ${{ needs.version.outputs.version }} ${{ inputs.channel }}
|
||||
@@ -274,12 +258,10 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node & Bun
|
||||
uses: ./.github/actions/setup-node-bun
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
package-manager-cache: 'false'
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
|
||||
@@ -27,15 +27,11 @@ jobs:
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node & Bun
|
||||
uses: ./.github/actions/setup-node-bun
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
bun-version: latest
|
||||
package-manager-cache: 'false'
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
run: pnpm install
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
|
||||
@@ -93,29 +89,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node & pnpm
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
- name: Setup build environment
|
||||
uses: ./.github/actions/desktop-build-setup
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: 'false'
|
||||
|
||||
# node-linker=hoisted 模式将可以确保 asar 压缩可用
|
||||
- name: Install dependencies
|
||||
run: pnpm install --node-linker=hoisted
|
||||
|
||||
# 移除国内 electron 镜像配置,GitHub Actions 使用官方源更快
|
||||
- name: Remove China electron mirror from .npmrc
|
||||
shell: bash
|
||||
run: |
|
||||
NPMRC_FILE="./apps/desktop/.npmrc"
|
||||
if [ -f "$NPMRC_FILE" ]; then
|
||||
sed -i.bak '/^electron_mirror=/d; /^electron_builder_binaries_mirror=/d' "$NPMRC_FILE"
|
||||
rm -f "${NPMRC_FILE}.bak"
|
||||
echo "✅ Removed electron mirror config from .npmrc"
|
||||
fi
|
||||
|
||||
- name: Install deps on Desktop
|
||||
run: npm run install-isolated --prefix=./apps/desktop
|
||||
|
||||
# 设置 package.json 的版本号
|
||||
- name: Set package version
|
||||
@@ -228,12 +205,8 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node & Bun
|
||||
uses: ./.github/actions/setup-node-bun
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
bun-version: latest
|
||||
package-manager-cache: 'false'
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
# 下载所有平台的构建产物
|
||||
- name: Download artifacts
|
||||
@@ -251,13 +224,11 @@ jobs:
|
||||
- name: Install yaml only for merge step
|
||||
run: |
|
||||
cd scripts/electronWorkflow
|
||||
# 在脚本目录创建最小 package.json,防止 bun 向上寻找根 package.json
|
||||
if [ ! -f package.json ]; then
|
||||
echo '{"name":"merge-mac-release","private":true}' > package.json
|
||||
fi
|
||||
bun add --no-save yaml@2.8.1
|
||||
|
||||
# 合并 macOS YAML 文件 (使用 bun 运行 JavaScript)
|
||||
- name: Merge latest-mac.yml files
|
||||
run: bun run scripts/electronWorkflow/mergeMacReleaseFiles.js
|
||||
|
||||
|
||||
@@ -62,19 +62,13 @@ jobs:
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
run: pnpm install
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
@@ -168,16 +162,10 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
# 下载所有平台的构建产物
|
||||
- name: Download artifacts
|
||||
@@ -195,13 +183,11 @@ jobs:
|
||||
- name: Install yaml only for merge step
|
||||
run: |
|
||||
cd scripts/electronWorkflow
|
||||
# 在脚本目录创建最小 package.json,防止 bun 向上寻找根 package.json
|
||||
if [ ! -f package.json ]; then
|
||||
echo '{"name":"merge-mac-release","private":true}' > package.json
|
||||
fi
|
||||
bun add --no-save yaml@2.8.1
|
||||
|
||||
# 合并 macOS YAML 文件 (使用 bun 运行 JavaScript)
|
||||
- name: Merge latest-mac.yml files
|
||||
run: bun run scripts/electronWorkflow/mergeMacReleaseFiles.js
|
||||
|
||||
|
||||
@@ -133,19 +133,13 @@ jobs:
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
run: pnpm install
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
@@ -247,16 +241,10 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
|
||||
@@ -128,19 +128,13 @@ jobs:
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
run: pnpm install
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
@@ -242,16 +236,10 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
|
||||
@@ -266,16 +266,10 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
|
||||
@@ -37,19 +37,11 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
run: pnpm install
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
|
||||
@@ -15,15 +15,13 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ secrets.BUN_VERSION }}
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
run: pnpm install
|
||||
|
||||
- name: sync database schema to dbdocs
|
||||
env:
|
||||
DBDOCS_TOKEN: ${{ secrets.DBDOCS_TOKEN }}
|
||||
run: npm run db:visualize
|
||||
run: bun run db:visualize
|
||||
|
||||
+14
-46
@@ -37,19 +37,11 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ secrets.BUN_VERSION }}
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
run: pnpm install
|
||||
|
||||
- name: Test packages with coverage
|
||||
run: |
|
||||
@@ -111,19 +103,11 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
run: pnpm install
|
||||
|
||||
- name: Run tests
|
||||
run: bunx vitest --coverage --silent='passed-only' --reporter=default --reporter=blob --shard=${{ matrix.shard }}/2
|
||||
@@ -146,13 +130,11 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install deps
|
||||
run: bun i
|
||||
run: pnpm install
|
||||
|
||||
- name: Download blob reports
|
||||
uses: actions/download-artifact@v7
|
||||
@@ -181,16 +163,8 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 10
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install deps
|
||||
run: pnpm install
|
||||
@@ -235,20 +209,14 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
- name: Install deps
|
||||
run: pnpm i
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
run: bun run lint
|
||||
|
||||
- name: Test Coverage
|
||||
run: pnpm --filter @lobechat/database test:coverage
|
||||
|
||||
+5
-1
@@ -52,6 +52,7 @@ bun.lockb
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
public/_spa/
|
||||
public/spa/
|
||||
es/
|
||||
lib/
|
||||
@@ -134,4 +135,7 @@ i18n-unused-keys-report.json
|
||||
|
||||
pnpm-lock.yaml
|
||||
.turbo
|
||||
spaHtmlTemplates.ts
|
||||
spaHtmlTemplates.ts
|
||||
|
||||
.superpowers/
|
||||
docs/superpowers
|
||||
@@ -47,6 +47,7 @@ lobehub/
|
||||
- Git commit messages should prefix with gitmoji
|
||||
- Git branch name format: `feat/feature-name`
|
||||
- Use `.github/PULL_REQUEST_TEMPLATE.md` for PR descriptions
|
||||
- **Protection of local changes**: Never use `git restore`, `git checkout --`, `git reset --hard`, or any other command or workflow that can forcibly overwrite, discard, or silently replace user-owned uncommitted changes. Before any revert or restoration affecting existing files, inspect the working tree carefully and obtain explicit user confirmation.
|
||||
|
||||
### Package Management
|
||||
|
||||
@@ -89,7 +90,8 @@ cd packages/[package-name] && bunx vitest run --silent='passed-only' '[file-path
|
||||
|
||||
- **`src/routes/`** holds only page segments (`_layout/index.tsx`, `index.tsx`, `[id]/index.tsx`). Keep route files **thin** — import from `@/features/*` and compose, no business logic.
|
||||
- **`src/features/`** holds business components by **domain** (e.g. `Pages`, `PageEditor`, `Home`). Layout pieces, hooks, and domain UI go here.
|
||||
- See the **spa-routes** skill for the full convention and file-division rules.
|
||||
- **Desktop router parity:** When changing the main SPA route tree, update **both** `src/spa/router/desktopRouter.config.tsx` (dynamic imports) and `src/spa/router/desktopRouter.config.desktop.tsx` (sync imports) so paths and nesting match. Changing only one can leave routes unregistered and cause **blank screens**.
|
||||
- See the **spa-routes** skill (`.agents/skills/spa-routes/SKILL.md`) for the full convention and file-division rules.
|
||||
|
||||
## Skills (Auto-loaded)
|
||||
|
||||
|
||||
@@ -2,6 +2,64 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
### [Version 2.1.45](https://github.com/lobehub/lobe-chat/compare/v2.1.44...v2.1.45)
|
||||
|
||||
<sup>Released on **2026-03-26**</sup>
|
||||
|
||||
#### 👷 Build System
|
||||
|
||||
- **misc**: add agent task system database schema.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### Build System
|
||||
|
||||
- **misc**: add agent task system database schema, closes [#13280](https://github.com/lobehub/lobe-chat/issues/13280) ([b005a9c](https://github.com/lobehub/lobe-chat/commit/b005a9c))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 2.1.44](https://github.com/lobehub/lobe-chat/compare/v2.2.0-nightly.202603200623...v2.1.44)
|
||||
|
||||
<sup>Released on **2026-03-20**</sup>
|
||||
|
||||
#### 🐛 Bug Fixes
|
||||
|
||||
- **misc**: misc UI/UX improvements and bug fixes.
|
||||
|
||||
#### 💄 Styles
|
||||
|
||||
- **misc**: add image/video switch.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's fixed
|
||||
|
||||
- **misc**: misc UI/UX improvements and bug fixes, closes [#13153](https://github.com/lobehub/lobe-chat/issues/13153) ([abd152b](https://github.com/lobehub/lobe-chat/commit/abd152b))
|
||||
|
||||
#### Styles
|
||||
|
||||
- **misc**: add image/video switch, closes [#13152](https://github.com/lobehub/lobe-chat/issues/13152) ([2067cb2](https://github.com/lobehub/lobe-chat/commit/2067cb2))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 2.1.43](https://github.com/lobehub/lobe-chat/compare/v2.1.42...v2.1.43)
|
||||
|
||||
<sup>Released on **2026-03-16**</sup>
|
||||
|
||||
@@ -60,6 +60,7 @@ When adding or changing SPA routes:
|
||||
1. In `src/routes/`, add only the route segment files (layout + page) that delegate to features.
|
||||
2. Implement layout and page content under `src/features/<Domain>/` and export from there.
|
||||
3. In route files, use `import { X } from '@/features/<Domain>'` (or `import Y from '@/features/<Domain>/...'`). Do not add new `features/` folders inside `src/routes/`.
|
||||
4. **Register the desktop route tree in both configs:** `src/spa/router/desktopRouter.config.tsx` and `src/spa/router/desktopRouter.config.desktop.tsx` must stay in sync (same paths and nesting). Updating only one can cause **blank screens** if the other build path expects the route.
|
||||
|
||||
See the **spa-routes** skill (`.agents/skills/spa-routes/SKILL.md`) for the full convention and file-division rules.
|
||||
|
||||
|
||||
+1
-1
@@ -111,7 +111,7 @@ COPY --from=base /distroless/ /
|
||||
COPY --from=builder /app/.next/standalone /app/
|
||||
COPY --from=builder /app/.next/static /app/.next/static
|
||||
# Copy SPA assets (Vite build output)
|
||||
COPY --from=builder /app/public/spa /app/public/spa
|
||||
COPY --from=builder /app/public/_spa /app/public/_spa
|
||||
# Copy database migrations
|
||||
COPY --from=builder /app/packages/database/migrations /app/migrations
|
||||
COPY --from=builder /app/scripts/migrateServerDB/docker.cjs /app/docker.cjs
|
||||
|
||||
@@ -15,6 +15,17 @@ LobeHub command-line interface.
|
||||
- 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`.
|
||||
|
||||
## Custom Server URL
|
||||
|
||||
By default the CLI connects to `https://app.lobehub.com`. To point it at a different server (e.g. a local instance):
|
||||
|
||||
| Method | Command | Persistence |
|
||||
| -------------------- | --------------------------------------------------------------- | ----------------------------------- |
|
||||
| Environment variable | `LOBEHUB_SERVER=http://localhost:4000 bun run dev -- <command>` | Current command only |
|
||||
| Login flag | `lh login --server http://localhost:4000` | Saved to `~/.lobehub/settings.json` |
|
||||
|
||||
Priority: `LOBEHUB_SERVER` env var > `settings.json` > default official URL.
|
||||
|
||||
## Shell Completion
|
||||
|
||||
### Install completion for a linked CLI
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
|
||||
.\" Manual command details come from the Commander command tree.
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.1\-canary.12" "User Commands"
|
||||
.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
|
||||
@@ -27,7 +27,7 @@ For command-specific manuals, use the built-in manual command:
|
||||
.SH COMMANDS
|
||||
.TP
|
||||
.B login
|
||||
Log in to LobeHub via browser (Device Code Flow)
|
||||
Log in to LobeHub via browser (Device Code Flow) or configure API key server
|
||||
.TP
|
||||
.B logout
|
||||
Log out and remove stored credentials
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lobehub/cli",
|
||||
"version": "0.0.1-canary.12",
|
||||
"version": "0.0.1-canary.14",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"lh": "./dist/index.js",
|
||||
|
||||
+28
-14
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+242
-99
@@ -1,39 +1,130 @@
|
||||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import type { TrpcClient } from '../api/client';
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { confirm, outputJson, printTable } from '../utils/format';
|
||||
import { confirm, outputJson, printBoxTable, printTable, timeAgo } from '../utils/format';
|
||||
import { log } from '../utils/logger';
|
||||
import { registerBotMessageCommands } from './botMessage';
|
||||
|
||||
const SUPPORTED_PLATFORMS = ['discord', 'slack', 'telegram', 'lark', 'feishu', 'wechat'];
|
||||
// ── Helpers ──────────────────────────────────────────────
|
||||
|
||||
const PLATFORM_CREDENTIAL_FIELDS: Record<string, string[]> = {
|
||||
discord: ['botToken', 'publicKey'],
|
||||
feishu: ['appSecret'],
|
||||
lark: ['appSecret'],
|
||||
slack: ['botToken', 'signingSecret'],
|
||||
telegram: ['botToken'],
|
||||
wechat: ['botToken', 'botId'],
|
||||
function maskValue(val: string): string {
|
||||
if (val.length > 8) return val.slice(0, 4) + '****' + val.slice(-4);
|
||||
return '****';
|
||||
}
|
||||
|
||||
function camelToFlag(name: string): string {
|
||||
return '--' + name.replaceAll(/([A-Z])/g, '-$1').toLowerCase();
|
||||
}
|
||||
|
||||
/** Extract credential field definitions from a platform schema. */
|
||||
function getCredentialFields(platformDef: any): any[] {
|
||||
const credSchema = (platformDef.schema ?? []).find(
|
||||
(f: any) => f.key === 'credentials' && f.properties,
|
||||
);
|
||||
return credSchema?.properties ?? [];
|
||||
}
|
||||
|
||||
/** Extract credential values from CLI options based on platform schema. */
|
||||
function extractCredentials(
|
||||
platformDef: any,
|
||||
options: Record<string, any>,
|
||||
): { credentials: Record<string, string>; missing: any[] } {
|
||||
const fields = getCredentialFields(platformDef);
|
||||
const credentials: Record<string, string> = {};
|
||||
|
||||
for (const field of fields) {
|
||||
const value = options[field.key];
|
||||
if (typeof value === 'string') {
|
||||
credentials[field.key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const missing = fields.filter((f: any) => f.required && !credentials[f.key]);
|
||||
return { credentials, missing };
|
||||
}
|
||||
|
||||
/** Find a bot by ID from the user's bot list. */
|
||||
async function findBot(client: TrpcClient, botId: string) {
|
||||
const bots = await client.agentBotProvider.list.query();
|
||||
const bot = (bots as any[]).find((b: any) => b.id === botId);
|
||||
if (!bot) {
|
||||
log.error(`Bot integration not found: ${botId}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return bot;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, (s: string) => string> = {
|
||||
connected: pc.green,
|
||||
disconnected: pc.dim,
|
||||
failed: pc.red,
|
||||
queued: pc.yellow,
|
||||
starting: pc.yellow,
|
||||
unknown: pc.dim,
|
||||
};
|
||||
|
||||
function parseCredentials(
|
||||
platform: string,
|
||||
options: Record<string, string | undefined>,
|
||||
): Record<string, string> {
|
||||
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;
|
||||
|
||||
return creds;
|
||||
/** Validate a platform ID and return its definition. */
|
||||
async function resolvePlatform(client: TrpcClient, platformId: string) {
|
||||
const platforms = await client.agentBotProvider.listPlatforms.query();
|
||||
const def = (platforms as any[]).find((p: any) => p.id === platformId);
|
||||
if (!def) {
|
||||
const ids = (platforms as any[]).map((p: any) => p.id).join(', ');
|
||||
log.error(`Invalid platform "${platformId}". Must be one of: ${ids}`);
|
||||
log.info('Run `lh bot platforms` to see required credentials for each platform.');
|
||||
process.exit(1);
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
||||
// ── Command Registration ─────────────────────────────────
|
||||
|
||||
export function registerBotCommand(program: Command) {
|
||||
const bot = program.command('bot').description('Manage bot integrations');
|
||||
|
||||
// Register message subcommand group
|
||||
registerBotMessageCommands(bot);
|
||||
|
||||
// ── platforms ───────────────────────────────────────────
|
||||
|
||||
bot
|
||||
.command('platforms')
|
||||
.description('List supported platforms and their required credentials')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(async (options: { json?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const platforms = await client.agentBotProvider.listPlatforms.query();
|
||||
|
||||
if (options.json) {
|
||||
outputJson(platforms);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(pc.bold('Supported platforms:\n'));
|
||||
|
||||
for (const p of platforms as any[]) {
|
||||
console.log(` ${pc.bold(pc.cyan(p.id))}`);
|
||||
if (p.name) console.log(` Name: ${p.name}`);
|
||||
|
||||
const fields = getCredentialFields(p);
|
||||
const required = fields.filter((f: any) => f.required);
|
||||
const optional = fields.filter((f: any) => !f.required);
|
||||
|
||||
if (required.length > 0) {
|
||||
console.log(
|
||||
` Required: ${required.map((f: any) => pc.yellow(camelToFlag(f.key))).join(', ')}`,
|
||||
);
|
||||
}
|
||||
if (optional.length > 0) {
|
||||
console.log(
|
||||
` Optional: ${optional.map((f: any) => pc.dim(camelToFlag(f.key))).join(', ')}`,
|
||||
);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
});
|
||||
|
||||
// ── list ──────────────────────────────────────────────
|
||||
|
||||
bot
|
||||
@@ -63,15 +154,20 @@ export function registerBotCommand(program: Command) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = items.map((b: any) => [
|
||||
b.id || '',
|
||||
b.platform || '',
|
||||
b.applicationId || '',
|
||||
b.agentId || '',
|
||||
b.enabled ? pc.green('enabled') : pc.dim('disabled'),
|
||||
]);
|
||||
const rows = items.map((b: any) => {
|
||||
const status = b.enabled ? (b.runtimeStatus ?? 'disconnected') : 'disabled';
|
||||
const colorFn = STATUS_COLORS[status] ?? pc.dim;
|
||||
return [
|
||||
b.id || '',
|
||||
b.platform || '',
|
||||
b.applicationId || '',
|
||||
b.agentId || '',
|
||||
colorFn(status),
|
||||
b.updatedAt ? timeAgo(b.updatedAt) : pc.dim('-'),
|
||||
];
|
||||
});
|
||||
|
||||
printTable(rows, ['ID', 'PLATFORM', 'APP ID', 'AGENT', 'STATUS']);
|
||||
printTable(rows, ['ID', 'PLATFORM', 'APP ID', 'AGENT', 'STATUS', 'UPDATED']);
|
||||
});
|
||||
|
||||
// ── view ──────────────────────────────────────────────
|
||||
@@ -79,44 +175,62 @@ export function registerBotCommand(program: Command) {
|
||||
bot
|
||||
.command('view <botId>')
|
||||
.description('View bot integration details')
|
||||
.requiredOption('-a, --agent <agentId>', 'Agent ID')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(async (botId: string, options: { agent: string; json?: string | boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.agentBotProvider.getByAgentId.query({
|
||||
agentId: options.agent,
|
||||
});
|
||||
const items = Array.isArray(result) ? result : [];
|
||||
const item = items.find((b: any) => b.id === botId);
|
||||
.option('--show-credentials', 'Show full credential values (unmasked)')
|
||||
.action(
|
||||
async (botId: string, options: { json?: string | boolean; showCredentials?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const b = await findBot(client, botId);
|
||||
|
||||
if (!item) {
|
||||
log.error(`Bot integration not found: ${botId}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(item, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
const b = item as any;
|
||||
console.log(pc.bold(`${b.platform} bot`));
|
||||
console.log(pc.dim(`ID: ${b.id}`));
|
||||
console.log(`Application ID: ${b.applicationId}`);
|
||||
console.log(`Status: ${b.enabled ? pc.green('enabled') : pc.dim('disabled')}`);
|
||||
|
||||
if (b.credentials && typeof b.credentials === 'object') {
|
||||
console.log();
|
||||
console.log(pc.bold('Credentials:'));
|
||||
for (const [key, value] of Object.entries(b.credentials)) {
|
||||
const val = String(value);
|
||||
const masked = val.length > 8 ? val.slice(0, 4) + '****' + val.slice(-4) : '****';
|
||||
console.log(` ${key}: ${masked}`);
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(b, fields);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const status = b.enabled ? (b.runtimeStatus ?? 'disconnected') : 'disabled';
|
||||
const statusColorFn = STATUS_COLORS[status] ?? pc.dim;
|
||||
|
||||
const credentialLines: string[] = [];
|
||||
if (b.credentials && typeof b.credentials === 'object') {
|
||||
for (const [key, value] of Object.entries(b.credentials)) {
|
||||
const val = String(value);
|
||||
const display = options.showCredentials ? val : maskValue(val);
|
||||
credentialLines.push(`${pc.dim(key)}: ${display}`);
|
||||
}
|
||||
}
|
||||
|
||||
const settingsLines: string[] = [];
|
||||
if (b.settings && typeof b.settings === 'object') {
|
||||
for (const [key, value] of Object.entries(b.settings)) {
|
||||
settingsLines.push(`${pc.dim(key)}: ${JSON.stringify(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
printBoxTable(
|
||||
[
|
||||
{ header: 'Field', key: 'field' },
|
||||
{ header: 'Value', key: 'value' },
|
||||
],
|
||||
[
|
||||
{ field: 'ID', value: b.id || '' },
|
||||
{ field: 'Platform', value: pc.cyan(b.platform || '') },
|
||||
{ field: 'Application ID', value: b.applicationId || '' },
|
||||
{ field: 'Agent ID', value: b.agentId || '' },
|
||||
{ field: 'Status', value: statusColorFn(status) },
|
||||
...(credentialLines.length > 0
|
||||
? [{ field: 'Credentials', value: credentialLines }]
|
||||
: []),
|
||||
...(settingsLines.length > 0 ? [{ field: 'Settings', value: settingsLines }] : []),
|
||||
...(b.createdAt
|
||||
? [{ field: 'Created', value: new Date(b.createdAt).toLocaleString() }]
|
||||
: []),
|
||||
...(b.updatedAt ? [{ field: 'Updated', value: timeAgo(b.updatedAt) }] : []),
|
||||
],
|
||||
`${b.platform} bot`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ── add ───────────────────────────────────────────────
|
||||
|
||||
@@ -124,13 +238,18 @@ export function registerBotCommand(program: Command) {
|
||||
.command('add')
|
||||
.description('Add a bot integration to an agent')
|
||||
.requiredOption('-a, --agent <agentId>', 'Agent ID')
|
||||
.requiredOption('--platform <platform>', `Platform: ${SUPPORTED_PLATFORMS.join(', ')}`)
|
||||
.requiredOption('--platform <platform>', 'Platform (run `lh bot platforms` to see options)')
|
||||
.requiredOption('--app-id <appId>', 'Application ID for webhook routing')
|
||||
.option('--bot-token <token>', 'Bot token')
|
||||
.option('--bot-token <token>', 'Bot token (Discord, Slack, Telegram)')
|
||||
.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)')
|
||||
.option('--app-secret <secret>', 'App secret (Lark, Feishu, QQ)')
|
||||
.option('--secret-token <token>', 'Secret token (Telegram)')
|
||||
.option('--webhook-proxy-url <url>', 'Webhook proxy URL (Telegram)')
|
||||
.option('--encrypt-key <key>', 'Encrypt key (Feishu)')
|
||||
.option('--verification-token <token>', 'Verification token (Feishu)')
|
||||
.option('--json', 'Output created bot as JSON')
|
||||
.action(
|
||||
async (options: {
|
||||
agent: string;
|
||||
@@ -138,34 +257,39 @@ export function registerBotCommand(program: Command) {
|
||||
appSecret?: string;
|
||||
botId?: string;
|
||||
botToken?: string;
|
||||
encryptKey?: string;
|
||||
json?: boolean;
|
||||
platform: string;
|
||||
publicKey?: string;
|
||||
secretToken?: string;
|
||||
signingSecret?: string;
|
||||
verificationToken?: string;
|
||||
webhookProxyUrl?: string;
|
||||
}) => {
|
||||
if (!SUPPORTED_PLATFORMS.includes(options.platform)) {
|
||||
log.error(`Invalid platform. Must be one of: ${SUPPORTED_PLATFORMS.join(', ')}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
const client = await getTrpcClient();
|
||||
const platformDef = await resolvePlatform(client, options.platform);
|
||||
|
||||
const credentials = parseCredentials(options.platform, options);
|
||||
const requiredFields = PLATFORM_CREDENTIAL_FIELDS[options.platform] || [];
|
||||
const missing = requiredFields.filter((f) => !credentials[f]);
|
||||
const { credentials, missing } = extractCredentials(platformDef, options);
|
||||
if (missing.length > 0) {
|
||||
log.error(
|
||||
`Missing required credentials for ${options.platform}: ${missing.map((f) => '--' + f.replaceAll(/([A-Z])/g, '-$1').toLowerCase()).join(', ')}`,
|
||||
`Missing required credentials for ${options.platform}: ${missing.map((f: any) => camelToFlag(f.key)).join(', ')}`,
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.agentBotProvider.create.mutate({
|
||||
agentId: options.agent,
|
||||
applicationId: options.appId,
|
||||
credentials,
|
||||
platform: options.platform,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
outputJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const r = result as any;
|
||||
console.log(
|
||||
`${pc.green('✓')} Added ${pc.bold(options.platform)} bot ${pc.bold(r.id || '')}`,
|
||||
@@ -183,6 +307,10 @@ export function registerBotCommand(program: Command) {
|
||||
.option('--public-key <key>', 'New public key')
|
||||
.option('--signing-secret <secret>', 'New signing secret')
|
||||
.option('--app-secret <secret>', 'New app secret')
|
||||
.option('--secret-token <token>', 'New secret token')
|
||||
.option('--webhook-proxy-url <url>', 'New webhook proxy URL')
|
||||
.option('--encrypt-key <key>', 'New encrypt key')
|
||||
.option('--verification-token <token>', 'New verification token')
|
||||
.option('--app-id <appId>', 'New application ID')
|
||||
.option('--platform <platform>', 'New platform')
|
||||
.action(
|
||||
@@ -193,20 +321,23 @@ export function registerBotCommand(program: Command) {
|
||||
appSecret?: string;
|
||||
botId?: string;
|
||||
botToken?: string;
|
||||
encryptKey?: string;
|
||||
platform?: string;
|
||||
publicKey?: string;
|
||||
secretToken?: string;
|
||||
signingSecret?: string;
|
||||
verificationToken?: string;
|
||||
webhookProxyUrl?: string;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const input: Record<string, any> = { id: botId };
|
||||
|
||||
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;
|
||||
const existing = await findBot(client, botId);
|
||||
const platform = options.platform ?? existing.platform;
|
||||
const platformDef = await resolvePlatform(client, platform);
|
||||
|
||||
const { credentials } = extractCredentials(platformDef, options);
|
||||
if (Object.keys(credentials).length > 0) input.credentials = credentials;
|
||||
if (options.appId) input.applicationId = options.appId;
|
||||
if (options.platform) input.platform = options.platform;
|
||||
@@ -217,7 +348,6 @@ export function registerBotCommand(program: Command) {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
await client.agentBotProvider.update.mutate(input as any);
|
||||
console.log(`${pc.green('✓')} Updated bot ${pc.bold(botId)}`);
|
||||
},
|
||||
@@ -263,28 +393,41 @@ export function registerBotCommand(program: Command) {
|
||||
console.log(`${pc.green('✓')} Disabled bot ${pc.bold(botId)}`);
|
||||
});
|
||||
|
||||
// ── test ───────────────────────────────────────────────
|
||||
|
||||
bot
|
||||
.command('test <botId>')
|
||||
.description('Test bot credentials against the platform API')
|
||||
.action(async (botId: string) => {
|
||||
const client = await getTrpcClient();
|
||||
const b = await findBot(client, botId);
|
||||
|
||||
log.status(`Testing ${b.platform} credentials for ${b.applicationId}...`);
|
||||
|
||||
try {
|
||||
await client.agentBotProvider.testConnection.mutate({
|
||||
applicationId: b.applicationId,
|
||||
platform: b.platform,
|
||||
});
|
||||
console.log(`${pc.green('✓')} Credentials are valid for ${pc.bold(b.platform)} bot`);
|
||||
} catch (err: any) {
|
||||
const message = err?.message || 'Connection test failed';
|
||||
log.error(`Credential test failed: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ── connect ───────────────────────────────────────────
|
||||
|
||||
bot
|
||||
.command('connect <botId>')
|
||||
.description('Connect and start a bot')
|
||||
.requiredOption('-a, --agent <agentId>', 'Agent ID')
|
||||
.action(async (botId: string, options: { agent: string }) => {
|
||||
// First fetch the bot to get platform and applicationId
|
||||
.action(async (botId: string) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.agentBotProvider.getByAgentId.query({
|
||||
agentId: options.agent,
|
||||
});
|
||||
const items = Array.isArray(result) ? result : [];
|
||||
const item = items.find((b: any) => b.id === botId);
|
||||
const b = await findBot(client, botId);
|
||||
|
||||
if (!item) {
|
||||
log.error(`Bot integration not found: ${botId}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
log.status(`Connecting ${b.platform} bot ${b.applicationId}...`);
|
||||
|
||||
const b = item as any;
|
||||
const connectResult = await client.agentBotProvider.connectBot.mutate({
|
||||
applicationId: b.applicationId,
|
||||
platform: b.platform,
|
||||
|
||||
@@ -0,0 +1,564 @@
|
||||
import { DEFAULT_BOT_HISTORY_LIMIT } from '@lobechat/const';
|
||||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { confirm, outputJson, printTable, truncate } from '../utils/format';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
export function registerBotMessageCommands(bot: Command) {
|
||||
const message = bot
|
||||
.command('message')
|
||||
.description('Send and manage messages on connected platforms');
|
||||
|
||||
// ── send ────────────────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('send <botId>')
|
||||
.description('Send a message to a channel')
|
||||
.requiredOption('--target <channelId>', 'Target channel / conversation ID')
|
||||
.requiredOption('--message <text>', 'Message content')
|
||||
.option('--reply-to <messageId>', 'Reply to a specific message')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(
|
||||
async (
|
||||
botId: string,
|
||||
options: { json?: boolean; message: string; replyTo?: string; target: string },
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.sendMessage.mutate({
|
||||
botId,
|
||||
channelId: options.target,
|
||||
content: options.message,
|
||||
replyTo: options.replyTo,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
outputJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const r = result as any;
|
||||
console.log(
|
||||
`${pc.green('✓')} Message sent${r.messageId ? ` (${pc.dim(r.messageId)})` : ''}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ── read ────────────────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('read <botId>')
|
||||
.description('Read messages from a channel')
|
||||
.requiredOption('--target <channelId>', 'Target channel / conversation ID')
|
||||
.option('--limit <n>', 'Max messages to fetch', String(DEFAULT_BOT_HISTORY_LIMIT))
|
||||
.option('--before <messageId>', 'Read messages before this ID')
|
||||
.option('--after <messageId>', 'Read messages after this ID')
|
||||
.option('--start-time <timestamp>', 'Start time as Unix seconds (Feishu/Lark)')
|
||||
.option('--end-time <timestamp>', 'End time as Unix seconds (Feishu/Lark)')
|
||||
.option('--cursor <token>', 'Pagination cursor from a previous response (Feishu/Lark)')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(
|
||||
async (
|
||||
botId: string,
|
||||
options: {
|
||||
after?: string;
|
||||
before?: string;
|
||||
cursor?: string;
|
||||
endTime?: string;
|
||||
json?: boolean;
|
||||
limit?: string;
|
||||
startTime?: string;
|
||||
target: string;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.readMessages.query({
|
||||
after: options.after,
|
||||
before: options.before,
|
||||
botId,
|
||||
channelId: options.target,
|
||||
cursor: options.cursor,
|
||||
endTime: options.endTime,
|
||||
limit: options.limit ? Number.parseInt(options.limit, 10) : undefined,
|
||||
startTime: options.startTime,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
outputJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const messages = (result as any).messages ?? [];
|
||||
if (messages.length === 0) {
|
||||
console.log('No messages found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = messages.map((m: any) => [
|
||||
m.id || '',
|
||||
m.author?.name || '',
|
||||
truncate(m.content || '', 60),
|
||||
m.timestamp || '',
|
||||
]);
|
||||
|
||||
printTable(rows, ['ID', 'AUTHOR', 'CONTENT', 'TIME']);
|
||||
|
||||
const r = result as any;
|
||||
if (r.hasMore && r.nextCursor) {
|
||||
console.log(
|
||||
`\nMore messages available. Use ${pc.dim(`--cursor ${r.nextCursor}`)} to fetch next page.`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── edit ────────────────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('edit <botId>')
|
||||
.description('Edit a message')
|
||||
.requiredOption('--target <channelId>', 'Channel ID')
|
||||
.requiredOption('--message-id <id>', 'Message ID to edit')
|
||||
.requiredOption('--message <text>', 'New message content')
|
||||
.action(
|
||||
async (botId: string, options: { message: string; messageId: string; target: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.botMessage.editMessage.mutate({
|
||||
botId,
|
||||
channelId: options.target,
|
||||
content: options.message,
|
||||
messageId: options.messageId,
|
||||
});
|
||||
|
||||
console.log(`${pc.green('✓')} Message ${pc.bold(options.messageId)} edited`);
|
||||
},
|
||||
);
|
||||
|
||||
// ── delete ──────────────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('delete <botId>')
|
||||
.description('Delete a message')
|
||||
.requiredOption('--target <channelId>', 'Channel ID')
|
||||
.requiredOption('--message-id <id>', 'Message ID to delete')
|
||||
.option('--yes', 'Skip confirmation prompt')
|
||||
.action(
|
||||
async (botId: string, options: { messageId: string; target: string; yes?: boolean }) => {
|
||||
if (!options.yes) {
|
||||
const confirmed = await confirm('Are you sure you want to delete this message?');
|
||||
if (!confirmed) {
|
||||
console.log('Cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
await client.botMessage.deleteMessage.mutate({
|
||||
botId,
|
||||
channelId: options.target,
|
||||
messageId: options.messageId,
|
||||
});
|
||||
|
||||
console.log(`${pc.green('✓')} Message ${pc.bold(options.messageId)} deleted`);
|
||||
},
|
||||
);
|
||||
|
||||
// ── search ──────────────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('search <botId>')
|
||||
.description('Search messages in a channel')
|
||||
.requiredOption('--target <channelId>', 'Channel ID to search in')
|
||||
.requiredOption('--query <text>', 'Search query')
|
||||
.option('--author-id <id>', 'Filter by author ID')
|
||||
.option('--limit <n>', 'Max results')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(
|
||||
async (
|
||||
botId: string,
|
||||
options: {
|
||||
authorId?: string;
|
||||
json?: boolean;
|
||||
limit?: string;
|
||||
query: string;
|
||||
target: string;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.searchMessages.query({
|
||||
authorId: options.authorId,
|
||||
botId,
|
||||
channelId: options.target,
|
||||
limit: options.limit ? Number.parseInt(options.limit, 10) : undefined,
|
||||
query: options.query,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
outputJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const messages = (result as any).messages ?? [];
|
||||
if (messages.length === 0) {
|
||||
console.log('No messages found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = messages.map((m: any) => [
|
||||
m.id || '',
|
||||
m.author?.name || '',
|
||||
truncate(m.content || '', 60),
|
||||
]);
|
||||
|
||||
printTable(rows, ['ID', 'AUTHOR', 'CONTENT']);
|
||||
},
|
||||
);
|
||||
|
||||
// ── react ───────────────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('react <botId>')
|
||||
.description('Add an emoji reaction to a message')
|
||||
.requiredOption('--target <channelId>', 'Channel ID')
|
||||
.requiredOption('--message-id <id>', 'Message ID to react to')
|
||||
.requiredOption('--emoji <emoji>', 'Emoji to react with')
|
||||
.action(
|
||||
async (botId: string, options: { emoji: string; messageId: string; target: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.botMessage.reactToMessage.mutate({
|
||||
botId,
|
||||
channelId: options.target,
|
||||
emoji: options.emoji,
|
||||
messageId: options.messageId,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`${pc.green('✓')} Reacted with ${options.emoji} to message ${pc.bold(options.messageId)}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ── reactions ───────────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('reactions <botId>')
|
||||
.description('List reactions on a message')
|
||||
.requiredOption('--target <channelId>', 'Channel ID')
|
||||
.requiredOption('--message-id <id>', 'Message ID')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(
|
||||
async (botId: string, options: { json?: boolean; messageId: string; target: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.getReactions.query({
|
||||
botId,
|
||||
channelId: options.target,
|
||||
messageId: options.messageId,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
outputJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const reactions = (result as any).reactions ?? [];
|
||||
if (reactions.length === 0) {
|
||||
console.log('No reactions found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = reactions.map((r: any) => [r.emoji || '', String(r.count || 0)]);
|
||||
printTable(rows, ['EMOJI', 'COUNT']);
|
||||
},
|
||||
);
|
||||
|
||||
// ── pin ─────────────────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('pin <botId>')
|
||||
.description('Pin a message')
|
||||
.requiredOption('--target <channelId>', 'Channel ID')
|
||||
.requiredOption('--message-id <id>', 'Message ID to pin')
|
||||
.action(async (botId: string, options: { messageId: string; target: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.botMessage.pinMessage.mutate({
|
||||
botId,
|
||||
channelId: options.target,
|
||||
messageId: options.messageId,
|
||||
});
|
||||
|
||||
console.log(`${pc.green('✓')} Pinned message ${pc.bold(options.messageId)}`);
|
||||
});
|
||||
|
||||
// ── unpin ───────────────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('unpin <botId>')
|
||||
.description('Unpin a message')
|
||||
.requiredOption('--target <channelId>', 'Channel ID')
|
||||
.requiredOption('--message-id <id>', 'Message ID to unpin')
|
||||
.action(async (botId: string, options: { messageId: string; target: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.botMessage.unpinMessage.mutate({
|
||||
botId,
|
||||
channelId: options.target,
|
||||
messageId: options.messageId,
|
||||
});
|
||||
|
||||
console.log(`${pc.green('✓')} Unpinned message ${pc.bold(options.messageId)}`);
|
||||
});
|
||||
|
||||
// ── pins ────────────────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('pins <botId>')
|
||||
.description('List pinned messages')
|
||||
.requiredOption('--target <channelId>', 'Channel ID')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(async (botId: string, options: { json?: boolean; target: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.listPins.query({
|
||||
botId,
|
||||
channelId: options.target,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
outputJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const messages = (result as any).messages ?? [];
|
||||
if (messages.length === 0) {
|
||||
console.log('No pinned messages.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = messages.map((m: any) => [
|
||||
m.id || '',
|
||||
m.author?.name || '',
|
||||
truncate(m.content || '', 60),
|
||||
]);
|
||||
|
||||
printTable(rows, ['ID', 'AUTHOR', 'CONTENT']);
|
||||
});
|
||||
|
||||
// ── poll ────────────────────────────────────────────────
|
||||
|
||||
message
|
||||
.command('poll <botId>')
|
||||
.description('Create a poll')
|
||||
.requiredOption('--target <channelId>', 'Channel ID')
|
||||
.requiredOption('--poll-question <text>', 'Poll question')
|
||||
.requiredOption('--poll-option <option>', 'Poll option (repeatable)', collectOptions, [])
|
||||
.option('--poll-multi', 'Allow multiple answers')
|
||||
.option('--poll-duration-hours <n>', 'Poll duration in hours')
|
||||
.action(
|
||||
async (
|
||||
botId: string,
|
||||
options: {
|
||||
pollDurationHours?: string;
|
||||
pollMulti?: boolean;
|
||||
pollOption: string[];
|
||||
pollQuestion: string;
|
||||
target: string;
|
||||
},
|
||||
) => {
|
||||
if (options.pollOption.length < 2) {
|
||||
log.error('At least 2 poll options are required.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.createPoll.mutate({
|
||||
botId,
|
||||
channelId: options.target,
|
||||
duration: options.pollDurationHours
|
||||
? Number.parseInt(options.pollDurationHours, 10)
|
||||
: undefined,
|
||||
multipleAnswers: options.pollMulti,
|
||||
options: options.pollOption,
|
||||
question: options.pollQuestion,
|
||||
});
|
||||
|
||||
const r = result as any;
|
||||
console.log(`${pc.green('✓')} Poll created${r.pollId ? ` (${pc.dim(r.pollId)})` : ''}`);
|
||||
},
|
||||
);
|
||||
|
||||
// ── thread (subcommand group) ───────────────────────────
|
||||
|
||||
const thread = message.command('thread').description('Manage threads');
|
||||
|
||||
thread
|
||||
.command('create <botId>')
|
||||
.description('Create a new thread')
|
||||
.requiredOption('--target <channelId>', 'Channel ID')
|
||||
.requiredOption('--thread-name <name>', 'Thread name')
|
||||
.option('--message <text>', 'Initial message content')
|
||||
.option('--message-id <id>', 'Create thread from a message')
|
||||
.action(
|
||||
async (
|
||||
botId: string,
|
||||
options: { message?: string; messageId?: string; target: string; threadName: string },
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.createThread.mutate({
|
||||
botId,
|
||||
channelId: options.target,
|
||||
content: options.message,
|
||||
messageId: options.messageId,
|
||||
name: options.threadName,
|
||||
});
|
||||
|
||||
const r = result as any;
|
||||
console.log(
|
||||
`${pc.green('✓')} Thread created${r.threadId ? ` (${pc.dim(r.threadId)})` : ''}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
thread
|
||||
.command('list <botId>')
|
||||
.description('List threads in a channel')
|
||||
.requiredOption('--target <channelId>', 'Channel ID')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(async (botId: string, options: { json?: boolean; target: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.listThreads.query({
|
||||
botId,
|
||||
channelId: options.target,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
outputJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const threads = (result as any).threads ?? [];
|
||||
if (threads.length === 0) {
|
||||
console.log('No threads found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = threads.map((t: any) => [
|
||||
t.id || '',
|
||||
t.name || '',
|
||||
String(t.messageCount ?? ''),
|
||||
]);
|
||||
|
||||
printTable(rows, ['ID', 'NAME', 'MESSAGES']);
|
||||
});
|
||||
|
||||
thread
|
||||
.command('reply <botId>')
|
||||
.description('Reply to a thread')
|
||||
.requiredOption('--thread-id <id>', 'Thread ID')
|
||||
.requiredOption('--message <text>', 'Reply content')
|
||||
.action(async (botId: string, options: { message: string; threadId: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.replyToThread.mutate({
|
||||
botId,
|
||||
content: options.message,
|
||||
threadId: options.threadId,
|
||||
});
|
||||
|
||||
const r = result as any;
|
||||
console.log(`${pc.green('✓')} Reply sent${r.messageId ? ` (${pc.dim(r.messageId)})` : ''}`);
|
||||
});
|
||||
|
||||
// ── channel (subcommand group) ──────────────────────────
|
||||
|
||||
const channel = message.command('channel').description('Manage channels');
|
||||
|
||||
channel
|
||||
.command('list <botId>')
|
||||
.description('List channels')
|
||||
.option('--server-id <id>', 'Server / workspace ID')
|
||||
.option('--filter <type>', 'Filter by type')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(
|
||||
async (botId: string, options: { filter?: string; json?: boolean; serverId?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.listChannels.query({
|
||||
botId,
|
||||
filter: options.filter,
|
||||
serverId: options.serverId,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
outputJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const channels = (result as any).channels ?? [];
|
||||
if (channels.length === 0) {
|
||||
console.log('No channels found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = channels.map((c: any) => [c.id || '', c.name || '', c.type || '']);
|
||||
printTable(rows, ['ID', 'NAME', 'TYPE']);
|
||||
},
|
||||
);
|
||||
|
||||
channel
|
||||
.command('info <botId>')
|
||||
.description('Get channel details')
|
||||
.requiredOption('--target <channelId>', 'Channel ID')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(async (botId: string, options: { json?: boolean; target: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.getChannelInfo.query({
|
||||
botId,
|
||||
channelId: options.target,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
outputJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const r = result as any;
|
||||
console.log(`Channel: ${pc.bold(r.name || options.target)}`);
|
||||
if (r.type) console.log(` Type: ${r.type}`);
|
||||
if (r.memberCount != null) console.log(` Members: ${r.memberCount}`);
|
||||
if (r.description) console.log(` Description: ${r.description}`);
|
||||
});
|
||||
|
||||
// ── member ──────────────────────────────────────────────
|
||||
|
||||
const member = message.command('member').description('Member information');
|
||||
|
||||
member
|
||||
.command('info <botId>')
|
||||
.description('Get member details')
|
||||
.requiredOption('--member-id <id>', 'Member / user ID')
|
||||
.option('--server-id <id>', 'Server / workspace ID')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(
|
||||
async (botId: string, options: { json?: boolean; memberId: string; serverId?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.botMessage.getMemberInfo.query({
|
||||
botId,
|
||||
memberId: options.memberId,
|
||||
serverId: options.serverId,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
outputJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const r = result as any;
|
||||
console.log(`Member: ${pc.bold(r.displayName || r.username || options.memberId)}`);
|
||||
if (r.status) console.log(` Status: ${r.status}`);
|
||||
if (r.roles?.length) console.log(` Roles: ${r.roles.join(', ')}`);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────
|
||||
|
||||
function collectOptions(value: string, previous: string[]): string[] {
|
||||
return [...previous, value];
|
||||
}
|
||||
@@ -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 '·';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -172,9 +173,9 @@ function buildDaemonArgs(options: ConnectOptions): string[] {
|
||||
}
|
||||
|
||||
async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
const auth = await resolveToken(options);
|
||||
let 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,20 +288,37 @@ 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
|
||||
// Handle auth expired — refresh token and reconnect automatically
|
||||
client.on('auth_expired', async () => {
|
||||
error('Authentication expired. Attempting to refresh...');
|
||||
const refreshed = await resolveToken({});
|
||||
if (refreshed) {
|
||||
info('Token refreshed. Please reconnect.');
|
||||
} else {
|
||||
error("Could not refresh token. Run 'lh login' to re-authenticate.");
|
||||
if (auth.tokenType === 'apiKey') {
|
||||
// API keys don't expire; ignore stale auth_expired signals
|
||||
return;
|
||||
}
|
||||
|
||||
info('Authentication expired. Attempting to refresh token...');
|
||||
|
||||
try {
|
||||
const refreshed = await resolveToken({});
|
||||
if (refreshed) {
|
||||
info('Token refreshed successfully. Reconnecting...');
|
||||
client.updateToken(refreshed.token);
|
||||
// Update cached auth so subsequent refreshes use the latest token
|
||||
auth = refreshed;
|
||||
await client.reconnect();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// refresh failed — fall through
|
||||
}
|
||||
|
||||
error("Could not refresh token. Run 'lh login' to re-authenticate.");
|
||||
cleanup();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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.');
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
@@ -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)}`);
|
||||
});
|
||||
}
|
||||
@@ -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('-');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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.`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const CLI_API_KEY_ENV = 'LOBEHUB_CLI_API_KEY';
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -28,7 +28,10 @@ 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,
|
||||
|
||||
@@ -55,11 +55,13 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
|
||||
@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();
|
||||
}
|
||||
|
||||
@@ -70,16 +72,33 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
|
||||
@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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -196,7 +196,10 @@ describe('GatewayConnectionCtr', () => {
|
||||
vi.useFakeTimers();
|
||||
MockGatewayClient.lastInstance = null;
|
||||
MockGatewayClient.lastOptions = null;
|
||||
mockStoreGet.mockReturnValue(undefined);
|
||||
mockStoreGet.mockImplementation((key: string) => {
|
||||
if (key === 'gatewayEnabled') return true;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
mockGatewayConnectionSrv = new GatewayConnectionService(mockApp);
|
||||
ctr = new GatewayConnectionCtr(mockApp);
|
||||
@@ -212,6 +215,7 @@ describe('GatewayConnectionCtr', () => {
|
||||
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;
|
||||
@@ -231,6 +235,7 @@ describe('GatewayConnectionCtr', () => {
|
||||
|
||||
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;
|
||||
});
|
||||
@@ -255,6 +260,16 @@ describe('GatewayConnectionCtr', () => {
|
||||
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);
|
||||
@@ -299,6 +314,16 @@ describe('GatewayConnectionCtr', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -327,6 +352,19 @@ describe('GatewayConnectionCtr', () => {
|
||||
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);
|
||||
|
||||
@@ -353,9 +391,11 @@ describe('GatewayConnectionCtr', () => {
|
||||
});
|
||||
|
||||
it('should reuse persisted device ID', () => {
|
||||
mockStoreGet.mockImplementation((key: string) =>
|
||||
key === 'gatewayDeviceId' ? 'existing-id' : undefined,
|
||||
);
|
||||
mockStoreGet.mockImplementation((key: string) => {
|
||||
if (key === 'gatewayEnabled') return true;
|
||||
if (key === 'gatewayDeviceId') return 'existing-id';
|
||||
return undefined;
|
||||
});
|
||||
ctr = new GatewayConnectionCtr(mockApp);
|
||||
ctr.afterAppReady();
|
||||
|
||||
@@ -545,16 +585,20 @@ describe('GatewayConnectionCtr', () => {
|
||||
|
||||
describe('getDeviceInfo', () => {
|
||||
it('should return device information', async () => {
|
||||
mockStoreGet.mockImplementation((key: string) =>
|
||||
key === 'gatewayDeviceId' ? 'my-device' : undefined,
|
||||
);
|
||||
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({
|
||||
|
||||
@@ -84,12 +84,32 @@ export default class GatewayConnectionService extends ServiceModule {
|
||||
|
||||
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 }> {
|
||||
|
||||
@@ -12,7 +12,10 @@ export interface ElectronMainStore {
|
||||
lastRefreshAt?: number;
|
||||
refreshToken?: string;
|
||||
};
|
||||
gatewayDeviceDescription: string;
|
||||
gatewayDeviceId: string;
|
||||
gatewayDeviceName: string;
|
||||
gatewayEnabled: boolean;
|
||||
gatewayUrl: string;
|
||||
locale: string;
|
||||
networkProxy: NetworkProxySettings;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
+108
-29
@@ -1,4 +1,25 @@
|
||||
[
|
||||
{
|
||||
"children": {
|
||||
"improvements": [
|
||||
"add agent task system database schema."
|
||||
]
|
||||
},
|
||||
"date": "2026-03-26",
|
||||
"version": "2.1.45"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": [
|
||||
"misc UI/UX improvements and bug fixes."
|
||||
],
|
||||
"improvements": [
|
||||
"add image/video switch."
|
||||
]
|
||||
},
|
||||
"date": "2026-03-20",
|
||||
"version": "2.1.44"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": [
|
||||
@@ -26,21 +47,27 @@
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["add api key hash column migration."]
|
||||
"improvements": [
|
||||
"add api key hash column migration."
|
||||
]
|
||||
},
|
||||
"date": "2026-03-09",
|
||||
"version": "2.1.39"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["when use trustclient not register market m2m token."]
|
||||
"fixes": [
|
||||
"when use trustclient not register market m2m token."
|
||||
]
|
||||
},
|
||||
"date": "2026-03-06",
|
||||
"version": "2.1.38"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Update i18n."]
|
||||
"improvements": [
|
||||
"Update i18n."
|
||||
]
|
||||
},
|
||||
"date": "2026-02-10",
|
||||
"version": "2.1.26"
|
||||
@@ -52,7 +79,9 @@
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Fix multimodal content_part images rendered as base64 text."]
|
||||
"fixes": [
|
||||
"Fix multimodal content_part images rendered as base64 text."
|
||||
]
|
||||
},
|
||||
"date": "2026-02-09",
|
||||
"version": "2.1.24"
|
||||
@@ -62,14 +91,18 @@
|
||||
"fixes": [
|
||||
"Fix editor content missing when send error, use custom avatar for group chat in sidebar."
|
||||
],
|
||||
"improvements": ["Update i18n."]
|
||||
"improvements": [
|
||||
"Update i18n."
|
||||
]
|
||||
},
|
||||
"date": "2026-02-09",
|
||||
"version": "2.1.23"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Register Notebook tool in server runtime."]
|
||||
"fixes": [
|
||||
"Register Notebook tool in server runtime."
|
||||
]
|
||||
},
|
||||
"date": "2026-02-08",
|
||||
"version": "2.1.22"
|
||||
@@ -94,7 +127,9 @@
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Fixed in community pluings tab the lobehub skills not display."]
|
||||
"fixes": [
|
||||
"Fixed in community pluings tab the lobehub skills not display."
|
||||
]
|
||||
},
|
||||
"date": "2026-02-06",
|
||||
"version": "2.1.19"
|
||||
@@ -111,21 +146,27 @@
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Add the preview publish to market button preview check."]
|
||||
"fixes": [
|
||||
"Add the preview publish to market button preview check."
|
||||
]
|
||||
},
|
||||
"date": "2026-02-04",
|
||||
"version": "2.1.16"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Fixed the agents list the show updateAt time error."]
|
||||
"fixes": [
|
||||
"Fixed the agents list the show updateAt time error."
|
||||
]
|
||||
},
|
||||
"date": "2026-02-04",
|
||||
"version": "2.1.15"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Fix cannot uncompressed messages."]
|
||||
"fixes": [
|
||||
"Fix cannot uncompressed messages."
|
||||
]
|
||||
},
|
||||
"date": "2026-02-04",
|
||||
"version": "2.1.14"
|
||||
@@ -142,7 +183,9 @@
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Hide password features when AUTH_DISABLE_EMAIL_PASSWORD is set."]
|
||||
"fixes": [
|
||||
"Hide password features when AUTH_DISABLE_EMAIL_PASSWORD is set."
|
||||
]
|
||||
},
|
||||
"date": "2026-02-02",
|
||||
"version": "2.1.11"
|
||||
@@ -154,42 +197,54 @@
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Use oauth2.link for generic OIDC provider account linking."]
|
||||
"fixes": [
|
||||
"Use oauth2.link for generic OIDC provider account linking."
|
||||
]
|
||||
},
|
||||
"date": "2026-02-02",
|
||||
"version": "2.1.9"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Improve tasks display."]
|
||||
"improvements": [
|
||||
"Improve tasks display."
|
||||
]
|
||||
},
|
||||
"date": "2026-02-01",
|
||||
"version": "2.1.8"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Add missing description parameter docs in Notebook system prompt."]
|
||||
"fixes": [
|
||||
"Add missing description parameter docs in Notebook system prompt."
|
||||
]
|
||||
},
|
||||
"date": "2026-02-01",
|
||||
"version": "2.1.7"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Improve local-system tool implement."]
|
||||
"improvements": [
|
||||
"Improve local-system tool implement."
|
||||
]
|
||||
},
|
||||
"date": "2026-02-01",
|
||||
"version": "2.1.6"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Slove the group member agents cant set skills problem."]
|
||||
"fixes": [
|
||||
"Slove the group member agents cant set skills problem."
|
||||
]
|
||||
},
|
||||
"date": "2026-01-31",
|
||||
"version": "2.1.5"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Update i18n, Update Kimi K2.5 & Qwen3 Max Thinking models."]
|
||||
"improvements": [
|
||||
"Update i18n, Update Kimi K2.5 & Qwen3 Max Thinking models."
|
||||
]
|
||||
},
|
||||
"date": "2026-01-31",
|
||||
"version": "2.1.4"
|
||||
@@ -201,49 +256,63 @@
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Fix feishu sso provider."]
|
||||
"fixes": [
|
||||
"Fix feishu sso provider."
|
||||
]
|
||||
},
|
||||
"date": "2026-01-30",
|
||||
"version": "2.1.2"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Correct desktop download URL path."]
|
||||
"fixes": [
|
||||
"Correct desktop download URL path."
|
||||
]
|
||||
},
|
||||
"date": "2026-01-30",
|
||||
"version": "2.1.1"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Refactor cron job UI and use runtime enableBusinessFeatures flag."]
|
||||
"features": [
|
||||
"Refactor cron job UI and use runtime enableBusinessFeatures flag."
|
||||
]
|
||||
},
|
||||
"date": "2026-01-30",
|
||||
"version": "2.1.0"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Fix usage table display issues."]
|
||||
"improvements": [
|
||||
"Fix usage table display issues."
|
||||
]
|
||||
},
|
||||
"date": "2026-01-29",
|
||||
"version": "2.0.13"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Group publish to market should set local group market identifer."]
|
||||
"fixes": [
|
||||
"Group publish to market should set local group market identifer."
|
||||
]
|
||||
},
|
||||
"date": "2026-01-29",
|
||||
"version": "2.0.12"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Fix group task render."]
|
||||
"improvements": [
|
||||
"Fix group task render."
|
||||
]
|
||||
},
|
||||
"date": "2026-01-29",
|
||||
"version": "2.0.11"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Add ExtendParamsTypeSchema for enhanced model settings."]
|
||||
"fixes": [
|
||||
"Add ExtendParamsTypeSchema for enhanced model settings."
|
||||
]
|
||||
},
|
||||
"date": "2026-01-29",
|
||||
"version": "2.0.10"
|
||||
@@ -255,7 +324,9 @@
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Fix inbox agent in mobile."]
|
||||
"fixes": [
|
||||
"Fix inbox agent in mobile."
|
||||
]
|
||||
},
|
||||
"date": "2026-01-28",
|
||||
"version": "2.0.8"
|
||||
@@ -267,21 +338,27 @@
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["The klavis in onboarding connect timeout fixed."]
|
||||
"fixes": [
|
||||
"The klavis in onboarding connect timeout fixed."
|
||||
]
|
||||
},
|
||||
"date": "2026-01-27",
|
||||
"version": "2.0.6"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Update the artifact prompt."]
|
||||
"fixes": [
|
||||
"Update the artifact prompt."
|
||||
]
|
||||
},
|
||||
"date": "2026-01-27",
|
||||
"version": "2.0.5"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Rename docker image and update docs for v2."]
|
||||
"fixes": [
|
||||
"Rename docker image and update docs for v2."
|
||||
]
|
||||
},
|
||||
"date": "2026-01-27",
|
||||
"version": "2.0.4"
|
||||
@@ -297,7 +374,9 @@
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Slove the recentTopicLinkError."]
|
||||
"fixes": [
|
||||
"Slove the recentTopicLinkError."
|
||||
]
|
||||
},
|
||||
"date": "2026-01-27",
|
||||
"version": "2.0.2"
|
||||
|
||||
@@ -15,7 +15,7 @@ services:
|
||||
- .env
|
||||
|
||||
postgresql:
|
||||
image: pgvector/pgvector:pg17
|
||||
image: paradedb/paradedb:latest-pg17
|
||||
container_name: lobe-postgres
|
||||
ports:
|
||||
- '5432:5432'
|
||||
@@ -63,11 +63,11 @@ services:
|
||||
volumes:
|
||||
- rustfs-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:9000/health >/dev/null 2>&1 || exit 1"]
|
||||
test: ['CMD-SHELL', 'wget -qO- http://localhost:9000/health >/dev/null 2>&1 || exit 1']
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
command: ["--access-key","${RUSTFS_ACCESS_KEY}","--secret-key","${RUSTFS_SECRET_KEY}","/data"]
|
||||
command: ['--access-key','${RUSTFS_ACCESS_KEY}','--secret-key','${RUSTFS_SECRET_KEY}','/data']
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
@@ -90,7 +90,7 @@ services:
|
||||
mc admin info rustfs || true;
|
||||
mc anonymous set-json "/bucket.config.json" "rustfs/lobe";
|
||||
'
|
||||
restart: "no"
|
||||
restart: 'no'
|
||||
networks:
|
||||
- lobe-network
|
||||
env_file:
|
||||
|
||||
@@ -17,7 +17,7 @@ services:
|
||||
- lobe-network
|
||||
|
||||
postgresql:
|
||||
image: pgvector/pgvector:pg17
|
||||
image: paradedb/paradedb:latest-pg17
|
||||
container_name: lobe-postgres
|
||||
ports:
|
||||
- '5432:5432'
|
||||
|
||||
+34
-1
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"https://file.rene.wang/540830955-0fe626a3-0ddc-4f67-b595-3c5b3f1701e0.png": "/blog/assetsa8e504275f2cd891fabecca985998de0.webp",
|
||||
"https://file.rene.wang/clipboard-1768907980491-9cc0669fc3a38.png": "/blog/assets8be3a46c8f9c5d3b61bc541f44b7f245.webp",
|
||||
"https://file.rene.wang/clipboard-1768908081787-ed9eb1cb78bdb.png": "/blog/assetsab009b79dd794f02aec24b7607f342e8.webp",
|
||||
"https://file.rene.wang/clipboard-1768908121691-b3517bf882633.png": "/blog/assetsd3cae44cba0d3f57df6440b46246e5e7.webp",
|
||||
@@ -48,6 +49,7 @@
|
||||
"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/clipboard-1774923001079-89ce6aa271a62.png": "/blog/assets53e6ec9cf72554dbc1f8224fc0550a03.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",
|
||||
@@ -237,6 +239,7 @@
|
||||
"https://github.com/user-attachments/assets/09c994cf-78f8-46ea-9fef-a06022c0f6d7": "/blog/assets6b6c251a2d4a77784c08fb07fc51abf9.webp",
|
||||
"https://github.com/user-attachments/assets/0af85438-ac99-4c95-b888-a17e88ede043": "/blog/assetsf1e1ca1adaac36881ec6c3b2ce1a099e.webp",
|
||||
"https://github.com/user-attachments/assets/0c73c453-6ee3-4f90-bc5d-119c52c38fef": "/blog/assets2a74d926ae05faf2ee9f8da858bec3f6.webp",
|
||||
"https://github.com/user-attachments/assets/0d5fb9e3-f9f0-4f35-a2b8-abd000ab600f": "/blog/assets313dfd5108d6fade542c846a87e2aa5a.webp",
|
||||
"https://github.com/user-attachments/assets/0e2fdc5d-9623-4a74-a7f6-dcb802d52297": "/blog/assets61324ea13398c8920f798b97ac19d58f.webp",
|
||||
"https://github.com/user-attachments/assets/0e3a7174-6b66-4432-a319-dff60b033c24": "/blog/assets/39d7890f8cbe21e77db8d3c94f7f22e4.webp",
|
||||
"https://github.com/user-attachments/assets/0f79c266-cce5-4936-aabd-4c8f19196d91": "/blog/assets6b67dabe7b9226cdff1bace5a3b8ab18.webp",
|
||||
@@ -251,9 +254,11 @@
|
||||
"https://github.com/user-attachments/assets/162bc64e-0d34-4a4e-815a-028247b73143": "/blog/assets308f9fd45d0e8a140c1c18e6c92a1a57.webp",
|
||||
"https://github.com/user-attachments/assets/16cd9aef-c87b-48a4-95c0-b666082e7515": "/blog/assets0ceb7e446f9a850df283093563ba7803.webp",
|
||||
"https://github.com/user-attachments/assets/199b862a-5de4-4a54-83b2-f4dbf69be902": "/blog/assetsb9d1f02ab6c26f8a2c7873a949b4dd3c.webp",
|
||||
"https://github.com/user-attachments/assets/19f34b62-fb65-4a5d-9ca5-2ace06fb778b": "/blog/assets5dd8b54083201bff2494404b66e37df0.webp",
|
||||
"https://github.com/user-attachments/assets/1a7e9600-cd0f-4c82-9d32-4e61bbb351cc": "/blog/assets5997a6461e20103f5bc9d6b78b872833.webp",
|
||||
"https://github.com/user-attachments/assets/1bf1a5f0-32ad-418c-a8d1-6c54740f50b9": "/blog/assets4d0d191b487c114abf084eb7f2dc381c.webp",
|
||||
"https://github.com/user-attachments/assets/1c6a3e42-8e24-4148-b2c3-0bfe60a8cf77": "/blog/assets8096422e62e10dcd58efe75c616f9e88.webp",
|
||||
"https://github.com/user-attachments/assets/1ce3a977-05d8-4120-9260-34323c147087": "/blog/assetsa95ea7fad4727559d3f8d84a96947d5e.webp",
|
||||
"https://github.com/user-attachments/assets/1d77cca4-7363-4a46-9ad5-10604e111d7c": "/blog/assets1049abec5850cebf8ce12cd50199b9c5.webp",
|
||||
"https://github.com/user-attachments/assets/1e33aff2-6186-4e1f-80a8-4a2c855d8cc1": "/blog/assets6f2a84bee4245ca507e98e96247d5c5e.webp",
|
||||
"https://github.com/user-attachments/assets/1fb5df18-5261-483e-a445-96f52f80dd20": "/blog/assets69146738e31a47ac6425070208ebd906.webp",
|
||||
@@ -261,20 +266,27 @@
|
||||
"https://github.com/user-attachments/assets/21c52e2a-b2f8-4de8-a5d4-cf3444608db7": "/blog/assets50607dece1bbffe80fdcbe76324ff9b6.webp",
|
||||
"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/23e57d4f-9449-48a0-b263-f6a869c023b3": "/blog/assets1aaca5d65761b58564e3f196a91cde3e.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/2a0a21f6-4dc8-4160-a683-8629af1f6336": "/blog/assetsbd0ac93d1d3bba86d5da86b9569a6fb1.webp",
|
||||
"https://github.com/user-attachments/assets/2a4116a7-15ad-43e5-b801-cc62d8da2012": "/blog/assets/37d85fdfccff9ed56e9c6827faee01c7.webp",
|
||||
"https://github.com/user-attachments/assets/2b9d5184-5884-4dab-9eaa-c3097b19c499": "/blog/assets8a08815733e06500b6552019d6dfbe7b.webp",
|
||||
"https://github.com/user-attachments/assets/2bb4c09d-75bb-4c46-bb2f-faf538308305": "/blog/assetsf0ebf396dbe9559eb3478f48f648a6e2.webp",
|
||||
"https://github.com/user-attachments/assets/2dd3cde5-fa0d-4f52-b82b-28d9e89379a0": "/blog/assets66b0dfa56c1f5b3063b5ba740dd3ef8d.webp",
|
||||
"https://github.com/user-attachments/assets/2f7c5c45-ec6a-4393-8fa9-19a4c5f52f7a": "/blog/assets89168f61edcb2ee92d2ad7064da218b2.webp",
|
||||
"https://github.com/user-attachments/assets/301ff923-7702-46c7-b1f8-b2c43bd699aa": "/blog/assetscb1c097430e064f8f99de85e5f078784.webp",
|
||||
"https://github.com/user-attachments/assets/3050839a-cb16-485d-8bae-1bc2f9ade632": "/blog/assetsf117203c39294f45930785d85773c83e.webp",
|
||||
"https://github.com/user-attachments/assets/30c33426-412d-4dec-b096-317fe5880e79": "/blog/assets66829206b15b6c36fa3344835659c041.webp",
|
||||
"https://github.com/user-attachments/assets/31a0b226-523d-4540-a98a-290b6853a3db": "/blog/assets0a25d3ffb02d35f6f28cdfa9da2dccd8.webp",
|
||||
"https://github.com/user-attachments/assets/328e9755-8da9-4849-8569-e099924822fe": "/blog/assetsf78c85b0a0183a3ae3f2e916d59c0a67.webp",
|
||||
"https://github.com/user-attachments/assets/35164b25-c964-42ce-9cb0-32f6ebe1d07c": "/blog/assetsb6af626eeb0e1e638d80dc9ff7a6eba9.webp",
|
||||
"https://github.com/user-attachments/assets/37251adf-949b-4aec-bc49-bf4647e119da": "/blog/assetscd53b161a6d02424d03f8c5dcadc3dd5.webp",
|
||||
"https://github.com/user-attachments/assets/378df8df-8ec4-436e-8451-fbc52705faee": "/blog/assetsba0243e75b0421b6dd7dadad02e4b0d6.webp",
|
||||
"https://github.com/user-attachments/assets/37bd35c6-c6e1-4c33-aeb6-c4b0cb1e25ff": "/blog/assets3fcf2ee44ffb6be5c3148667f0c1696e.webp",
|
||||
"https://github.com/user-attachments/assets/3849afb3-ea46-4d30-bc81-a7cb88cf451f": "/blog/assetsb6f4b163825de58e2b6fe4dba8ef1b26.webp",
|
||||
"https://github.com/user-attachments/assets/385eaca6-daea-484a-9bea-ba7270b4753d": "/blog/assets/d6129350de510a62fe87b2d2f0fb9477.webp",
|
||||
"https://github.com/user-attachments/assets/3ad2655e-dd20-4534-bf6d-080b3677df86": "/blog/assets48b5c19e20fb870c7bdd34bd3aefbb21.webp",
|
||||
"https://github.com/user-attachments/assets/3c1a492d-a3d4-4570-9e74-785c2942ca41": "/blog/assets9880145be3e52b8f9dcd8343cd34a6ca.webp",
|
||||
@@ -284,6 +296,7 @@
|
||||
"https://github.com/user-attachments/assets/411e2002-61f0-4010-9841-18e88ca895ec": "/blog/assets7c3eab218c0823fa353b1cd23afe21c3.webp",
|
||||
"https://github.com/user-attachments/assets/420379cd-d8a4-4ab3-9a46-75dcc3d56920": "/blog/assets0ca3e3989fb3884658765ee0ef2587a0.webp",
|
||||
"https://github.com/user-attachments/assets/4257e123-9018-4562-ac66-0f39278906f5": "/blog/assetsadbc0db573a0f581b22c30ecf243f721.webp",
|
||||
"https://github.com/user-attachments/assets/432d22e3-8d73-4376-b4cf-a7dcacae4444": "/blog/assets862c2fcdfd3a9e51c44c721c47e1ff5a.webp",
|
||||
"https://github.com/user-attachments/assets/433fdce4-0af5-417f-b80d-163c2d4f02f6": "/blog/assets4aaf8d5d092608b649230e0e6fc92df6.webp",
|
||||
"https://github.com/user-attachments/assets/452d0b48-5ff7-4f42-a46e-68a62b87632b": "/blog/assets78232916d13ddc942ab3d0b62b639509.webp",
|
||||
"https://github.com/user-attachments/assets/467bb431-ca0d-4bb4-ac17-e5e2b764a770": "/blog/assetsff480f9009cf873852a43c252ac36828.webp",
|
||||
@@ -312,8 +325,10 @@
|
||||
"https://github.com/user-attachments/assets/638dcd7c-2bff-4adb-bade-da2aaef872bf": "/blog/assets95e6fe7c19ebfb9ead1c5a267aaf2a4e.webp",
|
||||
"https://github.com/user-attachments/assets/639ed70b-abc5-476f-9eb0-10c739e5a115": "/blog/assets/b2845057b23bccfec3bfea90e43ac381.webp",
|
||||
"https://github.com/user-attachments/assets/63e5ced7-1d23-44e1-b933-cc3b5df47eab": "/blog/assets5f1a6cb003752055b9ed131c1715154c.webp",
|
||||
"https://github.com/user-attachments/assets/64f6a8cb-a693-4764-99f3-8e3621629db3": "/blog/assetsb74a9fc9aecbaa74529cf0fb0da37bca.webp",
|
||||
"https://github.com/user-attachments/assets/659b5ac1-82f1-43bd-9d4b-a98491e05794": "/blog/assets856bd407c8a1510f616a4bdb1e02a883.webp",
|
||||
"https://github.com/user-attachments/assets/669c68bf-3f85-4a6f-bb08-d0d7fb7f7417": "/blog/assets02dce7325584974cdba327fe2f996b9e.webp",
|
||||
"https://github.com/user-attachments/assets/689c613b-776c-471f-b25c-167cce4033b0": "/blog/assets39788a720a65b89f84b2d0d844c4791d.webp",
|
||||
"https://github.com/user-attachments/assets/692e7c67-f173-45da-86ef-5c69e17988e4": "/blog/assets6b01801b405c366fa4ebe683a77f289d.webp",
|
||||
"https://github.com/user-attachments/assets/6935e155-4a1d-4ab7-a61a-2b813d65bb7b": "/blog/assets/6ee2609d79281b6b915e317461013f31.webp",
|
||||
"https://github.com/user-attachments/assets/6d068fe0-8100-4b43-b0c3-7934f54e688f": "/blog/assets87c281587b15f05b6b4e1afcd5bb47e8.webp",
|
||||
@@ -328,6 +343,7 @@
|
||||
"https://github.com/user-attachments/assets/72f02ce5-9991-425b-9864-9113ee1ed6bf": "/blog/assetsfa2c650be15522ac2fd71a3e434a1b2e.webp",
|
||||
"https://github.com/user-attachments/assets/7350f211-61ce-488e-b0e2-f0fcac25caeb": "/blog/assetsf9ed064fe764cbeff2f46910e7099a91.webp",
|
||||
"https://github.com/user-attachments/assets/76ad163e-ee19-4f95-a712-85bea764d3ec": "/blog/assets5205b6dd0f80b8ba02c297fcdfc1aecb.webp",
|
||||
"https://github.com/user-attachments/assets/78c408b0-8432-4938-bdff-c9a291b6c5be": "/blog/assetsf9317924035e48fcb1d1ae586568ea5f.webp",
|
||||
"https://github.com/user-attachments/assets/796c94af-9bad-4e3c-b1c7-dbb17c215c56": "/blog/assetsbd8c97ef67055e3ff93c56e46c33fa8d.webp",
|
||||
"https://github.com/user-attachments/assets/798ddb18-50c7-462a-a083-0c6841351d26": "/blog/assets11a8089b511aaa61e8982dea0a3665c5.webp",
|
||||
"https://github.com/user-attachments/assets/7cb3019b-78c1-48e0-a64c-a6a4836affd9": "/blog/assets3ca963d92475f34b0789cfa50071bc52.webp",
|
||||
@@ -346,6 +362,7 @@
|
||||
"https://github.com/user-attachments/assets/8910186f-4609-4798-a588-2780dcf8db60": "/blog/assets4175fc55c2093d635f15a3287e89e977.webp",
|
||||
"https://github.com/user-attachments/assets/899a4393-db41-45a6-97ec-9813e1f9879d": "/blog/assets88248c034ef28ca9b909219d2e7ef32a.webp",
|
||||
"https://github.com/user-attachments/assets/8a0225e0-16ed-40ce-9cd5-553dda561679": "/blog/assets74fbd94a0dc865d2178954662dc964ae.webp",
|
||||
"https://github.com/user-attachments/assets/8b52d907-4359-405c-95f6-eb61c36be0bc": "/blog/assetsc3042da681a9df811e70473636a8f461.webp",
|
||||
"https://github.com/user-attachments/assets/8ce79bd6-f1a3-48bb-b3d0-5271c84801c2": "/blog/assets5f8cc99da9c3c1eaca284411833c99e3.webp",
|
||||
"https://github.com/user-attachments/assets/8d90ae64-cf8e-4d90-8a31-c18ab484740b": "/blog/assets04ab03ac7920031925f7ee27846b3f7d.webp",
|
||||
"https://github.com/user-attachments/assets/8ec7656e-1e3d-41e0-95a0-f6883135c2fc": "/blog/assets71b5cfd165bc907f437bf807048a3e67.webp",
|
||||
@@ -361,7 +378,12 @@
|
||||
"https://github.com/user-attachments/assets/a1af5778-f47a-4fdc-baf5-ca2a1e66f48e": "/blog/assets97ac48dab1a35e45e034fefe0a1a1006.webp",
|
||||
"https://github.com/user-attachments/assets/a1ba8ec0-e259-4da4-8980-0cf82ca5f52b": "/blog/assetsbd69842ebb37848ecd50c242aad835b0.webp",
|
||||
"https://github.com/user-attachments/assets/a42ba52b-491e-4993-8e2f-217aa1776e0f": "/blog/assets0f847842a5dedf7bef1f534278aec584.webp",
|
||||
"https://github.com/user-attachments/assets/a4350cec-20ad-4abe-a135-de54d0790623": "/blog/assets95dc1ff1901807b3f860b70294667682.webp",
|
||||
"https://github.com/user-attachments/assets/a43dd863-fd97-41ab-bcc0-0cf5fb1a859d": "/blog/assets05b5684db0f7035e8f0609f6b1b8d85c.webp",
|
||||
"https://github.com/user-attachments/assets/a49860c9-11a9-4916-ae61-042e24b1e2f1": "/blog/assetsa8003533498461272ea15a19407db9f4.webp",
|
||||
"https://github.com/user-attachments/assets/a53deb11-2c14-441a-8a5c-a0f3a74e2a63": "/blog/assets65c86d6e63ddd5dd9896a6a67c054c0d.webp",
|
||||
"https://github.com/user-attachments/assets/a850b19f-c45a-4aa9-a583-4a453e421fc1": "/blog/assetsf811b07c10e4a887248fc3f53d085241.webp",
|
||||
"https://github.com/user-attachments/assets/a92c8ad1-4243-4eaa-affa-8650fe0a6c63": "/blog/assets03aba6c4b7a39ed9b1be75ecd8f335dc.webp",
|
||||
"https://github.com/user-attachments/assets/a9de7780-d0cb-47d5-ad9c-fcbbec14b940": "/blog/assets79e8fff075490d2a4535590a02333316.webp",
|
||||
"https://github.com/user-attachments/assets/aa91ca54-65fc-4e33-8c76-999f0a5d2bee": "/blog/assetsf625540e8340bafe69ccbb89ad75707a.webp",
|
||||
"https://github.com/user-attachments/assets/aaa3e2c5-7f16-4cfb-86b6-2814a1aafe3a": "/blog/assets93da89c4892a80e2e5a6caa49d80af5f.webp",
|
||||
@@ -369,7 +391,9 @@
|
||||
"https://github.com/user-attachments/assets/ae03eab5-a319-4d2a-a5f6-1683ab7739ee": "/blog/assetsa25c48c9faa225bf6f72658e5bd58d64.webp",
|
||||
"https://github.com/user-attachments/assets/aea782b1-27bd-4d9c-b521-c172c2095fe6": "/blog/assets52c8de6425a785409464561c09f8c98d.webp",
|
||||
"https://github.com/user-attachments/assets/aead3c6c-891e-47c3-9f34-bdc33875e0c2": "/blog/assetsb6959f725c38f86053e4b07c9188d825.webp",
|
||||
"https://github.com/user-attachments/assets/aeb73c3e-4f04-4bec-820f-264792f8d0dc": "/blog/assets737e194726e134bc205a37d74eaee98e.webp",
|
||||
"https://github.com/user-attachments/assets/aee846d5-b5ee-46cb-9dd0-d952ea708b67": "/blog/assets/8a8d361b4c0cce6da350cc0de65c0ad6.webp",
|
||||
"https://github.com/user-attachments/assets/b022fb0b-9773-4bf1-adf2-c602d16467ae": "/blog/assetscfcdfc63bc4f8defc06accef81339a5b.webp",
|
||||
"https://github.com/user-attachments/assets/b2b36128-6a43-4a1f-9c08-99fe73fb565f": "/blog/assets85af5a2a51b851fe125055d374cc8263.webp",
|
||||
"https://github.com/user-attachments/assets/b3ab6e35-4fbc-468d-af10-e3e0c687350f": "/blog/assets4cd6d49afb0ab1354156961d396195a1.webp",
|
||||
"https://github.com/user-attachments/assets/b49ed0c1-d6bf-4f46-b9df-5f7c730afaa3": "/blog/assets74000cc1bc59ee4a15e8f0304afbf866.webp",
|
||||
@@ -388,6 +412,8 @@
|
||||
"https://github.com/user-attachments/assets/c68e88e4-cf2e-4122-82bc-89ba193b1eb4": "/blog/assets/1f6c4f1c5e6211735ca4924c7807aca1.webp",
|
||||
"https://github.com/user-attachments/assets/c75eb19e-e0f5-4135-91e4-55be8be8a996": "/blog/assets0f97d1dfccd5ba07172aff71ff9acd7b.webp",
|
||||
"https://github.com/user-attachments/assets/c77fcf70-9039-49ff-86e4-f8eaa267bbf6": "/blog/assets5a2f360c19fcf9a037b2d1609479b713.webp",
|
||||
"https://github.com/user-attachments/assets/ca1ef965-c7b6-401a-826c-bb9f1ac14769": "/blog/assets086849ced67ad95fc3f0d1f509add1bf.webp",
|
||||
"https://github.com/user-attachments/assets/cb301317-8ac0-4962-8957-060c52c2010b": "/blog/assets8f3657f3785fc04c42b0f53c17daa72e.webp",
|
||||
"https://github.com/user-attachments/assets/cb4ba5fe-c223-4b9f-a662-de93e4a536d1": "/blog/assets45d90e73abffd7ae7d85808f81827bb9.webp",
|
||||
"https://github.com/user-attachments/assets/cc1f6146-8063-4a4d-947a-7fd6b9133c0c": "/blog/assets28749075f0c4d62c1642694a4ed9ec08.webp",
|
||||
"https://github.com/user-attachments/assets/cf3bfd44-9c13-4026-95cd-67f54f40ce6c": "/blog/assetsc557d9ee77afeb958d198abf5ca79761.webp",
|
||||
@@ -401,12 +427,14 @@
|
||||
"https://github.com/user-attachments/assets/d7d65e32-679d-4e50-a933-28cf5dde1330": "/blog/assetsc51018f1581b769727ad1bb3bb641567.webp",
|
||||
"https://github.com/user-attachments/assets/d902b5df-edb1-48d6-b659-daf948a97aed": "/blog/assets1e640c898e897bfb4ce4b66d5377010b.webp",
|
||||
"https://github.com/user-attachments/assets/d961f2af-47b0-4806-8288-b1e8f7ee8a47": "/blog/assets9c1839eb146b89e9e2d262ca95d24323.webp",
|
||||
"https://github.com/user-attachments/assets/d9daa8c9-957d-476e-83b1-4bbb351df555": "/blog/assets3865756ef6158a855aee64dd01bd3d6b.webp",
|
||||
"https://github.com/user-attachments/assets/db59a5e7-32ed-49d7-a791-8f8ee6618c01": "/blog/assetsf601ee6fa15bed25e17d6b6879691f0f.webp",
|
||||
"https://github.com/user-attachments/assets/dba58ea6-7df8-4971-b6d4-b24d5f486ba7": "/blog/assetsbbe90aa719d182d3d2f327e4182732c5.webp",
|
||||
"https://github.com/user-attachments/assets/dd6bc4a4-3c20-4162-87fd-5cac57e5d7e7": "/blog/assetseebf66254337ce88357629c34e78c08d.webp",
|
||||
"https://github.com/user-attachments/assets/dde2c9c5-cdda-4a65-8f32-b6f4da907df2": "/blog/assets/d47654360d626f80144cdedb979a3526.webp",
|
||||
"https://github.com/user-attachments/assets/dec6665a-b3ec-4c50-a57f-7c7eb3160e7b": "/blog/assets8d4fbb776e2209a1ec58c6b3516351a1.webp",
|
||||
"https://github.com/user-attachments/assets/dfc45807-2ed6-43eb-af4c-47df66dfff7d": "/blog/assetscad58c557fda04b9379000cbbaa4c493.webp",
|
||||
"https://github.com/user-attachments/assets/e063e4e6-3d5c-47e4-84a2-c7f904a92a81": "/blog/assetsbc6a72dc53430bbbbeafcc7d921396f4.webp",
|
||||
"https://github.com/user-attachments/assets/e269bd27-d323-43ba-811b-c0f5e4137903": "/blog/assetse12925fba0dda232168e695e6a5e4384.webp",
|
||||
"https://github.com/user-attachments/assets/e3f44bc8-2fa5-441d-8934-943481472450": "/blog/assets3c54d6f2d55fae843fbbfdc0bd7ffec7.webp",
|
||||
"https://github.com/user-attachments/assets/e43dacf6-313e-499c-8888-f1065c53e424": "/blog/assets89b0698da3476c6df24ba1f0a07e438e.webp",
|
||||
@@ -414,6 +442,7 @@
|
||||
"https://github.com/user-attachments/assets/e70c2db6-05c9-43ea-b111-6f6f99e0ae88": "/blog/assets/944c671604833cd2457445b211ebba33.webp",
|
||||
"https://github.com/user-attachments/assets/e887fa04-c553-45f1-917f-5c123ac9c68b": "/blog/assets73ba166f1e6d54e8c860b91f61c23355.webp",
|
||||
"https://github.com/user-attachments/assets/e89d2a56-4bf0-4bff-ac39-0d44789fa858": "/blog/assets9f6d4113be26efbcab41d83ed39dcb14.webp",
|
||||
"https://github.com/user-attachments/assets/e8cb84eb-6eaf-4c72-8693-d28744965c22": "/blog/assetsfa30300bd730d56097bfbce49c5f3d06.webp",
|
||||
"https://github.com/user-attachments/assets/eaa2a1fb-41ad-473d-ac10-a39c05886425": "/blog/assetsf5a62c963127764ebdf1cd226fac3dac.webp",
|
||||
"https://github.com/user-attachments/assets/eaed3762-136f-4297-b161-ca92a27c4982": "/blog/assets/50b38eac1769ae6f13aef72f3d725eec.webp",
|
||||
"https://github.com/user-attachments/assets/eb027093-5ceb-4a9d-8850-b791fbf69a71": "/blog/assetsd0c4369f894abb5ad6e514059b8f378e.webp",
|
||||
@@ -422,15 +451,19 @@
|
||||
"https://github.com/user-attachments/assets/ebdbc01a-a6b5-4bbc-b7ff-240d6015fbfc": "/blog/assets13656829368732a95940edeff9ddfca6.webp",
|
||||
"https://github.com/user-attachments/assets/ed6965c8-6884-4adf-a457-573a96755f55": "/blog/assets2f83a9f03f13e73b7393641078627cf1.webp",
|
||||
"https://github.com/user-attachments/assets/f0b2e72d-9eee-46a8-b094-4834b78764df": "/blog/assets8d6bb40d21d74cfa0312bdec347a11d0.webp",
|
||||
"https://github.com/user-attachments/assets/f0e0a473-d6bf-4358-b9f1-cc6bfb4d74c4": "/blog/assetsfd4606a4b5d801a8764bf333cde77d57.webp",
|
||||
"https://github.com/user-attachments/assets/f1f5c321-0285-46b7-9777-4a6bfa24029e": "/blog/assets939b659e955daf90e2e9e7caba8aa9bd.webp",
|
||||
"https://github.com/user-attachments/assets/f3068287-8ade-4eca-9841-ea67d8ff1226": "/blog/assetsa343af49a2d7da73a3fa51f2086afdd4.webp",
|
||||
"https://github.com/user-attachments/assets/f3177ce2-281c-4ed4-a061-239547b466c6": "/blog/assets86924c724c66931cf61417dbdcc04ee8.webp",
|
||||
"https://github.com/user-attachments/assets/f4dbbadb-7461-4370-a836-09c487fdd206": "/blog/assets94397c91265c37b9f313dc439b90125f.webp",
|
||||
"https://github.com/user-attachments/assets/f54c912d-3ee9-4f85-b8bf-619790e51b49": "/blog/assets620c308554394e72034d27ea743f8bff.webp",
|
||||
"https://github.com/user-attachments/assets/f67180c2-47ba-4b04-9f12-d274c7821085": "/blog/assetscbda3a61a2d158eeb6046e1d1bf9972f.webp",
|
||||
"https://github.com/user-attachments/assets/f6b19eab-42e5-4293-980a-b13fe409045f": "/blog/assets1be39423d2bca3a6ee3f247e02a638be.webp",
|
||||
"https://github.com/user-attachments/assets/f878355f-710b-452e-8606-0c75c47f29d2": "/blog/assets3e2af0090f02059c687b6add6b73a90b.webp",
|
||||
"https://github.com/user-attachments/assets/f9ccce84-4fd4-48ca-9450-40660112d0d7": "/blog/assetsd94f3e0cf32639bea46dbf92e0862f89.webp",
|
||||
"https://github.com/user-attachments/assets/f9f7ed26-e506-4c52-a118-e0bb5e0918db": "/blog/assetse5dff9a2e16a134d85e891e4eb98fe55.webp",
|
||||
"https://github.com/user-attachments/assets/fa8fab19-ace2-4f85-8428-a3a0e28845bb": "/blog/assets/2d678631c55369ba7d753c3ffcb73782.webp",
|
||||
"https://github.com/user-attachments/assets/facdc83c-e789-4649-8060-7f7a10a1b1dd": "/blog/assets05b20e40c03ced0ec8707fed2e8e0f25.webp",
|
||||
"https://github.com/user-attachments/assets/fcdfb9c5-819a-488f-b28d-0857fe861219": "/blog/assets8477415ecec1f37e38ab38ff1217d0a7.webp"
|
||||
"https://github.com/user-attachments/assets/fcdfb9c5-819a-488f-b28d-0857fe861219": "/blog/assets8477415ecec1f37e38ab38ff1217d0a7.webp",
|
||||
"https://github.com/user-attachments/assets/fd60ab55-ead2-4930-ad00-fdf77662f5a0": "/blog/assets276a4e8748e9bd300b30dcd9d0e24980.webp"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
title: Image & Video Generation Redesign
|
||||
description: >-
|
||||
Redesigned image and video generation experience with easy switching between
|
||||
media types, improved memory management, and better overall stability.
|
||||
tags:
|
||||
- Image Generation
|
||||
- Video Generation
|
||||
- Memory
|
||||
---
|
||||
|
||||
# Image & Video Generation Redesign
|
||||
|
||||
This week LobeHub refreshed the image and video generation experience, making it easier to create and browse visual content.
|
||||
|
||||
## Key Updates
|
||||
|
||||
- Image & video generation redesign: completely overhauled the generation interface with a new switch to easily toggle between image and video creation
|
||||
- Memory management: you can now delete all memory entries at once for a clean slate
|
||||
- Bot improvements: restructured bot internals for better reliability and extensibility
|
||||
|
||||
## Experience Improvements
|
||||
|
||||
Fixed visual glitches in the compression view, improved mobile menu behavior, and corrected message count display accuracy.
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
title: 图片与视频生成重设计
|
||||
description: 重新设计图片与视频生成体验,支持媒体类型快速切换,改进记忆管理,提升整体稳定性。
|
||||
tags:
|
||||
- 图片生成
|
||||
- 视频生成
|
||||
- 记忆
|
||||
---
|
||||
|
||||
# 图片与视频生成重设计
|
||||
|
||||
本周 LobeHub 全面升级了图片与视频生成体验,让创作和浏览视觉内容更加便捷。
|
||||
|
||||
## 重要更新
|
||||
|
||||
- 图片与视频生成重设计:全新的生成界面,新增图片 / 视频切换功能,轻松在两种创作模式间自由切换
|
||||
- 记忆管理:支持一键清除所有记忆条目,快速重置对话记忆
|
||||
- Bot 改进:重构 Bot 内部架构,提升可靠性和可扩展性
|
||||
|
||||
## 体验优化
|
||||
|
||||
修复压缩视图的显示异常,改进移动端菜单交互,修正消息计数显示的准确性。
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
title: Agent Task System & Bot Management
|
||||
description: >-
|
||||
Introduced agent task system, in-app notifications, bot management, and
|
||||
improved onboarding experience.
|
||||
tags:
|
||||
- Agent Tasks
|
||||
- Bot Management
|
||||
- Notification
|
||||
- Onboarding
|
||||
---
|
||||
|
||||
# Agent Task System & Bot Management
|
||||
|
||||
This week LobeHub introduced powerful new agent capabilities and a smoother getting-started experience.
|
||||
|
||||
## Key Updates
|
||||
|
||||
- Notification system: receive important updates and alerts directly inside LobeHub
|
||||
- Bot management: manage your bots with custom rendering and richer content support
|
||||
- Agent onboarding: a new guided onboarding flow helps you get started with agents quickly
|
||||
- Skill-specific icons: slash menu commands now show distinct icons for each skill, making them easier to find
|
||||
- GitHub Copilot improvements: better vision support and overall compatibility with GitHub Copilot
|
||||
|
||||
## Experience Improvements
|
||||
|
||||
Moved Marketplace below Resources in the sidebar for a cleaner layout, added a visual hint when AI generation is interrupted, fixed topic transition glitches, and improved error handling with friendlier fallback screens.
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
title: 智能体任务系统与 Bot 管理
|
||||
description: 引入智能体任务系统、应用内通知、Bot 管理,以及改进的引导体验。
|
||||
tags:
|
||||
- 智能体任务
|
||||
- Bot 管理
|
||||
- 通知
|
||||
- 引导
|
||||
---
|
||||
|
||||
# 智能体任务系统与 Bot 管理
|
||||
|
||||
本周 LobeHub 带来了强大的智能体新功能和更流畅的上手体验。
|
||||
|
||||
## 重要更新
|
||||
|
||||
- 通知系统:在 LobeHub 内直接接收重要更新和提醒
|
||||
- Bot 管理:支持管理你的 Bot,提供自定义渲染和更丰富的内容展示
|
||||
- 智能体引导:全新的引导流程帮助你快速上手智能体功能
|
||||
- 技能专属图标:斜杠菜单中的命令现在显示各技能的专属图标,更容易查找
|
||||
- GitHub Copilot 改进:提升视觉识别支持和与 GitHub Copilot 的整体兼容性
|
||||
|
||||
## 体验优化
|
||||
|
||||
将市场入口移至侧边栏资源下方以优化布局,在 AI 生成被中断时添加可视化提示,修复话题切换时的显示异常,并改进错误处理以提供更友好的降级界面。
|
||||
@@ -2,6 +2,17 @@
|
||||
"$schema": "https://github.com/lobehub/lobe-chat/blob/main/docs/changelog/schema.json",
|
||||
"cloud": [],
|
||||
"community": [
|
||||
{
|
||||
"id": "2026-03-30-agent-tasks",
|
||||
"date": "2026-03-30",
|
||||
"versionRange": ["2.1.45", "2.1.46"]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets53e6ec9cf72554dbc1f8224fc0550a03.webp",
|
||||
"id": "2026-03-23-media-memory",
|
||||
"date": "2026-03-23",
|
||||
"versionRange": ["2.1.44"]
|
||||
},
|
||||
{
|
||||
"image": "https://hub-apac-1.lobeobjects.space/blog/assets/4a68a7644501cb513d08670b102a446e.webp",
|
||||
"id": "2026-03-16-search",
|
||||
@@ -14,7 +25,7 @@
|
||||
"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",
|
||||
"image": "/blog/assetsa8e504275f2cd891fabecca985998de0.webp",
|
||||
"id": "2026-01-27-v2",
|
||||
"date": "2026-01-27",
|
||||
"versionRange": ["2.0.1", "2.1.5"]
|
||||
|
||||
@@ -91,17 +91,17 @@ bunx vitest run --silent='passed-only' '[file-path]'
|
||||
|
||||
提交信息请使用以下 emoji 作为前缀:
|
||||
|
||||
| Emoji | 代码 | 类型 | 说明 | 触发发布? |
|
||||
| ----- | ------------------------ | -------- | ------------- | ---------- |
|
||||
| ✨ | `:sparkles:` | feat | 新功能 | 是 |
|
||||
| 🐛 | `:bug:` | fix | Bug 修复 | 是 |
|
||||
| 📝 | `:memo:` | docs | 文档更新 | 否 |
|
||||
| 💄 | `:lipstick:` | style | UI / 样式更改 | 否 |
|
||||
| ♻️ | `:recycle:` | refactor | 代码重构 | 否 |
|
||||
| ✅ | `:white_check_mark:` | test | 测试相关 | 否 |
|
||||
| 🔨 | `:hammer:` | chore | 维护任务 | 否 |
|
||||
| 🚀 | `:rocket:` | perf | 性能优化 | 否 |
|
||||
| 🌐 | `:globe_with_meridians:` | i18n | 国际化 | 否 |
|
||||
| Emoji | 代码 | 类型 | 说明 | 触发发布? |
|
||||
| ----- | ------------------------ | -------- | --------- | ----- |
|
||||
| ✨ | `:sparkles:` | feat | 新功能 | 是 |
|
||||
| 🐛 | `:bug:` | fix | Bug 修复 | 是 |
|
||||
| 📝 | `:memo:` | docs | 文档更新 | 否 |
|
||||
| 💄 | `:lipstick:` | style | UI / 样式更改 | 否 |
|
||||
| ♻️ | `:recycle:` | refactor | 代码重构 | 否 |
|
||||
| ✅ | `:white_check_mark:` | test | 测试相关 | 否 |
|
||||
| 🔨 | `:hammer:` | chore | 维护任务 | 否 |
|
||||
| 🚀 | `:rocket:` | perf | 性能优化 | 否 |
|
||||
| 🌐 | `:globe_with_meridians:` | i18n | 国际化 | 否 |
|
||||
|
||||
### 如何贡献
|
||||
|
||||
|
||||
@@ -907,6 +907,46 @@ table nextauth_verificationtokens {
|
||||
}
|
||||
}
|
||||
|
||||
table notification_deliveries {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`]
|
||||
notification_id uuid [not null]
|
||||
channel text [not null]
|
||||
status text [not null]
|
||||
provider_message_id text
|
||||
failed_reason text
|
||||
sent_at "timestamp with time zone"
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
notification_id [name: 'idx_deliveries_notification']
|
||||
channel [name: 'idx_deliveries_channel']
|
||||
status [name: 'idx_deliveries_status']
|
||||
}
|
||||
}
|
||||
|
||||
table notifications {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`]
|
||||
user_id text [not null]
|
||||
category text [not null]
|
||||
type text [not null]
|
||||
title text [not null]
|
||||
content text [not null]
|
||||
dedupe_key text
|
||||
action_url text
|
||||
is_read boolean [not null, default: false]
|
||||
is_archived boolean [not null, default: false]
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
updated_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
user_id [name: 'idx_notifications_user']
|
||||
(user_id, created_at) [name: 'idx_notifications_user_active']
|
||||
user_id [name: 'idx_notifications_user_unread']
|
||||
(user_id, dedupe_key) [name: 'idx_notifications_dedupe', unique]
|
||||
(updated_at, created_at, id) [name: 'idx_notifications_archived_cleanup']
|
||||
}
|
||||
}
|
||||
|
||||
table oauth_handoffs {
|
||||
id text [pk, not null]
|
||||
client varchar(50) [not null]
|
||||
@@ -1340,6 +1380,166 @@ table sessions {
|
||||
}
|
||||
}
|
||||
|
||||
table briefs {
|
||||
id text [pk, not null]
|
||||
user_id text [not null]
|
||||
task_id text
|
||||
cron_job_id text
|
||||
topic_id text
|
||||
agent_id text
|
||||
type text [not null]
|
||||
priority text [default: 'info']
|
||||
title text [not null]
|
||||
summary text [not null]
|
||||
artifacts jsonb
|
||||
actions jsonb
|
||||
resolved_action text
|
||||
resolved_comment text
|
||||
read_at "timestamp with time zone"
|
||||
resolved_at "timestamp with time zone"
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
user_id [name: 'briefs_user_id_idx']
|
||||
task_id [name: 'briefs_task_id_idx']
|
||||
cron_job_id [name: 'briefs_cron_job_id_idx']
|
||||
agent_id [name: 'briefs_agent_id_idx']
|
||||
type [name: 'briefs_type_idx']
|
||||
priority [name: 'briefs_priority_idx']
|
||||
(user_id, resolved_at) [name: 'briefs_unresolved_idx']
|
||||
}
|
||||
}
|
||||
|
||||
table task_comments {
|
||||
id text [pk, not null]
|
||||
task_id text [not null]
|
||||
user_id text [not null]
|
||||
author_user_id text
|
||||
author_agent_id text
|
||||
content text [not null]
|
||||
editor_data jsonb
|
||||
brief_id text
|
||||
topic_id text
|
||||
accessed_at "timestamp with time zone" [not null, default: `now()`]
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
updated_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
task_id [name: 'task_comments_task_id_idx']
|
||||
user_id [name: 'task_comments_user_id_idx']
|
||||
author_user_id [name: 'task_comments_author_user_id_idx']
|
||||
author_agent_id [name: 'task_comments_agent_id_idx']
|
||||
brief_id [name: 'task_comments_brief_id_idx']
|
||||
topic_id [name: 'task_comments_topic_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
table task_dependencies {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`]
|
||||
task_id text [not null]
|
||||
depends_on_id text [not null]
|
||||
user_id text [not null]
|
||||
type text [not null, default: 'blocks']
|
||||
condition jsonb
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
(task_id, depends_on_id) [name: 'task_deps_unique_idx', unique]
|
||||
task_id [name: 'task_deps_task_id_idx']
|
||||
depends_on_id [name: 'task_deps_depends_on_id_idx']
|
||||
user_id [name: 'task_deps_user_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
table task_documents {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`]
|
||||
task_id text [not null]
|
||||
document_id text [not null]
|
||||
user_id text [not null]
|
||||
pinned_by text [not null, default: 'agent']
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
(task_id, document_id) [name: 'task_docs_unique_idx', unique]
|
||||
task_id [name: 'task_docs_task_id_idx']
|
||||
document_id [name: 'task_docs_document_id_idx']
|
||||
user_id [name: 'task_docs_user_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
table task_topics {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`]
|
||||
task_id text [not null]
|
||||
topic_id text
|
||||
user_id text [not null]
|
||||
seq integer [not null]
|
||||
operation_id text
|
||||
status text [not null, default: 'running']
|
||||
handoff jsonb
|
||||
review_passed integer
|
||||
review_score integer
|
||||
review_scores jsonb
|
||||
review_iteration integer
|
||||
reviewed_at "timestamp with time zone"
|
||||
accessed_at "timestamp with time zone" [not null, default: `now()`]
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
updated_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
(task_id, topic_id) [name: 'task_topics_unique_idx', unique]
|
||||
task_id [name: 'task_topics_task_id_idx']
|
||||
topic_id [name: 'task_topics_topic_id_idx']
|
||||
user_id [name: 'task_topics_user_id_idx']
|
||||
(task_id, status) [name: 'task_topics_status_idx']
|
||||
}
|
||||
}
|
||||
|
||||
table tasks {
|
||||
id text [pk, not null]
|
||||
identifier text [not null]
|
||||
seq integer [not null]
|
||||
created_by_user_id text [not null]
|
||||
created_by_agent_id text
|
||||
assignee_user_id text
|
||||
assignee_agent_id text
|
||||
parent_task_id text
|
||||
name text
|
||||
description varchar(255)
|
||||
instruction text [not null]
|
||||
status text [not null, default: 'backlog']
|
||||
priority integer [default: 0]
|
||||
sort_order integer [default: 0]
|
||||
heartbeat_interval integer [default: 300]
|
||||
heartbeat_timeout integer
|
||||
last_heartbeat_at "timestamp with time zone"
|
||||
schedule_pattern text
|
||||
schedule_timezone text [default: 'UTC']
|
||||
total_topics integer [default: 0]
|
||||
max_topics integer
|
||||
current_topic_id text
|
||||
context jsonb [default: `{}`]
|
||||
config jsonb [default: `{}`]
|
||||
error text
|
||||
started_at "timestamp with time zone"
|
||||
completed_at "timestamp with time zone"
|
||||
accessed_at "timestamp with time zone" [not null, default: `now()`]
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
updated_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
|
||||
(identifier, created_by_user_id) [name: 'tasks_identifier_idx', unique]
|
||||
created_by_user_id [name: 'tasks_created_by_user_id_idx']
|
||||
created_by_agent_id [name: 'tasks_created_by_agent_id_idx']
|
||||
assignee_user_id [name: 'tasks_assignee_user_id_idx']
|
||||
assignee_agent_id [name: 'tasks_assignee_agent_id_idx']
|
||||
parent_task_id [name: 'tasks_parent_task_id_idx']
|
||||
status [name: 'tasks_status_idx']
|
||||
priority [name: 'tasks_priority_idx']
|
||||
(status, last_heartbeat_at) [name: 'tasks_heartbeat_idx']
|
||||
}
|
||||
}
|
||||
|
||||
table threads {
|
||||
id text [pk, not null]
|
||||
title text
|
||||
@@ -1463,6 +1663,7 @@ table user_settings {
|
||||
memory jsonb
|
||||
tool jsonb
|
||||
image jsonb
|
||||
notification jsonb
|
||||
}
|
||||
|
||||
table users {
|
||||
@@ -1477,6 +1678,7 @@ table users {
|
||||
full_name text
|
||||
interests "varchar(64)[]"
|
||||
is_onboarded boolean [default: false]
|
||||
agent_onboarding jsonb
|
||||
onboarding jsonb
|
||||
clerk_created_at "timestamp with time zone"
|
||||
email_verified boolean [not null, default: false]
|
||||
|
||||
@@ -13,11 +13,6 @@ 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**.
|
||||
</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.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -12,10 +12,6 @@ tags:
|
||||
|
||||
# 将 LobeHub 连接到 Discord
|
||||
|
||||
<Callout type={'info'}>
|
||||
此功能目前正在开发中,可能尚未完全稳定。您可以通过在 **设置** → **高级设置** → **开发者模式** 中启用 **开发者模式** 来使用此功能。
|
||||
</Callout>
|
||||
|
||||
通过将 Discord 渠道连接到您的 LobeHub 代理,用户可以直接通过 Discord 服务器频道和私信与 AI 助手互动。
|
||||
|
||||
## 前置条件
|
||||
@@ -28,6 +24,8 @@ tags:
|
||||
<Steps>
|
||||
### 访问 Discord 开发者门户
|
||||
|
||||

|
||||
|
||||
访问 [Discord 开发者门户](https://discord.com/developers/applications),点击 **新建应用程序**。为您的应用程序命名(例如,“LobeHub 助手”),然后点击 **创建**。
|
||||
|
||||
### 创建机器人
|
||||
@@ -36,6 +34,8 @@ tags:
|
||||
|
||||
### 启用特权网关意图
|
||||
|
||||

|
||||
|
||||
在机器人设置页面,向下滚动到 **特权网关意图** 并启用以下选项:
|
||||
|
||||
- **消息内容意图** — 允许机器人读取消息内容(必需)
|
||||
@@ -46,12 +46,16 @@ tags:
|
||||
|
||||
### 复制机器人令牌
|
||||
|
||||

|
||||
|
||||
在 **机器人** 页面,点击 **重置令牌** 以生成您的机器人令牌。复制并安全保存该令牌。
|
||||
|
||||
> **重要提示:** 请将您的机器人令牌视为密码。切勿公开分享或提交到版本控制系统。
|
||||
|
||||
### 复制应用程序 ID 和公钥
|
||||
|
||||

|
||||
|
||||
在左侧菜单中,转到 **常规信息**。复制并保存以下内容:
|
||||
|
||||
- **应用程序 ID**
|
||||
@@ -69,6 +73,8 @@ tags:
|
||||
|
||||
### 填写凭据
|
||||
|
||||

|
||||
|
||||
输入以下字段:
|
||||
|
||||
- **应用程序 ID** — 来自 Discord 应用程序常规信息页面的应用程序 ID
|
||||
@@ -87,6 +93,8 @@ tags:
|
||||
<Steps>
|
||||
### 生成邀请链接
|
||||
|
||||

|
||||
|
||||
在 Discord 开发者门户中,转到 **OAuth2** → **URL 生成器**。选择以下范围:
|
||||
|
||||
- `bot`
|
||||
@@ -103,6 +111,8 @@ tags:
|
||||
|
||||
### 授权机器人
|
||||
|
||||

|
||||
|
||||
复制生成的链接,在浏览器中打开,选择您希望添加机器人的服务器,然后点击 **授权**。
|
||||
</Steps>
|
||||
|
||||
|
||||
@@ -14,10 +14,6 @@ tags:
|
||||
|
||||
# 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 channel to your LobeHub agent, team members can interact with the AI assistant directly in Feishu private chats and group conversations.
|
||||
|
||||
> If you are using the international version (Lark), please refer to the [Lark setup guide](/docs/usage/channels/lark).
|
||||
@@ -38,6 +34,8 @@ By connecting a Feishu channel to your LobeHub agent, team members can interact
|
||||
|
||||
Click **Create Enterprise App**. Fill in the app name (e.g., "LobeHub 助手"), description, and icon, then submit the form.
|
||||
|
||||

|
||||
|
||||
### Copy App Credentials
|
||||
|
||||
Go to **Credentials & Basic Info** and copy:
|
||||
@@ -46,6 +44,8 @@ By connecting a Feishu channel to your LobeHub agent, team members can interact
|
||||
- **App Secret**
|
||||
|
||||
> **Important:** Keep your App Secret confidential. Never share it publicly.
|
||||
|
||||

|
||||
</Steps>
|
||||
|
||||
## Step 2: Configure App Permissions and Bot
|
||||
@@ -87,9 +87,13 @@ By connecting a Feishu channel to your LobeHub agent, team members can interact
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
|
||||
### Enable Bot Capability
|
||||
|
||||
Go to **App Capability** → **Bot**. Toggle the bot capability on and set your preferred bot name.
|
||||
|
||||

|
||||
</Steps>
|
||||
|
||||
## Step 3: Configure Feishu in LobeHub
|
||||
@@ -111,6 +115,8 @@ By connecting a Feishu channel to your LobeHub agent, team members can interact
|
||||
### 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 Feishu
|
||||
@@ -132,16 +138,22 @@ By connecting a Feishu channel to your LobeHub agent, team members can interact
|
||||
|
||||
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 Feishu
|
||||
- **Encrypt Key** (optional) — Used to decrypt encrypted event payloads
|
||||
|
||||
Click **Save Configuration** again to apply.
|
||||
|
||||

|
||||
</Steps>
|
||||
|
||||
## Step 5: Publish the App
|
||||
@@ -151,6 +163,8 @@ By connecting a Feishu channel to your LobeHub agent, team members can interact
|
||||
|
||||
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.
|
||||
|
||||
@@ -10,11 +10,6 @@ tags:
|
||||
|
||||
# 将 LobeHub 连接到飞书
|
||||
|
||||
<Callout type={'info'}>
|
||||
此功能目前正在开发中,可能尚未完全稳定。您可以通过在 **设置** → **高级设置** → **开发者模式**
|
||||
中启用 **开发者模式** 来使用此功能。
|
||||
</Callout>
|
||||
|
||||
通过将飞书渠道连接到您的 LobeHub 代理,团队成员可以直接在飞书的私聊和群组对话中与 AI 助手互动。
|
||||
|
||||
> 如果您使用的是国际版(Lark),请参阅 [Lark 设置指南](/docs/usage/channels/lark)。
|
||||
@@ -35,6 +30,8 @@ tags:
|
||||
|
||||
点击 **创建企业应用**。填写应用名称(例如 "LobeHub 助手")、描述和图标,然后提交表单。
|
||||
|
||||

|
||||
|
||||
### 复制应用凭证
|
||||
|
||||
进入 **凭证与基本信息**,复制以下内容:
|
||||
@@ -43,6 +40,8 @@ tags:
|
||||
- **应用密钥**
|
||||
|
||||
> **重要提示:** 请妥善保管您的应用密钥。切勿公开分享。
|
||||
|
||||

|
||||
</Steps>
|
||||
|
||||
## 第二步:配置应用权限和机器人功能
|
||||
@@ -84,9 +83,13 @@ tags:
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
|
||||
### 启用机器人功能
|
||||
|
||||
进入 **应用能力** → **机器人**。开启机器人功能并设置您喜欢的机器人名称。
|
||||
|
||||

|
||||
</Steps>
|
||||
|
||||
## 第三步:在 LobeHub 中配置飞书
|
||||
@@ -108,6 +111,8 @@ tags:
|
||||
### 保存并复制 Webhook URL
|
||||
|
||||
点击 **保存配置**。保存后,将显示一个 **事件订阅 URL**。复制此 URL—— 您将在下一步中需要它。
|
||||
|
||||

|
||||
</Steps>
|
||||
|
||||
## 第四步:在飞书中设置事件订阅
|
||||
@@ -129,16 +134,22 @@ tags:
|
||||
|
||||
这将使您的应用能够接收消息并将其转发到 LobeHub。
|
||||
|
||||

|
||||
|
||||
### (推荐)填写 Verification Token 和 Encrypt Key
|
||||
|
||||
配置事件订阅后,您可以在事件订阅页面顶部的 **加密策略** 中找到 **Verification Token** 和 **Encrypt Key**。
|
||||
|
||||

|
||||
|
||||
返回 LobeHub 的渠道设置,填写:
|
||||
|
||||
- **Verification Token** — 用于验证 webhook 事件是否来自飞书
|
||||
- **Encrypt Key**(可选)— 用于解密加密事件负载
|
||||
|
||||
再次点击 **保存配置** 以应用。
|
||||
|
||||

|
||||
</Steps>
|
||||
|
||||
## 第五步:发布应用
|
||||
@@ -148,6 +159,8 @@ tags:
|
||||
|
||||
在您的应用设置中,进入 **版本管理与发布**。创建一个新版本并填写发布说明。
|
||||
|
||||

|
||||
|
||||
### 提交审核
|
||||
|
||||
提交版本进行审核并发布。对于企业自管理应用,通常会自动批准。
|
||||
|
||||
@@ -13,10 +13,6 @@ tags:
|
||||
|
||||
# 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).
|
||||
@@ -37,6 +33,8 @@ By connecting a Lark channel to your LobeHub agent, team members can interact wi
|
||||
|
||||
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:
|
||||
@@ -45,6 +43,8 @@ By connecting a Lark channel to your LobeHub agent, team members can interact wi
|
||||
- **App Secret**
|
||||
|
||||
> **Important:** Keep your App Secret confidential. Never share it publicly.
|
||||
|
||||

|
||||
</Steps>
|
||||
|
||||
## Step 2: Configure App Permissions and Bot
|
||||
@@ -82,6 +82,8 @@ By connecting a Lark channel to your LobeHub agent, team members can interact wi
|
||||
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.
|
||||
@@ -106,6 +108,8 @@ By connecting a Lark channel to your LobeHub agent, team members can interact wi
|
||||
### 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
|
||||
@@ -127,6 +131,8 @@ By connecting a Lark channel to your LobeHub agent, team members can interact wi
|
||||
|
||||
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**.
|
||||
@@ -137,6 +143,8 @@ By connecting a Lark channel to your LobeHub agent, team members can interact wi
|
||||
- **Encrypt Key** (optional) — Used to decrypt encrypted event payloads
|
||||
|
||||
Click **Save Configuration** again to apply.
|
||||
|
||||

|
||||
</Steps>
|
||||
|
||||
## Step 5: Publish the App
|
||||
@@ -149,6 +157,8 @@ By connecting a Lark channel to your LobeHub agent, team members can interact wi
|
||||
### 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
|
||||
|
||||
@@ -10,11 +10,6 @@ tags:
|
||||
|
||||
# 将 LobeHub 连接到 Lark
|
||||
|
||||
<Callout type={'info'}>
|
||||
此功能目前正在开发中,可能尚未完全稳定。您可以通过在 **设置** → **高级设置** → **开发者模式**
|
||||
中启用 **开发者模式** 来使用此功能。
|
||||
</Callout>
|
||||
|
||||
通过将 Lark 渠道连接到您的 LobeHub 代理,团队成员可以直接在 Lark 的私聊和群组对话中与 AI 助手互动。
|
||||
|
||||
> 如果您使用的是中国版(飞书),请参阅[飞书设置指南](/docs/usage/channels/feishu)。
|
||||
@@ -35,6 +30,8 @@ tags:
|
||||
|
||||
点击 **Create Enterprise App**。填写应用名称(例如 "LobeHub Assistant")、描述和图标,然后提交表单。
|
||||
|
||||

|
||||
|
||||
### 复制应用凭证
|
||||
|
||||
进入 **Credentials & Basic Info**,复制以下内容:
|
||||
@@ -43,6 +40,8 @@ tags:
|
||||
- **App Secret**
|
||||
|
||||
> **重要提示:** 请妥善保管您的 App Secret。切勿公开分享。
|
||||
|
||||

|
||||
</Steps>
|
||||
|
||||
## 第二步:配置应用权限和机器人功能
|
||||
@@ -80,6 +79,8 @@ tags:
|
||||
以上权限码已针对 Lark(国际版)进行调整。部分飞书特有的权限码(如 `aily:*`、`corehr:*`、`im:chat.access_event.bot_p2p_chat:read`)在 Lark 上不可用,已被排除。
|
||||
</Callout>
|
||||
|
||||

|
||||
|
||||
### 启用机器人功能
|
||||
|
||||
进入 **App Capability** → **Bot**。开启机器人功能并设置您喜欢的机器人名称。
|
||||
@@ -104,6 +105,8 @@ tags:
|
||||
### 保存并复制 Webhook URL
|
||||
|
||||
点击 **Save Configuration**。保存后,将显示一个 **Event Subscription URL**。复制此 URL —— 您将在下一步中需要它。
|
||||
|
||||

|
||||
</Steps>
|
||||
|
||||
## 第四步:在 Lark 中设置事件订阅
|
||||
@@ -125,6 +128,8 @@ tags:
|
||||
|
||||
这将使您的应用能够接收消息并将其转发到 LobeHub。
|
||||
|
||||

|
||||
|
||||
### (推荐)填写 Verification Token 和 Encrypt Key
|
||||
|
||||
配置事件订阅后,您可以在事件订阅页面顶部的 **Encryption Strategy** 中找到 **Verification Token** 和 **Encrypt Key**。
|
||||
@@ -135,6 +140,8 @@ tags:
|
||||
- **Encrypt Key**(可选)— 用于解密加密事件负载
|
||||
|
||||
再次点击 **Save Configuration** 以应用。
|
||||
|
||||

|
||||
</Steps>
|
||||
|
||||
## 第五步:发布应用
|
||||
@@ -147,6 +154,8 @@ tags:
|
||||
### 提交审核
|
||||
|
||||
提交版本进行审核并发布。对于企业自管理应用,通常会自动批准。
|
||||
|
||||

|
||||
</Steps>
|
||||
|
||||
## 第六步:测试连接
|
||||
|
||||
@@ -19,10 +19,6 @@ tags:
|
||||
|
||||
# Channels
|
||||
|
||||
<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>
|
||||
|
||||
Channels allow you to connect your LobeHub agents to external messaging platforms. Once connected, users can interact with your AI assistant directly in the chat apps they already use — no need to visit LobeHub.
|
||||
|
||||
## Supported Platforms
|
||||
@@ -47,9 +43,8 @@ Each channel integration works by linking a bot account on the target platform t
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Enable **Developer Mode** in LobeHub: **Settings** → **Advanced Settings** → **Developer Mode**
|
||||
2. Navigate to your agent's settings and select the **Channels** tab
|
||||
3. Choose a platform and follow the setup guide:
|
||||
1. Navigate to your agent's settings and select the **Channels** tab
|
||||
2. Choose a platform and follow the setup guide:
|
||||
- [Discord](/docs/usage/channels/discord)
|
||||
- [Slack](/docs/usage/channels/slack)
|
||||
- [Telegram](/docs/usage/channels/telegram)
|
||||
|
||||
@@ -18,10 +18,6 @@ tags:
|
||||
|
||||
# 渠道
|
||||
|
||||
<Callout type={'info'}>
|
||||
此功能目前正在开发中,可能尚未完全稳定。您可以通过在 **设置** → **高级设置** → **开发者模式** 中启用 **开发者模式** 来开启此功能。
|
||||
</Callout>
|
||||
|
||||
渠道功能允许您将 LobeHub 代理连接到外部消息平台。一旦连接,用户可以直接在他们已经使用的聊天应用中与您的 AI 助手互动,无需访问 LobeHub。
|
||||
|
||||
## 支持的平台
|
||||
@@ -46,9 +42,8 @@ tags:
|
||||
|
||||
## 快速开始
|
||||
|
||||
1. 在 LobeHub 中启用 **开发者模式**:**设置** → **高级设置** → **开发者模式**
|
||||
2. 前往您的代理设置页面,选择 **渠道** 标签
|
||||
3. 选择一个平台并按照设置指南操作:
|
||||
1. 前往您的代理设置页面,选择 **渠道** 标签
|
||||
2. 选择一个平台并按照设置指南操作:
|
||||
- [Discord](/docs/usage/channels/discord)
|
||||
- [Slack](/docs/usage/channels/slack)
|
||||
- [Telegram](/docs/usage/channels/telegram)
|
||||
|
||||
@@ -13,11 +13,6 @@ tags:
|
||||
|
||||
# Connect LobeHub to QQ
|
||||
|
||||
<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 QQ channel to your LobeHub agent, users can interact with the AI assistant through QQ group chats, guild channels, and direct messages.
|
||||
|
||||
## Prerequisites
|
||||
@@ -45,6 +40,8 @@ By connecting a QQ channel to your LobeHub agent, users can interact with the AI
|
||||
|
||||
> **Important:** Keep your App Secret confidential. Never share it publicly.
|
||||
|
||||

|
||||
|
||||
### Configure Webhook URL
|
||||
|
||||
In the QQ Open Platform, navigate to **Development Settings** → **Callback Configuration**. You will need to paste the LobeHub Callback URL here after completing Step 2.
|
||||
@@ -69,6 +66,8 @@ By connecting a QQ channel to your LobeHub agent, users can interact with the AI
|
||||
Click **Save Configuration**. After saving, a **Callback URL** will be displayed. Copy this URL.
|
||||
|
||||
Your credentials will be encrypted and stored securely.
|
||||
|
||||

|
||||
</Steps>
|
||||
|
||||
## Step 3: Configure Callback in QQ Open Platform
|
||||
@@ -87,6 +86,8 @@ By connecting a QQ channel to your LobeHub agent, users can interact with the AI
|
||||
- `AT_MESSAGE_CREATE` — Triggered when the bot is @mentioned in a guild channel
|
||||
- `DIRECT_MESSAGE_CREATE` — Triggered for direct messages in a guild
|
||||
|
||||

|
||||
|
||||
### Verify the Callback
|
||||
|
||||
The QQ Open Platform will send a verification request to your Callback URL. LobeHub handles this automatically using Ed25519 signature verification.
|
||||
@@ -102,6 +103,8 @@ By connecting a QQ channel to your LobeHub agent, users can interact with the AI
|
||||
### Wait for Approval
|
||||
|
||||
QQ will review your bot. Once approved, the bot will be published and ready to use. For sandbox testing, you can add test users directly without publishing.
|
||||
|
||||

|
||||
</Steps>
|
||||
|
||||
## Step 5: Test the Connection
|
||||
|
||||
@@ -10,11 +10,6 @@ tags:
|
||||
|
||||
# 将 LobeHub 连接到 QQ
|
||||
|
||||
<Callout type={'info'}>
|
||||
此功能目前正在开发中,可能尚未完全稳定。您可以通过在 **设置** → **高级设置** → **开发者模式**
|
||||
中启用 **开发者模式** 来使用此功能。
|
||||
</Callout>
|
||||
|
||||
通过将 QQ 渠道连接到您的 LobeHub 代理,用户可以通过 QQ 群聊、频道和私聊与 AI 助手互动。
|
||||
|
||||
## 前置条件
|
||||
@@ -42,6 +37,8 @@ tags:
|
||||
|
||||
> **重要提示:** 请妥善保管您的 App Secret,切勿公开分享。
|
||||
|
||||

|
||||
|
||||
### 配置回调地址
|
||||
|
||||
在 QQ 开放平台中,导航到 **开发设置** → **回调配置**。您需要在完成第二步后将 LobeHub 的回调地址粘贴到此处。
|
||||
@@ -66,6 +63,8 @@ tags:
|
||||
点击 **保存配置**。保存后,将显示一个 **回调地址(Callback URL)**。复制此地址。
|
||||
|
||||
您的凭证将被加密并安全存储。
|
||||
|
||||

|
||||
</Steps>
|
||||
|
||||
## 第三步:在 QQ 开放平台配置回调
|
||||
@@ -84,6 +83,8 @@ tags:
|
||||
- `AT_MESSAGE_CREATE` — 在频道中被 @提及时触发
|
||||
- `DIRECT_MESSAGE_CREATE` — 频道私信时触发
|
||||
|
||||

|
||||
|
||||
### 验证回调
|
||||
|
||||
QQ 开放平台将向您的回调地址发送验证请求。LobeHub 会通过 Ed25519 签名验证自动处理此请求。
|
||||
@@ -99,6 +100,8 @@ tags:
|
||||
### 等待审核通过
|
||||
|
||||
QQ 会对您的机器人进行审核。审核通过后,机器人将发布并可投入使用。在沙盒测试阶段,您可以直接添加测试用户而无需发布。
|
||||
|
||||

|
||||
</Steps>
|
||||
|
||||
## 第五步:测试连接
|
||||
|
||||
@@ -13,11 +13,6 @@ tags:
|
||||
|
||||
# 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
|
||||
@@ -39,6 +34,8 @@ By connecting a Slack channel to your LobeHub agent, users can interact with the
|
||||
- **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:
|
||||
@@ -66,6 +63,8 @@ By connecting a Slack channel to your LobeHub agent, users can interact with the
|
||||
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
|
||||
@@ -92,6 +91,8 @@ By connecting a Slack channel to your LobeHub agent, users can interact with the
|
||||
### 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
|
||||
@@ -124,6 +125,8 @@ By connecting a Slack channel to your LobeHub agent, users can interact with the
|
||||
### Save Changes
|
||||
|
||||
Click **Save Changes** at the bottom of the page.
|
||||
|
||||

|
||||
</Steps>
|
||||
|
||||
## Step 4: Test the Connection
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user