mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-20 22:26:05 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e96924774 | |||
| 4b9ddc4fc2 | |||
| f0b16a5565 | |||
| 97987229a8 | |||
| 4aa6dcd01a |
@@ -20,11 +20,9 @@ This is NON-NEGOTIABLE. Skipping Linear comments is a workflow violation.
|
||||
## Workflow
|
||||
|
||||
1. **Retrieve issue details** before starting: `mcp__linear-server__get_issue`
|
||||
2. **Read images**: If the issue description contains images, MUST use `mcp__linear-server__extract_images` to read image content for full context
|
||||
3. **Check for sub-issues**: Use `mcp__linear-server__list_issues` with `parentId` filter
|
||||
4. **Mark as In Progress**: When starting to plan or implement an issue, immediately update status to **"In Progress"** via `mcp__linear-server__update_issue`
|
||||
5. **Update issue status** when completing: `mcp__linear-server__update_issue`
|
||||
6. **Add completion comment** (REQUIRED): `mcp__linear-server__create_comment`
|
||||
2. **Check for sub-issues**: Use `mcp__linear-server__list_issues` with `parentId` filter
|
||||
3. **Update issue status** when completing: `mcp__linear-server__update_issue`
|
||||
4. **Add completion comment** (REQUIRED): `mcp__linear-server__create_comment`
|
||||
|
||||
## Creating Issues
|
||||
|
||||
|
||||
@@ -24,277 +24,94 @@ Two approaches for local testing on macOS:
|
||||
|
||||
Use `agent-browser` to automate Chromium-based apps via Chrome DevTools Protocol.
|
||||
|
||||
Install via `npm i -g agent-browser`, `brew install agent-browser`, or `cargo install agent-browser`. Run `agent-browser install` to download Chrome. Run `agent-browser upgrade` to update.
|
||||
## Prerequisites
|
||||
|
||||
- `agent-browser` CLI installed globally (`agent-browser --version`)
|
||||
|
||||
## Core Workflow
|
||||
|
||||
Every browser automation follows this pattern:
|
||||
|
||||
1. **Navigate**: `agent-browser open <url>`
|
||||
2. **Snapshot**: `agent-browser snapshot -i` (get element refs like `@e1`, `@e2`)
|
||||
3. **Interact**: Use refs to click, fill, select
|
||||
4. **Re-snapshot**: After navigation or DOM changes, get fresh refs
|
||||
### 1. Snapshot → Find Elements
|
||||
|
||||
```bash
|
||||
agent-browser open https://example.com/form
|
||||
agent-browser snapshot -i
|
||||
# Output: @e1 [input type="email"], @e2 [input type="password"], @e3 [button] "Submit"
|
||||
|
||||
agent-browser fill @e1 "user@example.com"
|
||||
agent-browser fill @e2 "password123"
|
||||
agent-browser click @e3
|
||||
agent-browser wait --load networkidle
|
||||
agent-browser snapshot -i # Check result
|
||||
agent-browser --cdp -i < PORT > snapshot # Interactive elements only
|
||||
agent-browser --cdp -i -C < PORT > snapshot # Include contenteditable elements
|
||||
```
|
||||
|
||||
## Command Chaining
|
||||
Returns element refs like `@e1`, `@e2`. **Refs are ephemeral** — re-snapshot after any page change.
|
||||
|
||||
### 2. Interact
|
||||
|
||||
```bash
|
||||
# Chain open + wait + snapshot in one call
|
||||
agent-browser open https://example.com && agent-browser wait --load networkidle && agent-browser snapshot -i
|
||||
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
|
||||
```
|
||||
|
||||
Use `&&` when you don't need to read intermediate output. Run commands separately when you need to parse output first (e.g., snapshot to discover refs, then interact).
|
||||
|
||||
## Essential Commands
|
||||
### 3. Wait
|
||||
|
||||
```bash
|
||||
# Navigation
|
||||
agent-browser open <url> # Navigate (aliases: goto, navigate)
|
||||
agent-browser close # Close browser
|
||||
agent-browser close --all # Close all active sessions
|
||||
|
||||
# Snapshot
|
||||
agent-browser snapshot -i # Interactive elements with refs (recommended)
|
||||
agent-browser snapshot -s "#selector" # Scope to CSS selector
|
||||
|
||||
# Interaction (use @refs from snapshot)
|
||||
agent-browser click @e1 # Click element
|
||||
agent-browser click @e1 --new-tab # Click and open in new tab
|
||||
agent-browser fill @e2 "text" # Clear and type text
|
||||
agent-browser type @e2 "text" # Type without clearing
|
||||
agent-browser select @e1 "option" # Select dropdown option
|
||||
agent-browser check @e1 # Check checkbox
|
||||
agent-browser press Enter # Press key
|
||||
agent-browser keyboard type "text" # Type at current focus (no selector)
|
||||
agent-browser keyboard inserttext "text" # Insert without key events
|
||||
agent-browser scroll down 500 # Scroll page
|
||||
agent-browser scroll down 500 --selector "div.content" # Scroll within container
|
||||
|
||||
# Get information
|
||||
agent-browser get text @e1 # Get element text
|
||||
agent-browser get url # Get current URL
|
||||
agent-browser get title # Get page title
|
||||
agent-browser get cdp-url # Get CDP WebSocket URL
|
||||
|
||||
# Wait
|
||||
agent-browser wait @e1 # Wait for element
|
||||
agent-browser wait --load networkidle # Wait for network idle
|
||||
agent-browser wait --url "**/page" # Wait for URL pattern
|
||||
agent-browser wait 2000 # Wait milliseconds
|
||||
agent-browser wait --text "Welcome" # Wait for text to appear
|
||||
agent-browser wait --fn "!document.body.innerText.includes('Loading...')" # Wait for text to disappear
|
||||
agent-browser wait "#spinner" --state hidden # Wait for element to disappear
|
||||
|
||||
# Downloads
|
||||
agent-browser download @e1 ./file.pdf # Click element to trigger download
|
||||
agent-browser wait --download ./output.zip # Wait for any download to complete
|
||||
|
||||
# Network
|
||||
agent-browser network requests # Inspect tracked requests
|
||||
agent-browser network requests --type xhr,fetch # Filter by resource type
|
||||
agent-browser network requests --method POST # Filter by HTTP method
|
||||
agent-browser network route "**/api/*" --abort # Block matching requests
|
||||
agent-browser network har start # Start HAR recording
|
||||
agent-browser network har stop ./capture.har # Stop and save HAR file
|
||||
|
||||
# Viewport & Device Emulation
|
||||
agent-browser set viewport 1920 1080 # Set viewport size (default: 1280x720)
|
||||
agent-browser set viewport 1920 1080 2 # 2x retina
|
||||
agent-browser set device "iPhone 14" # Emulate device (viewport + user agent)
|
||||
|
||||
# Capture
|
||||
agent-browser screenshot # Screenshot to temp dir
|
||||
agent-browser screenshot --full # Full page screenshot
|
||||
agent-browser screenshot --annotate # Annotated screenshot with numbered element labels
|
||||
agent-browser pdf output.pdf # Save as PDF
|
||||
|
||||
# Clipboard
|
||||
agent-browser clipboard read # Read text from clipboard
|
||||
agent-browser clipboard write "text" # Write text to clipboard
|
||||
agent-browser clipboard copy # Copy current selection
|
||||
agent-browser clipboard paste # Paste from clipboard
|
||||
|
||||
# Dialogs (alert, confirm, prompt, beforeunload)
|
||||
agent-browser dialog accept # Accept dialog
|
||||
agent-browser dialog accept "input" # Accept prompt dialog with text
|
||||
agent-browser dialog dismiss # Dismiss/cancel dialog
|
||||
agent-browser dialog status # Check if dialog is open
|
||||
|
||||
# Diff (compare page states)
|
||||
agent-browser diff snapshot # Compare current vs last snapshot
|
||||
agent-browser diff screenshot --baseline before.png # Visual pixel diff
|
||||
agent-browser diff url <url1> <url2> # Compare two pages
|
||||
|
||||
# Streaming
|
||||
agent-browser stream enable # Start WebSocket streaming
|
||||
agent-browser stream status # Inspect streaming state
|
||||
agent-browser stream disable # Stop streaming
|
||||
agent-browser --cdp 2000 < PORT > wait # Wait ms
|
||||
agent-browser --cdp --load networkidle < PORT > wait # Wait for network
|
||||
```
|
||||
|
||||
## Batch Execution
|
||||
For waits >30s, use `sleep N` in bash instead — `agent-browser wait` blocks the daemon.
|
||||
|
||||
### 4. Screenshot & Verify
|
||||
|
||||
```bash
|
||||
echo '[
|
||||
["open", "https://example.com"],
|
||||
["snapshot", "-i"],
|
||||
["click", "@e1"],
|
||||
["screenshot", "result.png"]
|
||||
]' | agent-browser batch --json
|
||||
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
|
||||
```
|
||||
|
||||
## Authentication
|
||||
Read screenshots with the `Read` tool for visual verification.
|
||||
|
||||
### 5. Evaluate JavaScript
|
||||
|
||||
```bash
|
||||
# Option 1: Auth vault (credentials stored encrypted)
|
||||
echo "$PASSWORD" | agent-browser auth save myapp --url https://app.example.com/login --username user --password-stdin
|
||||
agent-browser auth login myapp
|
||||
|
||||
# Option 2: Session name (auto-save/restore cookies + localStorage)
|
||||
agent-browser --session-name myapp open https://app.example.com/login
|
||||
agent-browser close # State auto-saved
|
||||
agent-browser --session-name myapp open https://app.example.com/dashboard # Auto-restored
|
||||
|
||||
# Option 3: Persistent profile
|
||||
agent-browser --profile ~/.myapp open https://app.example.com/login
|
||||
|
||||
# Option 4: State file
|
||||
agent-browser state save auth.json
|
||||
agent-browser state load auth.json
|
||||
agent-browser --cdp "document.title" < PORT > eval
|
||||
```
|
||||
|
||||
## Semantic Locators (Alternative to Refs)
|
||||
For multi-line JS, use `--stdin`:
|
||||
|
||||
```bash
|
||||
agent-browser find text "Sign In" click
|
||||
agent-browser find label "Email" fill "user@test.com"
|
||||
agent-browser find role button click --name "Submit"
|
||||
agent-browser find placeholder "Search" type "query"
|
||||
agent-browser find testid "submit-btn" click
|
||||
```
|
||||
|
||||
## JavaScript Evaluation (eval)
|
||||
|
||||
```bash
|
||||
# Simple expressions
|
||||
agent-browser eval 'document.title'
|
||||
|
||||
# Complex JS: use --stdin with heredoc (RECOMMENDED)
|
||||
agent-browser eval --stdin << 'EVALEOF'
|
||||
JSON.stringify(
|
||||
Array.from(document.querySelectorAll("img"))
|
||||
.filter(i => !i.alt)
|
||||
.map(i => ({ src: i.src.split("/").pop(), width: i.width }))
|
||||
)
|
||||
agent-browser --cdp --stdin < PORT > eval << 'EVALEOF'
|
||||
(function() {
|
||||
return JSON.stringify({ title: document.title, url: location.href });
|
||||
})()
|
||||
EVALEOF
|
||||
|
||||
# Base64 encoding (avoids all shell escaping issues)
|
||||
agent-browser eval -b "$(echo -n 'document.title' | base64)"
|
||||
```
|
||||
|
||||
## Ref Lifecycle
|
||||
|
||||
Refs (`@e1`, `@e2`, etc.) are invalidated when the page changes. Always re-snapshot after clicking links/buttons that navigate, form submissions, or dynamic content loading.
|
||||
|
||||
## Annotated Screenshots (Vision Mode)
|
||||
|
||||
```bash
|
||||
agent-browser screenshot --annotate
|
||||
# Output includes the image path and a legend:
|
||||
# [1] @e1 button "Submit"
|
||||
# [2] @e2 link "Home"
|
||||
agent-browser click @e2 # Click using ref from annotated screenshot
|
||||
```
|
||||
|
||||
## Parallel Sessions
|
||||
|
||||
```bash
|
||||
agent-browser --session site1 open https://site-a.com
|
||||
agent-browser --session site2 open https://site-b.com
|
||||
agent-browser session list
|
||||
```
|
||||
|
||||
## Connect to Existing Chrome
|
||||
|
||||
```bash
|
||||
agent-browser --auto-connect snapshot # Auto-discover running Chrome
|
||||
agent-browser --cdp 9222 snapshot # Explicit CDP port
|
||||
```
|
||||
|
||||
## iOS Simulator (Mobile Safari)
|
||||
|
||||
```bash
|
||||
agent-browser device list
|
||||
agent-browser -p ios --device "iPhone 16 Pro" open https://example.com
|
||||
agent-browser -p ios snapshot -i
|
||||
agent-browser -p ios tap @e1
|
||||
agent-browser -p ios swipe up
|
||||
agent-browser -p ios screenshot mobile.png
|
||||
agent-browser -p ios close
|
||||
```
|
||||
|
||||
## Observability Dashboard
|
||||
|
||||
```bash
|
||||
agent-browser dashboard install
|
||||
agent-browser dashboard start # Background server on port 4848
|
||||
agent-browser dashboard stop
|
||||
```
|
||||
|
||||
## Cloud Providers
|
||||
|
||||
Use `-p <provider>` to run against cloud browsers: `agentcore`, `browserbase`, `browserless`, `browseruse`, `kernel`.
|
||||
|
||||
## Browser Engine Selection
|
||||
|
||||
```bash
|
||||
agent-browser --engine lightpanda open example.com # 10x faster, 10x less memory
|
||||
```
|
||||
|
||||
## Electron (LobeHub Desktop)
|
||||
|
||||
### Setup / Teardown
|
||||
|
||||
Use the `electron-dev.sh` script to manage the Electron dev environment. It handles process lifecycle, waits for SPA readiness, and reliably kills all child processes (main + helpers + vite).
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
SCRIPT=".agents/skills/local-testing/scripts/electron-dev.sh"
|
||||
# 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
|
||||
|
||||
# Start Electron dev with CDP (idempotent — skips if already running)
|
||||
$SCRIPT start
|
||||
# 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 &
|
||||
|
||||
# Check if Electron is running and CDP is reachable
|
||||
$SCRIPT status
|
||||
# 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
|
||||
|
||||
# Kill all Electron-related processes (main + helper + vite)
|
||||
$SCRIPT stop
|
||||
|
||||
# Force fresh restart
|
||||
$SCRIPT restart
|
||||
# 4. Wait for renderer, then connect
|
||||
sleep 15 && agent-browser --cdp 9222 wait 3000
|
||||
```
|
||||
|
||||
After `start` succeeds, connect with: `agent-browser --cdp 9222 snapshot -i`
|
||||
|
||||
**Always run `$SCRIPT stop` when done testing** — `pkill -f "Electron"` alone won't catch all helper processes.
|
||||
|
||||
#### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ----------------- | ----------------------- | ---------------------------------------- |
|
||||
| `CDP_PORT` | `9222` | Chrome DevTools Protocol port |
|
||||
| `ELECTRON_LOG` | `/tmp/electron-dev.log` | Electron process log |
|
||||
| `ELECTRON_WAIT_S` | `60` | Max seconds to wait for Electron process |
|
||||
| `RENDERER_WAIT_S` | `60` | Max seconds to wait for SPA to load |
|
||||
**Critical:** `npx electron-vite dev` MUST run from `apps/desktop/` directory, not project root.
|
||||
|
||||
### LobeHub-Specific Patterns
|
||||
|
||||
@@ -370,9 +187,6 @@ agent-browser --cdp 9222 eval "JSON.stringify(window.__CAPTURED_ERRORS)"
|
||||
"<URL>" &
|
||||
sleep 5
|
||||
agent-browser --cdp 9222 snapshot -i
|
||||
|
||||
# Or auto-discover running Chrome with remote debugging
|
||||
agent-browser --auto-connect snapshot -i
|
||||
```
|
||||
|
||||
---
|
||||
@@ -1001,17 +815,16 @@ echo "Result saved to /tmp/${APP_NAME,,}-bot-test.png"
|
||||
|
||||
Ready-to-use scripts in `.agents/skills/local-testing/scripts/`:
|
||||
|
||||
| Script | Usage |
|
||||
| ------------------------- | --------------------------------------------------- |
|
||||
| `electron-dev.sh` | Manage Electron dev env (start/stop/status/restart) |
|
||||
| `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 |
|
||||
| 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
|
||||
|
||||
@@ -1094,19 +907,16 @@ The script automatically:
|
||||
|
||||
### agent-browser
|
||||
|
||||
- **Daemon can get stuck** — if commands hang, `agent-browser close --all` or `pkill -f agent-browser` to reset
|
||||
- **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
|
||||
- **Dialogs block all commands** — if commands time out, check `agent-browser dialog status`
|
||||
- **Default timeout is 25s** — override with `AGENT_BROWSER_DEFAULT_TIMEOUT` (ms) or use explicit waits
|
||||
- **Shell quoting corrupts eval** — use `eval --stdin <<'EVALEOF'` for complex JS
|
||||
|
||||
### Electron-specific
|
||||
|
||||
- **Always use `electron-dev.sh stop` to clean up** — `pkill -f "Electron"` only kills the main process; helper processes (GPU, renderer, network) survive. The script finds and kills all of them via PID matching against the project's electron binary path.
|
||||
- **`npx electron-vite dev` must run from `apps/desktop/`** — running from project root fails silently. The `electron-dev.sh` script handles this automatically.
|
||||
- **`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__`
|
||||
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# electron-dev.sh — Manage Electron dev environment for testing
|
||||
#
|
||||
# Usage:
|
||||
# ./electron-dev.sh start # Kill existing, start fresh, wait until ready
|
||||
# ./electron-dev.sh stop # Kill all Electron-related processes
|
||||
# ./electron-dev.sh status # Check if Electron is running and CDP is reachable
|
||||
# ./electron-dev.sh restart # Stop then start
|
||||
#
|
||||
# Environment variables:
|
||||
# CDP_PORT — Chrome DevTools Protocol port (default: 9222)
|
||||
# ELECTRON_LOG — Log file path (default: /tmp/electron-dev.log)
|
||||
# ELECTRON_WAIT_S — Max seconds to wait for Electron process (default: 60)
|
||||
# RENDERER_WAIT_S — Max seconds to wait for renderer/SPA (default: 60)
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
CDP_PORT="${CDP_PORT:-9222}"
|
||||
ELECTRON_LOG="${ELECTRON_LOG:-/tmp/electron-dev.log}"
|
||||
ELECTRON_WAIT_S="${ELECTRON_WAIT_S:-60}"
|
||||
RENDERER_WAIT_S="${RENDERER_WAIT_S:-60}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
|
||||
PIDFILE="/tmp/electron-dev-cdp-${CDP_PORT}.pid"
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
# Get the Electron binary path used by this project
|
||||
electron_bin_pattern() {
|
||||
echo "${PROJECT_ROOT}/apps/desktop/node_modules/.pnpm/electron@*/node_modules/electron/dist/Electron.app"
|
||||
}
|
||||
|
||||
# Find all PIDs related to the project's Electron dev session
|
||||
find_electron_pids() {
|
||||
local pids=""
|
||||
|
||||
# 1. Main Electron process (launched with --remote-debugging-port)
|
||||
local main_pids
|
||||
main_pids=$(pgrep -f "Electron\.app.*--remote-debugging-port=${CDP_PORT}" 2>/dev/null || true)
|
||||
[ -n "$main_pids" ] && pids="$pids $main_pids"
|
||||
|
||||
# 2. Electron Helper processes (gpu, renderer, utility) spawned from the project's electron binary
|
||||
local helper_pids
|
||||
helper_pids=$(pgrep -f "${PROJECT_ROOT}/apps/desktop/node_modules/.*Electron Helper" 2>/dev/null || true)
|
||||
[ -n "$helper_pids" ] && pids="$pids $helper_pids"
|
||||
|
||||
# 3. electron-vite dev server
|
||||
local vite_pids
|
||||
vite_pids=$(pgrep -f "electron-vite.*dev" 2>/dev/null || true)
|
||||
[ -n "$vite_pids" ] && pids="$pids $vite_pids"
|
||||
|
||||
# 4. PID from pidfile (fallback)
|
||||
if [ -f "$PIDFILE" ]; then
|
||||
local saved_pid
|
||||
saved_pid=$(cat "$PIDFILE")
|
||||
if kill -0 "$saved_pid" 2>/dev/null; then
|
||||
pids="$pids $saved_pid"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Deduplicate
|
||||
echo "$pids" | tr ' ' '\n' | sort -u | grep -v '^$' | tr '\n' ' ' || true
|
||||
}
|
||||
|
||||
do_stop() {
|
||||
echo "[electron-dev] Stopping Electron dev environment..."
|
||||
|
||||
local pids
|
||||
pids=$(find_electron_pids)
|
||||
|
||||
if [ -z "$pids" ]; then
|
||||
echo "[electron-dev] No Electron processes found."
|
||||
else
|
||||
echo "[electron-dev] Killing PIDs: $pids"
|
||||
for pid in $pids; do
|
||||
kill "$pid" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# Wait up to 5s for graceful exit, then force-kill survivors
|
||||
local waited=0
|
||||
while [ $waited -lt 5 ]; do
|
||||
local alive=""
|
||||
for pid in $pids; do
|
||||
kill -0 "$pid" 2>/dev/null && alive="$alive $pid"
|
||||
done
|
||||
[ -z "$alive" ] && break
|
||||
sleep 1
|
||||
waited=$((waited + 1))
|
||||
done
|
||||
|
||||
# Force-kill any remaining
|
||||
for pid in $pids; do
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
echo "[electron-dev] Force-killing PID $pid"
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Also close any agent-browser sessions connected to this port
|
||||
agent-browser --cdp "$CDP_PORT" close --all 2>/dev/null || true
|
||||
|
||||
rm -f "$PIDFILE"
|
||||
echo "[electron-dev] Stopped."
|
||||
}
|
||||
|
||||
do_status() {
|
||||
local pids
|
||||
pids=$(find_electron_pids)
|
||||
|
||||
if [ -z "$pids" ]; then
|
||||
echo "[electron-dev] Electron is NOT running."
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "[electron-dev] Electron is running (PIDs: $pids)"
|
||||
|
||||
# Check CDP connectivity
|
||||
if agent-browser --cdp "$CDP_PORT" get url >/dev/null 2>&1; then
|
||||
local url
|
||||
url=$(agent-browser --cdp "$CDP_PORT" get url 2>&1 | tail -1)
|
||||
echo "[electron-dev] CDP port ${CDP_PORT} is reachable. URL: $url"
|
||||
return 0
|
||||
else
|
||||
echo "[electron-dev] CDP port ${CDP_PORT} is NOT reachable (Electron may still be loading)."
|
||||
return 2
|
||||
fi
|
||||
}
|
||||
|
||||
wait_for_electron() {
|
||||
echo "[electron-dev] Waiting for Electron process (up to ${ELECTRON_WAIT_S}s)..."
|
||||
local elapsed=0
|
||||
local interval=3
|
||||
while [ $elapsed -lt "$ELECTRON_WAIT_S" ]; do
|
||||
if strings "$ELECTRON_LOG" 2>/dev/null | grep -q "starting electron"; then
|
||||
echo "[electron-dev] Electron process started."
|
||||
return 0
|
||||
fi
|
||||
sleep "$interval"
|
||||
elapsed=$((elapsed + interval))
|
||||
echo "[electron-dev] Still waiting... (${elapsed}/${ELECTRON_WAIT_S}s)"
|
||||
done
|
||||
echo "[electron-dev] ERROR: Electron did not start within ${ELECTRON_WAIT_S}s"
|
||||
echo "[electron-dev] Last 20 lines of log:"
|
||||
tail -20 "$ELECTRON_LOG" 2>/dev/null || true
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_renderer() {
|
||||
echo "[electron-dev] Waiting for renderer/SPA to load (up to ${RENDERER_WAIT_S}s)..."
|
||||
|
||||
# Initial delay — renderer needs time to bootstrap
|
||||
sleep 10
|
||||
|
||||
local elapsed=10
|
||||
local interval=5
|
||||
while [ $elapsed -lt "$RENDERER_WAIT_S" ]; do
|
||||
if agent-browser --cdp "$CDP_PORT" wait 2000 >/dev/null 2>&1; then
|
||||
# Check if interactive elements are present (SPA loaded)
|
||||
local snap
|
||||
snap=$(agent-browser --cdp "$CDP_PORT" snapshot -i 2>&1 || true)
|
||||
if echo "$snap" | grep -qE 'link |button '; then
|
||||
echo "[electron-dev] Renderer ready (interactive elements found)."
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
sleep "$interval"
|
||||
elapsed=$((elapsed + interval))
|
||||
echo "[electron-dev] SPA still loading... (${elapsed}/${RENDERER_WAIT_S}s)"
|
||||
done
|
||||
|
||||
echo "[electron-dev] WARNING: Timed out waiting for renderer, proceeding anyway."
|
||||
return 0
|
||||
}
|
||||
|
||||
do_start() {
|
||||
# If already running and healthy, skip
|
||||
local status_ok=0
|
||||
do_status >/dev/null 2>&1 || status_ok=$?
|
||||
if [ "$status_ok" -eq 0 ]; then
|
||||
echo "[electron-dev] Electron is already running and CDP is reachable. Skipping start."
|
||||
echo "[electron-dev] Use 'restart' to force a fresh session, or 'stop' to tear down."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Clean up any stale processes
|
||||
do_stop
|
||||
|
||||
# Start fresh
|
||||
echo "[electron-dev] Starting Electron dev server..."
|
||||
echo "[electron-dev] Project: $PROJECT_ROOT"
|
||||
echo "[electron-dev] CDP port: $CDP_PORT"
|
||||
echo "[electron-dev] Log: $ELECTRON_LOG"
|
||||
|
||||
: > "$ELECTRON_LOG" # Truncate log
|
||||
|
||||
(
|
||||
cd "$PROJECT_ROOT/apps/desktop" && \
|
||||
ELECTRON_ENABLE_LOGGING=1 npx electron-vite dev -- --remote-debugging-port="$CDP_PORT" \
|
||||
>> "$ELECTRON_LOG" 2>&1
|
||||
) &
|
||||
local bg_pid=$!
|
||||
echo "$bg_pid" > "$PIDFILE"
|
||||
echo "[electron-dev] Background PID: $bg_pid"
|
||||
|
||||
# Wait for Electron process to start
|
||||
if ! wait_for_electron; then
|
||||
echo "[electron-dev] Failed to start. Cleaning up..."
|
||||
do_stop
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Wait for renderer to be interactive
|
||||
if ! wait_for_renderer; then
|
||||
echo "[electron-dev] Renderer not ready, but Electron is running. You may need to wait more."
|
||||
fi
|
||||
|
||||
echo "[electron-dev] Ready! Use: agent-browser --cdp $CDP_PORT snapshot -i"
|
||||
}
|
||||
|
||||
do_restart() {
|
||||
do_stop
|
||||
sleep 2
|
||||
do_start
|
||||
}
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────
|
||||
|
||||
case "${1:-help}" in
|
||||
start) do_start ;;
|
||||
stop) do_stop ;;
|
||||
status) do_status ;;
|
||||
restart) do_restart ;;
|
||||
*)
|
||||
echo "Usage: $0 {start|stop|status|restart}"
|
||||
echo ""
|
||||
echo " start — Start Electron dev with CDP (idempotent, skips if already running)"
|
||||
echo " stop — Kill all Electron dev processes (main + helpers + vite)"
|
||||
echo " status — Check if Electron is running and CDP is reachable"
|
||||
echo " restart — Stop then start"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -162,15 +162,11 @@ describe('ModuleName', () => {
|
||||
### 5. Create Pull Request
|
||||
|
||||
- Create a new branch: `automatic/add-tests-[module-name]-[date]`
|
||||
|
||||
- Commit changes with message format:
|
||||
|
||||
```
|
||||
✅ test: add unit tests for [module-name]
|
||||
```
|
||||
|
||||
- Push the branch
|
||||
|
||||
- Create a PR with:
|
||||
|
||||
- Title: `✅ test: add unit tests for [module-name]`
|
||||
@@ -202,7 +198,6 @@ describe('ModuleName', () => {
|
||||
- Test approach: [brief description]
|
||||
|
||||
---
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
```
|
||||
|
||||
|
||||
@@ -77,24 +77,20 @@ Create `e2e/src/features/{module-name}/README.md` with:
|
||||
# {Module} 模块 E2E 测试覆盖
|
||||
|
||||
## 模块概述
|
||||
|
||||
**路由**: `/module`, `/module/[id]`
|
||||
|
||||
## 功能清单与测试覆盖
|
||||
|
||||
### 1. 功能分组名称
|
||||
|
||||
| 功能点 | 描述 | 优先级 | 状态 | 测试文件 |
|
||||
| ------ | ---- | ------ | ---- | ------------- |
|
||||
| 功能点 | 描述 | 优先级 | 状态 | 测试文件 |
|
||||
| ------ | ---- | ------ | ---- | -------- |
|
||||
| 功能A | xxx | P0 | ✅ | `xxx.feature` |
|
||||
| 功能B | xxx | P1 | ⏳ | |
|
||||
| 功能B | xxx | P1 | ⏳ | |
|
||||
|
||||
## 测试文件结构
|
||||
|
||||
## 测试执行
|
||||
|
||||
## 已知问题
|
||||
|
||||
## 更新记录
|
||||
```
|
||||
|
||||
@@ -232,7 +228,7 @@ const testId = pickle.tags.find(
|
||||
tag.name.startsWith('@COMMUNITY-') ||
|
||||
tag.name.startsWith('@AGENT-') ||
|
||||
tag.name.startsWith('@HOME-') ||
|
||||
tag.name.startsWith('@PAGE-') || // Add new prefix
|
||||
tag.name.startsWith('@PAGE-') || // Add new prefix
|
||||
tag.name.startsWith('@ROUTES-'),
|
||||
);
|
||||
```
|
||||
@@ -304,15 +300,11 @@ HEADLESS=true BASE_URL=http://localhost:3006 \
|
||||
### 10. Create Pull Request
|
||||
|
||||
- Branch name: `test/e2e-{module-name}`
|
||||
|
||||
- Commit message format:
|
||||
|
||||
```
|
||||
✅ test: add E2E tests for {module-name}
|
||||
```
|
||||
|
||||
- PR title: `✅ test: add E2E tests for {module-name}`
|
||||
|
||||
- PR body template:
|
||||
|
||||
````markdown
|
||||
|
||||
@@ -36,7 +36,6 @@ If you detect any leaked secrets, respond IMMEDIATELY with:
|
||||
⚠️ **Security Warning**: Your comment appears to contain sensitive information (API keys, secrets, or credentials).
|
||||
|
||||
**Please delete your comment immediately** to protect your account security, then:
|
||||
|
||||
1. Rotate/regenerate any exposed credentials
|
||||
2. Re-post your question with secrets redacted (e.g., `AUTH_SECRET=***`)
|
||||
|
||||
@@ -74,17 +73,12 @@ Look for the "Troubleshooting" or "FAQ" section in the migration docs and match
|
||||
## Response Guidelines
|
||||
|
||||
1. **Be helpful and friendly** - Users are often frustrated when migration doesn't work
|
||||
|
||||
2. **Be specific** - Provide exact commands or configuration examples
|
||||
|
||||
3. **Reference documentation** - Point users to relevant docs sections
|
||||
|
||||
4. **Ask for logs** - If the issue is unclear, ask for Docker logs:
|
||||
|
||||
```bash
|
||||
docker logs <container_name> 2>&1 | tail -100
|
||||
```
|
||||
|
||||
5. **One issue at a time** - Focus on solving one problem before moving to the next
|
||||
|
||||
## Response Format
|
||||
@@ -96,7 +90,6 @@ Use this format for your responses:
|
||||
|
||||
[If missing information]
|
||||
To help you effectively, please provide:
|
||||
|
||||
- [List missing items]
|
||||
|
||||
[If you can help]
|
||||
@@ -109,7 +102,6 @@ Based on your description, here's what I suggest:
|
||||
|
||||
[If the issue is complex or unknown]
|
||||
This issue needs further investigation. I've notified the team. In the meantime, please:
|
||||
|
||||
1. [Any immediate steps they can try]
|
||||
2. Share your Docker logs if you haven't already
|
||||
```
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
|
||||
## Quick Reference by Name
|
||||
|
||||
- **@arvinxx**: Last resort only, mention for priority:high issues, tool calling, mcp, database
|
||||
- **@arvinxx**: Last resort only, mention for priority:high issues, tool calling , mcp
|
||||
- **@canisminor1990**: Design, UI components, editor, markdown rendering
|
||||
- **@tjx666**: Image/video generation, vision, cloud version, documentation, TTS, auth, login/register, database
|
||||
- **@ONLY-yours**: Performance, streaming, settings, general bugs, web platform, marketplace, agent builder, schedule task
|
||||
- **@Innei**: Knowledge base, files (KB-related), group chat, Electron, desktop client, build system
|
||||
- **@nekomeowww**: Memory, backend, deployment, DevOps, database
|
||||
- **@tjx666**: Image/video generation, vision, cloud version, documentation, TTS, auth, login/register
|
||||
- **@ONLY-yours**: Performance, streaming, settings, general bugs, web platform, marketplace
|
||||
- **@Innei**: Knowledge base, files (KB-related), group chat
|
||||
- **@nekomeowww**: Memory, backend, deployment, DevOps
|
||||
- **@sudongyuer**: Mobile app (React Native)
|
||||
- **@sxjeru**: Model providers and configuration
|
||||
- **@rdmclin2**: Team workspace, IM and bot integration
|
||||
- **@rdmclin2**: Team workspace
|
||||
- **@tcmonster**: Subscription, refund, recharge, business cooperation
|
||||
|
||||
Quick reference for assigning issues based on labels.
|
||||
@@ -28,7 +28,7 @@ Quick reference for assigning issues based on labels.
|
||||
| Label | Owner | Notes |
|
||||
| ------------------ | ----------- | -------------------------------------- |
|
||||
| `platform:mobile` | @sudongyuer | React Native mobile app |
|
||||
| `platform:desktop` | @Innei | Electron desktop client, build system |
|
||||
| `platform:desktop` | @ONLY-yours | Electron desktop client (general) |
|
||||
| `platform:web` | @ONLY-yours | Web platform (unless specific feature) |
|
||||
|
||||
### Feature Labels (feature:\*)
|
||||
@@ -60,9 +60,6 @@ Quick reference for assigning issues based on labels.
|
||||
| `feature:group-chat` | @arvinxx | Group chat functionality |
|
||||
| `feature:memory` | @nekomeowww | Memory feature |
|
||||
| `feature:team-workspace` | @rdmclin2 | Team workspace application |
|
||||
| `feature:im-integration` | @rdmclin2 | IM and bot integration (Slack, Discord, etc.) |
|
||||
| `feature:agent-builder` | @ONLY-yours | Agent builder |
|
||||
| `feature:schedule-task` | @ONLY-yours | Schedule task |
|
||||
| `feature:subscription` | @tcmonster | Subscription and billing |
|
||||
| `feature:refund` | @tcmonster | Refund requests |
|
||||
| `feature:recharge` | @tcmonster | Recharge and payment |
|
||||
@@ -128,18 +125,18 @@ Quick reference for assigning issues based on labels.
|
||||
|
||||
**Single owner:**
|
||||
|
||||
```plaintext
|
||||
```
|
||||
@username - This is a [feature/component] issue. Please take a look.
|
||||
```
|
||||
|
||||
**Multiple owners:**
|
||||
|
||||
```plaintext
|
||||
```
|
||||
@primary @secondary - This involves [features]. Please coordinate.
|
||||
```
|
||||
|
||||
**High priority:**
|
||||
|
||||
```plaintext
|
||||
```
|
||||
@owner @arvinxx - High priority [feature] issue.
|
||||
```
|
||||
|
||||
@@ -72,15 +72,11 @@ Module granularity examples:
|
||||
### 5. Create Pull Request
|
||||
|
||||
- Create a new branch: `automatic/translate-comments-[module-name]-[date]`
|
||||
|
||||
- Commit changes with message format:
|
||||
|
||||
```
|
||||
🌐 chore: translate non-English comments to English in [module-name]
|
||||
```
|
||||
|
||||
- Push the branch
|
||||
|
||||
- Create a PR with:
|
||||
|
||||
- Title: `🌐 chore: translate non-English comments to English in [module-name]`
|
||||
@@ -104,7 +100,6 @@ Module granularity examples:
|
||||
`[module-path]`
|
||||
|
||||
---
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
```
|
||||
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
AmAzing129
|
||||
arvinxx
|
||||
canisminor1990
|
||||
ilimei
|
||||
Innei
|
||||
lobehubbot
|
||||
nekomeowww
|
||||
ONLY-yours
|
||||
rdmclin2
|
||||
rivertwilight
|
||||
sudongyuer
|
||||
tcmonster
|
||||
tjx666
|
||||
@@ -18,16 +18,6 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Check if author is a team member
|
||||
id: check-team
|
||||
run: |
|
||||
ISSUE_AUTHOR="${{ github.event.issue.user.login }}"
|
||||
if grep -iq "^${ISSUE_AUTHOR}$" .github/maintainers.txt; then
|
||||
echo "is_team=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_team=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Copy triage prompts
|
||||
run: |
|
||||
mkdir -p /tmp/claude-prompts
|
||||
@@ -72,7 +62,7 @@ jobs:
|
||||
**IMPORTANT**:
|
||||
- Follow ALL steps in the issue-triage.md guide
|
||||
- Apply labels according to the guide's rules
|
||||
- ${{ steps.check-team.outputs.is_team == 'true' && 'The issue author is a team member. Do NOT post any @mention comment.' || 'Post a mention comment to the appropriate team member(s) based on team-assignment.md' }}
|
||||
- Post a mention comment to the appropriate team member(s) based on team-assignment.md
|
||||
- Replace [ISSUE_NUMBER] with: ${{ github.event.issue.number }}
|
||||
|
||||
**Start the triage process now.**
|
||||
|
||||
@@ -21,18 +21,7 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Check if author is a team member
|
||||
id: check-team
|
||||
run: |
|
||||
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
|
||||
if grep -iq "^${PR_AUTHOR}$" .github/maintainers.txt; then
|
||||
echo "is_team=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_team=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Copy prompts
|
||||
if: steps.check-team.outputs.is_team == 'false'
|
||||
run: |
|
||||
mkdir -p /tmp/claude-prompts
|
||||
cp .claude/prompts/pr-assign.md /tmp/claude-prompts/
|
||||
@@ -40,7 +29,6 @@ jobs:
|
||||
cp .claude/prompts/security-rules.md /tmp/claude-prompts/
|
||||
|
||||
- name: Run Claude Code for PR Reviewer Assignment
|
||||
if: steps.check-team.outputs.is_team == 'false'
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
github_token: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
@@ -28,21 +28,9 @@ jobs:
|
||||
✅ @{{ author }}
|
||||
|
||||
This issue is closed, If you have any questions, you can comment and reply.
|
||||
- name: Checkout repository
|
||||
if: github.event_name == 'pull_request_target' && github.event.pull_request.merged == true
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check if PR author is maintainer
|
||||
if: github.event.pull_request.merged == true
|
||||
id: maintainer-check
|
||||
run: |
|
||||
if [ -f .github/maintainers.txt ] && grep -qx "${{ github.event.pull_request.user.login }}" .github/maintainers.txt; then
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Auto Comment on Pull Request Merged
|
||||
uses: actions-cool/pr-welcome@main
|
||||
if: github.event.pull_request.merged == true && steps.maintainer-check.outputs.skip != 'true'
|
||||
if: github.event.pull_request.merged == true
|
||||
with:
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
comment: |
|
||||
|
||||
@@ -6,10 +6,10 @@ on:
|
||||
channel:
|
||||
description: 'Release channel for desktop build (affects version suffix and workflow:set-desktop-version)'
|
||||
required: true
|
||||
default: canary
|
||||
default: nightly
|
||||
type: choice
|
||||
options:
|
||||
- canary
|
||||
- nightly
|
||||
- beta
|
||||
- stable
|
||||
build_macos:
|
||||
@@ -118,8 +118,8 @@ jobs:
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ inputs.channel == 'stable' && secrets.UMAMI_STABLE_DESKTOP_PROJECT_ID || secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'stable' && secrets.UMAMI_STABLE_DESKTOP_BASE_URL || secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_PROJECT_ID || secrets.UMAMI_NIGHTLY_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_BASE_URL || secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL }}
|
||||
CSC_FOR_PULL_REQUEST: true
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
@@ -184,8 +184,8 @@ jobs:
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ inputs.channel == 'stable' && secrets.UMAMI_STABLE_DESKTOP_PROJECT_ID || secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'stable' && secrets.UMAMI_STABLE_DESKTOP_BASE_URL || secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_PROJECT_ID || secrets.UMAMI_NIGHTLY_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_BASE_URL || secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL }}
|
||||
TEMP: C:\temp
|
||||
TMP: C:\temp
|
||||
|
||||
@@ -228,8 +228,8 @@ jobs:
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ inputs.channel == 'stable' && secrets.UMAMI_STABLE_DESKTOP_PROJECT_ID || secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'stable' && secrets.UMAMI_STABLE_DESKTOP_BASE_URL || secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_PROJECT_ID || secrets.UMAMI_NIGHTLY_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_BASE_URL || secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL }}
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
|
||||
@@ -7,7 +7,7 @@ name: Release Desktop Beta
|
||||
# 如: v2.0.0-beta.1, v2.0.0-alpha.1, v2.0.0-rc.1
|
||||
#
|
||||
# 注意: Stable 版本 (如 v2.0.0) 由 release-desktop-stable.yml 处理
|
||||
# 注意: Nightly 版本已停用,不再参与 Desktop 发布流程
|
||||
# 注意: Nightly 版本 (如 v2.1.0-nightly.xxx) 由 release-desktop-nightly.yml 处理
|
||||
# ============================================
|
||||
|
||||
on:
|
||||
@@ -41,10 +41,10 @@ jobs:
|
||||
version="${version#v}"
|
||||
echo "version=${version}" >> $GITHUB_OUTPUT
|
||||
|
||||
# Beta 版本包含 beta/alpha/rc;nightly 标签已停用
|
||||
# Beta 版本包含 beta/alpha/rc (nightly 由 release-desktop-nightly.yml 处理)
|
||||
if [[ "$version" == *"nightly"* ]]; then
|
||||
echo "is_beta=false" >> $GITHUB_OUTPUT
|
||||
echo "⏭️ Skipping: $version is a disabled nightly release tag"
|
||||
echo "⏭️ Skipping: $version is a nightly release (handled by release-desktop-nightly.yml)"
|
||||
elif [[ "$version" == *"beta"* ]] || [[ "$version" == *"alpha"* ]] || [[ "$version" == *"rc"* ]]; then
|
||||
echo "is_beta=true" >> $GITHUB_OUTPUT
|
||||
echo "✅ Beta release detected: $version"
|
||||
|
||||
@@ -45,7 +45,6 @@ jobs:
|
||||
name: Calculate Canary Version
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_notes: ${{ steps.release-notes.outputs.release_notes }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
should_build: ${{ steps.check.outputs.should_build }}
|
||||
@@ -122,66 +121,6 @@ jobs:
|
||||
echo "✅ Canary version: ${version}"
|
||||
echo "🏷️ Tag: ${tag}"
|
||||
|
||||
- name: Generate canary release notes
|
||||
if: steps.check.outputs.should_build == 'true'
|
||||
id: release-notes
|
||||
env:
|
||||
TAG: ${{ steps.version.outputs.tag }}
|
||||
run: |
|
||||
previous_canary=$(git tag --sort=-creatordate | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+-canary\.[0-9]+$' | head -n 1)
|
||||
latest_stable=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1)
|
||||
|
||||
if [ -n "$previous_canary" ]; then
|
||||
compare_from="$previous_canary"
|
||||
compare_range="${previous_canary}..HEAD"
|
||||
elif [ -n "$latest_stable" ]; then
|
||||
compare_from="$latest_stable"
|
||||
compare_range="${latest_stable}..HEAD"
|
||||
else
|
||||
compare_from="initial commit"
|
||||
compare_range="HEAD"
|
||||
fi
|
||||
|
||||
commit_count=$(git rev-list --count "$compare_range")
|
||||
commits=$(git log --no-merges --pretty='- `%h` %s (%an)' "$compare_range")
|
||||
|
||||
if [ -z "$commits" ]; then
|
||||
commits='- No new commits recorded.'
|
||||
fi
|
||||
|
||||
{
|
||||
echo "release_notes<<EOF"
|
||||
echo "## 🐤 Canary Build — ${TAG}"
|
||||
echo
|
||||
echo "> Automated canary build from \`canary\` branch."
|
||||
echo
|
||||
echo "### Commit Information"
|
||||
echo
|
||||
echo "- Based on changes since \`${compare_from}\`"
|
||||
echo "- Commit count: ${commit_count}"
|
||||
echo
|
||||
printf '%s\n' "$commits"
|
||||
echo
|
||||
echo "### ⚠️ Important Notes"
|
||||
echo
|
||||
echo "- **This is an automated canary build and is NOT intended for production use.**"
|
||||
echo "- Canary builds are triggered by \`build\`/\`fix\`/\`style\` commits on the \`canary\` branch."
|
||||
echo "- May contain **unstable or incomplete changes**. **Use at your own risk.**"
|
||||
echo "- It is strongly recommended to **back up your data** before using a canary build."
|
||||
echo
|
||||
echo "### 📦 Installation"
|
||||
echo
|
||||
echo "Download the appropriate installer for your platform from the assets below."
|
||||
echo
|
||||
echo "| Platform | File |"
|
||||
echo "|----------|------|"
|
||||
echo "| macOS (Apple Silicon) | \`.dmg\` (arm64) |"
|
||||
echo "| macOS (Intel) | \`.dmg\` (x64) |"
|
||||
echo "| Windows | \`.exe\` |"
|
||||
echo "| Linux | \`.AppImage\` / \`.deb\` |"
|
||||
echo "EOF"
|
||||
} >> $GITHUB_OUTPUT
|
||||
|
||||
# ============================================
|
||||
# 代码质量检查
|
||||
# ============================================
|
||||
@@ -243,7 +182,6 @@ jobs:
|
||||
env:
|
||||
UPDATE_CHANNEL: canary
|
||||
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
|
||||
RELEASE_NOTES: ${{ needs.calculate-version.outputs.release_notes }}
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
@@ -263,7 +201,6 @@ jobs:
|
||||
env:
|
||||
UPDATE_CHANNEL: canary
|
||||
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
|
||||
RELEASE_NOTES: ${{ needs.calculate-version.outputs.release_notes }}
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
@@ -279,7 +216,6 @@ jobs:
|
||||
env:
|
||||
UPDATE_CHANNEL: canary
|
||||
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
|
||||
RELEASE_NOTES: ${{ needs.calculate-version.outputs.release_notes }}
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
@@ -363,7 +299,28 @@ jobs:
|
||||
tag_name: ${{ needs.calculate-version.outputs.tag }}
|
||||
name: 'Desktop Canary ${{ needs.calculate-version.outputs.tag }}'
|
||||
prerelease: true
|
||||
body: ${{ needs.calculate-version.outputs.release_notes }}
|
||||
body: |
|
||||
## 🐤 Canary Build — ${{ needs.calculate-version.outputs.tag }}
|
||||
|
||||
> Automated canary build from `canary` branch.
|
||||
|
||||
### ⚠️ Important Notes
|
||||
|
||||
- **This is an automated canary build and is NOT intended for production use.**
|
||||
- Canary builds are triggered by `build`/`fix`/`style` commits on the `canary` branch.
|
||||
- May contain **unstable or incomplete changes**. **Use at your own risk.**
|
||||
- It is strongly recommended to **back up your data** before using a canary build.
|
||||
|
||||
### 📦 Installation
|
||||
|
||||
Download the appropriate installer for your platform from the assets below.
|
||||
|
||||
| Platform | File |
|
||||
|----------|------|
|
||||
| macOS (Apple Silicon) | `.dmg` (arm64) |
|
||||
| macOS (Intel) | `.dmg` (x64) |
|
||||
| Windows | `.exe` |
|
||||
| Linux | `.AppImage` / `.deb` |
|
||||
files: |
|
||||
release/latest*
|
||||
release/*.dmg*
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
name: Release Desktop Nightly
|
||||
|
||||
# ============================================
|
||||
# Nightly 自动发版工作流
|
||||
# ============================================
|
||||
# 触发条件:
|
||||
# 1. 定时: 每天 UTC+8 14:00 (UTC 06:00)
|
||||
# 2. 手动触发 (workflow_dispatch)
|
||||
#
|
||||
# 版本策略:
|
||||
# 基于最新 tag 的 minor+1, 格式: X.(Y+1).0-nightly.YYYYMMDDHHMM
|
||||
# 例: 当前 tag v2.0.12 → v2.1.0-nightly.202502091400
|
||||
# 使用精确到分钟的时间戳避免同一天多次触发时 tag 冲突
|
||||
# ============================================
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force:
|
||||
description: 'Force build (skip diff check)'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: read-all
|
||||
|
||||
env:
|
||||
NODE_VERSION: '24.11.1'
|
||||
|
||||
jobs:
|
||||
# ============================================
|
||||
# 计算 Nightly 版本号
|
||||
# ============================================
|
||||
calculate-version:
|
||||
name: Calculate Nightly Version
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
has_changes: ${{ steps.changes.outputs.has_changes }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check for code changes since last nightly
|
||||
id: changes
|
||||
run: |
|
||||
# 手动触发 + force 时跳过 diff 检查
|
||||
if [ "${{ inputs.force }}" == "true" ]; then
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
echo "🔧 Force build requested, skipping diff check"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 查找上一个 nightly tag
|
||||
last_nightly=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+-nightly\.' | head -n 1)
|
||||
|
||||
if [ -z "$last_nightly" ]; then
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
echo "📦 No previous nightly tag found, proceeding with first nightly build"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "📌 Last nightly tag: $last_nightly"
|
||||
|
||||
# 对比指定目录是否有变更
|
||||
changes=$(git diff --name-only "$last_nightly"..HEAD -- package.json src/ packages/ apps/desktop/)
|
||||
|
||||
if [ -z "$changes" ]; then
|
||||
echo "has_changes=false" >> $GITHUB_OUTPUT
|
||||
echo "⏭️ No code changes since $last_nightly, skipping nightly build"
|
||||
else
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
change_count=$(echo "$changes" | wc -l | tr -d ' ')
|
||||
echo "✅ ${change_count} file(s) changed since $last_nightly:"
|
||||
echo "$changes" | head -20
|
||||
[ "$change_count" -gt 20 ] && echo " ... and $((change_count - 20)) more"
|
||||
fi
|
||||
|
||||
- name: Calculate nightly version
|
||||
if: steps.changes.outputs.has_changes == 'true'
|
||||
id: version
|
||||
run: |
|
||||
# 获取最新的 tag (排除 nightly tag)
|
||||
latest_tag=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1)
|
||||
|
||||
if [ -z "$latest_tag" ]; then
|
||||
echo "❌ No stable tag found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "📌 Latest stable tag: $latest_tag"
|
||||
|
||||
# 去掉 v 前缀
|
||||
base_version="${latest_tag#v}"
|
||||
|
||||
# 解析 major.minor.patch
|
||||
IFS='.' read -r major minor patch <<< "$base_version"
|
||||
|
||||
# minor + 1, patch 归零
|
||||
new_minor=$((minor + 1))
|
||||
timestamp=$(date -u +"%Y%m%d%H%M")
|
||||
|
||||
version="${major}.${new_minor}.0-nightly.${timestamp}"
|
||||
tag="v${version}"
|
||||
|
||||
echo "version=${version}" >> $GITHUB_OUTPUT
|
||||
echo "tag=${tag}" >> $GITHUB_OUTPUT
|
||||
echo "✅ Nightly version: ${version}"
|
||||
echo "🏷️ Tag: ${tag}"
|
||||
|
||||
# ============================================
|
||||
# 代码质量检查
|
||||
# ============================================
|
||||
test:
|
||||
name: Code quality check
|
||||
needs: [calculate-version]
|
||||
if: needs.calculate-version.outputs.has_changes == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Install deps
|
||||
run: pnpm install
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
|
||||
# ============================================
|
||||
# 多平台构建
|
||||
# ============================================
|
||||
build:
|
||||
needs: [calculate-version, test]
|
||||
if: needs.calculate-version.outputs.has_changes == 'true'
|
||||
name: Build Desktop App
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-15, macos-15-intel, windows-2025, ubuntu-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup build environment
|
||||
uses: ./.github/actions/desktop-build-setup
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Set package version
|
||||
run: npm run workflow:set-desktop-version ${{ needs.calculate-version.outputs.version }} nightly
|
||||
|
||||
# macOS 构建前清理 (修复 hdiutil 问题 https://github.com/electron-userland/electron-builder/issues/8415)
|
||||
- name: Clean previous build artifacts (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
sudo rm -rf apps/desktop/release || true
|
||||
sudo rm -rf apps/desktop/dist || true
|
||||
sudo rm -rf /tmp/electron-builder* || true
|
||||
|
||||
# macOS 构建
|
||||
- name: Build artifact on macOS
|
||||
if: runner.os == 'macOS'
|
||||
run: npm run desktop:package:app
|
||||
env:
|
||||
UPDATE_CHANNEL: nightly
|
||||
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
CSC_FOR_PULL_REQUEST: true
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
|
||||
|
||||
# Windows 构建
|
||||
- name: Build artifact on Windows
|
||||
if: runner.os == 'Windows'
|
||||
run: npm run desktop:package:app
|
||||
env:
|
||||
UPDATE_CHANNEL: nightly
|
||||
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
|
||||
TEMP: C:\temp
|
||||
TMP: C:\temp
|
||||
|
||||
# Linux 构建
|
||||
- name: Build artifact on Linux
|
||||
if: runner.os == 'Linux'
|
||||
run: npm run desktop:package:app
|
||||
env:
|
||||
UPDATE_CHANNEL: nightly
|
||||
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
|
||||
APP_URL: http://localhost:3015
|
||||
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
|
||||
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: ./.github/actions/desktop-upload-artifacts
|
||||
with:
|
||||
artifact-name: release-${{ matrix.os }}
|
||||
retention-days: 3
|
||||
|
||||
# ============================================
|
||||
# 合并 macOS 多架构 latest-mac.yml 文件
|
||||
# ============================================
|
||||
merge-mac-files:
|
||||
needs: [build]
|
||||
name: Merge macOS Release Files
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
path: release
|
||||
pattern: release-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: List downloaded artifacts
|
||||
run: ls -R release
|
||||
|
||||
- name: Install yaml only for merge step
|
||||
run: |
|
||||
cd scripts/electronWorkflow
|
||||
if [ ! -f package.json ]; then
|
||||
echo '{"name":"merge-mac-release","private":true}' > package.json
|
||||
fi
|
||||
bun add --no-save yaml@2.8.1
|
||||
|
||||
- name: Merge latest-mac.yml files
|
||||
run: bun run scripts/electronWorkflow/mergeMacReleaseFiles.js
|
||||
|
||||
- name: Upload artifacts with merged macOS files
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: merged-release
|
||||
path: release/
|
||||
retention-days: 1
|
||||
|
||||
# ============================================
|
||||
# 创建 Nightly Release
|
||||
# ============================================
|
||||
publish-release:
|
||||
needs: [merge-mac-files, calculate-version]
|
||||
name: Publish Nightly Release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download merged artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: merged-release
|
||||
path: release
|
||||
|
||||
- name: List final artifacts
|
||||
run: ls -R release
|
||||
|
||||
- name: Create Nightly Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ needs.calculate-version.outputs.tag }}
|
||||
name: 'Desktop Nightly ${{ needs.calculate-version.outputs.tag }}'
|
||||
prerelease: true
|
||||
body: |
|
||||
## 🌙 Nightly Build — ${{ needs.calculate-version.outputs.tag }}
|
||||
|
||||
> Automated nightly build from `main` branch.
|
||||
|
||||
### ⚠️ Important Notes
|
||||
|
||||
- **This is an automated nightly build and is NOT intended for production use.**
|
||||
- Nightly builds are generated from the latest `main` branch and may contain **unstable, untested, or incomplete features**.
|
||||
- **No guarantees** are made regarding stability, data integrity, or backward compatibility.
|
||||
- Bugs, crashes, and breaking changes are expected. **Use at your own risk.**
|
||||
- **Do NOT report bugs** from nightly builds unless you can reproduce them on the latest beta or stable release.
|
||||
- Nightly builds may have **different update channels** — they will not auto-update to/from stable or beta versions.
|
||||
- It is strongly recommended to **back up your data** before using a nightly build.
|
||||
|
||||
### 📦 Installation
|
||||
|
||||
Download the appropriate installer for your platform from the assets below.
|
||||
|
||||
| Platform | File |
|
||||
|----------|------|
|
||||
| macOS (Apple Silicon) | `.dmg` (arm64) |
|
||||
| macOS (Intel) | `.dmg` (x64) |
|
||||
| Windows | `.exe` |
|
||||
| Linux | `.AppImage` / `.deb` |
|
||||
files: |
|
||||
release/latest*
|
||||
release/*.dmg*
|
||||
release/*.zip*
|
||||
release/*.exe*
|
||||
release/*.AppImage
|
||||
release/*.deb*
|
||||
release/*.snap*
|
||||
release/*.rpm*
|
||||
release/*.tar.gz*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# ============================================
|
||||
# 发布到 S3 更新服务器
|
||||
# ============================================
|
||||
publish-s3:
|
||||
needs: [merge-mac-files, calculate-version]
|
||||
name: Publish to S3
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: ./.github/actions/desktop-publish-s3
|
||||
with:
|
||||
channel: nightly
|
||||
version: ${{ needs.calculate-version.outputs.version }}
|
||||
aws-access-key-id: ${{ secrets.UPDATE_AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.UPDATE_AWS_SECRET_ACCESS_KEY }}
|
||||
s3-bucket: ${{ secrets.UPDATE_S3_BUCKET }}
|
||||
s3-region: ${{ secrets.UPDATE_S3_REGION }}
|
||||
s3-endpoint: ${{ secrets.UPDATE_S3_ENDPOINT }}
|
||||
|
||||
# ============================================
|
||||
# 清理旧的 Nightly Releases (保留最近 7 个)
|
||||
# ============================================
|
||||
cleanup-old-nightlies:
|
||||
needs: [publish-release, publish-s3]
|
||||
name: Cleanup Old Nightly Releases
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Delete old nightly GitHub releases
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { data: releases } = await github.rest.repos.listReleases({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const nightlyReleases = releases
|
||||
.filter(r => r.tag_name.includes('-nightly.'))
|
||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
|
||||
const toDelete = nightlyReleases.slice(7);
|
||||
|
||||
for (const release of toDelete) {
|
||||
console.log(`🗑️ Deleting old nightly release: ${release.tag_name}`);
|
||||
|
||||
// Delete the release
|
||||
await github.rest.repos.deleteRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: release.id,
|
||||
});
|
||||
|
||||
// Delete the tag
|
||||
try {
|
||||
await github.rest.git.deleteRef({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
ref: `tags/${release.tag_name}`,
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(`⚠️ Could not delete tag ${release.tag_name}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ Cleanup complete. Kept ${Math.min(nightlyReleases.length, 7)} nightly releases, deleted ${toDelete.length}.`);
|
||||
|
||||
- name: Cleanup old S3 versions
|
||||
uses: ./.github/actions/desktop-cleanup-s3
|
||||
with:
|
||||
channel: nightly
|
||||
keep-count: '15'
|
||||
aws-access-key-id: ${{ secrets.UPDATE_AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.UPDATE_AWS_SECRET_ACCESS_KEY }}
|
||||
s3-bucket: ${{ secrets.UPDATE_S3_BUCKET }}
|
||||
s3-region: ${{ secrets.UPDATE_S3_REGION }}
|
||||
s3-endpoint: ${{ secrets.UPDATE_S3_ENDPOINT }}
|
||||
@@ -1,89 +0,0 @@
|
||||
name: Release ModelBank
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- canary
|
||||
paths:
|
||||
- packages/model-bank/**
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ModelBank
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build package
|
||||
run: pnpm --filter model-bank build
|
||||
|
||||
publish:
|
||||
name: Publish ModelBank
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.11.1
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Bump patch version
|
||||
id: version
|
||||
run: |
|
||||
npm version patch --no-git-tag-version --prefix packages/model-bank
|
||||
echo "version=$(node -p 'require(\"./packages/model-bank/package.json\").version')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build package
|
||||
run: pnpm --filter model-bank build
|
||||
|
||||
- name: Publish to npm
|
||||
run: npm publish --provenance
|
||||
working-directory: packages/model-bank
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Commit version bump
|
||||
env:
|
||||
MODEL_BANK_VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
git config user.name "lobehubbot"
|
||||
git config user.email "i@lobehub.com"
|
||||
git add packages/model-bank/package.json
|
||||
git commit -m "🔖 chore(model-bank): release v${MODEL_BANK_VERSION}"
|
||||
git push
|
||||
+7
-7
@@ -1,8 +1,8 @@
|
||||
# LobeHub - Contributing Guide 🌟
|
||||
# Lobe Chat - Contributing Guide 🌟
|
||||
|
||||
We're thrilled that you want to contribute to LobeHub, the future of communication! 😄
|
||||
We're thrilled that you want to contribute to Lobe Chat, the future of communication! 😄
|
||||
|
||||
LobeHub is an open-source project, and we welcome your collaboration. Before you jump in, let's make sure you're all set to contribute effectively and have loads of fun along the way!
|
||||
Lobe Chat is an open-source project, and we welcome your collaboration. Before you jump in, let's make sure you're all set to contribute effectively and have loads of fun along the way!
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -69,11 +69,11 @@ git fetch upstream
|
||||
git merge upstream/main
|
||||
```
|
||||
|
||||
This ensures you're working on the most current version of LobeHub. Stay fresh! 💨
|
||||
This ensures you're working on the most current version of Lobe Chat. Stay fresh! 💨
|
||||
|
||||
## Open a Pull Request
|
||||
|
||||
🚀 Time to share your contribution! Head over to the original LobeHub repository and open a Pull Request (PR). Our maintainers will review your work.
|
||||
🚀 Time to share your contribution! Head over to the original Lobe Chat repository and open a Pull Request (PR). Our maintainers will review your work.
|
||||
|
||||
## Review and Collaboration
|
||||
|
||||
@@ -81,8 +81,8 @@ This ensures you're working on the most current version of LobeHub. Stay fresh!
|
||||
|
||||
## Celebrate 🎉
|
||||
|
||||
🎈 Congratulations! Your contribution is now part of LobeHub. 🥳
|
||||
🎈 Congratulations! Your contribution is now part of Lobe Chat. 🥳
|
||||
|
||||
Thank you for making LobeHub even more magical. We can't wait to see what you create! 🌠
|
||||
Thank you for making Lobe Chat even more magical. We can't wait to see what you create! 🌠
|
||||
|
||||
Happy Coding! 🚀🦄
|
||||
|
||||
-81
@@ -1,81 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
We only provide security fixes for the **latest 2.x release**. Older versions (including all 1.x releases) are end-of-life and will not receive patches.
|
||||
|
||||
| Version | Supported |
|
||||
| ------------ | --------- |
|
||||
| 2.x (latest) | ✅ |
|
||||
| 1.x | ❌ |
|
||||
| 0.x | ❌ |
|
||||
|
||||
If you are running a 1.x deployment, we strongly recommend upgrading to the latest 2.x release.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report security vulnerabilities through the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/lobehub/lobehub/security/advisories/new) tab.
|
||||
|
||||
**Please do not report security vulnerabilities through public GitHub issues.**
|
||||
|
||||
### Response Timeline
|
||||
|
||||
- **Acknowledgement**: We aim to respond to all reports within **7 days**.
|
||||
- **Fix**: Confirmed vulnerabilities will be addressed within **30 days**.
|
||||
- **Urgent issues**: If you believe the vulnerability is critical and actively exploitable, you can reach out directly on Discord (`arvinxu`) for faster coordination.
|
||||
|
||||
### What to Include
|
||||
|
||||
A good vulnerability report should include:
|
||||
|
||||
- A clear description of the issue and its potential impact
|
||||
- The affected version (must be the latest 2.x release)
|
||||
- Step-by-step reproduction instructions or a working PoC
|
||||
- Any relevant logs, screenshots, or code references
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope
|
||||
|
||||
- Security issues affecting the **latest 2.x release** of LobeHub
|
||||
- Vulnerabilities in the **server-side deployment** (LobeHub Cloud or self-hosted server mode)
|
||||
- Issues that can be exploited **without requiring admin/owner access** to the deployment
|
||||
|
||||
### Out of Scope (Not a Vulnerability)
|
||||
|
||||
The following are considered **by design** or **out of scope** and will not be accepted as vulnerability reports:
|
||||
|
||||
#### 1. End-of-Life Versions
|
||||
|
||||
Any issue that only affects 1.x or earlier versions. This includes but is not limited to the `X-lobe-chat-auth` header mechanism, `webapi` route authentication, and other 1.x-specific architectures that have been completely removed in 2.x.
|
||||
|
||||
#### 2. File Proxy Public Access (`/f/:id`)
|
||||
|
||||
The file proxy endpoint `/f/:id` uses randomly generated, non-enumerable IDs as [capability URLs](https://www.w3.org/TR/capability-urls/). This is a deliberate design choice, similar to how S3 presigned URLs or Google Docs sharing links work. Knowing the URL grants access — this is by design, not an authorization bypass.
|
||||
|
||||
#### 3. User Enumeration on Login Flows
|
||||
|
||||
Endpoints such as `check-user` that indicate whether an account exists are part of the standard login UX. This is a common and intentional pattern used by most modern authentication flows.
|
||||
|
||||
#### 4. Self-Hosted Client-Side API Key Storage
|
||||
|
||||
In self-hosted client-side mode, users configure their own API keys which are stored in the browser's local storage. This is the expected behavior for client-side deployments where the user is both the operator and the consumer.
|
||||
|
||||
#### 5. Issues Requiring Admin or Owner Privileges
|
||||
|
||||
Actions that require administrative access to the deployment (e.g., environment variable configuration, server-side settings) are not considered security vulnerabilities, as the admin is already a trusted party.
|
||||
|
||||
#### 6. Theoretical Attacks Without Practical Impact
|
||||
|
||||
Reports based on theoretical attack scenarios without a working proof of concept against a realistic deployment, or issues that require unlikely preconditions (e.g., physical access to the server, pre-existing compromise of the host system).
|
||||
|
||||
## Disclosure Policy
|
||||
|
||||
- We follow [coordinated vulnerability disclosure](https://en.wikipedia.org/wiki/Coordinated_vulnerability_disclosure).
|
||||
- We will credit reporters in the security advisory unless they prefer to remain anonymous.
|
||||
- Please allow us reasonable time to address the issue before any public disclosure.
|
||||
|
||||
## Contact
|
||||
|
||||
- **Primary**: [GitHub Security Advisories](https://github.com/lobehub/lobehub/security/advisories/new)
|
||||
- **Urgent**: Discord — `arvinxu`
|
||||
@@ -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.3" "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
|
||||
@@ -83,9 +83,6 @@ Manage agent skills
|
||||
.B session\-group
|
||||
Manage agent session groups
|
||||
.TP
|
||||
.B task
|
||||
Manage agent tasks
|
||||
.TP
|
||||
.B thread
|
||||
Manage message threads
|
||||
.TP
|
||||
@@ -115,9 +112,6 @@ View usage statistics
|
||||
.TP
|
||||
.B eval
|
||||
Manage evaluation workflows
|
||||
.TP
|
||||
.B migrate
|
||||
Migrate data from external tools (OpenClaw, ChatGPT, Claude, etc.)
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
.B \-V, \-\-version
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lobehub/cli",
|
||||
"version": "0.0.3",
|
||||
"version": "0.0.1-canary.14",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"lh": "./dist/index.js",
|
||||
@@ -37,7 +37,6 @@
|
||||
"debug": "^4.4.0",
|
||||
"diff": "^8.0.3",
|
||||
"fast-glob": "^3.3.3",
|
||||
"ignore": "^7.0.5",
|
||||
"picocolors": "^1.1.1",
|
||||
"superjson": "^2.2.6",
|
||||
"tsdown": "^0.21.4",
|
||||
|
||||
@@ -39,9 +39,7 @@ async function getAuthAndServer() {
|
||||
|
||||
const result = await getValidToken();
|
||||
if (!result) {
|
||||
log.error(
|
||||
`No authentication found. Run 'lh login' (or 'npx -y @lobehub/cli login') first, or set ${CLI_API_KEY_ENV}.`,
|
||||
);
|
||||
log.error(`No authentication found. Run 'lh login' first, or set ${CLI_API_KEY_ENV}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
+22
-37
@@ -3,9 +3,29 @@ 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)
|
||||
const SECRET_XOR_KEY = 'LobeHub · LobeHub';
|
||||
|
||||
/**
|
||||
* XOR-obfuscate a payload and encode as Base64.
|
||||
* The /webapi/* routes require `X-lobe-chat-auth` with this encoding.
|
||||
*/
|
||||
function obfuscatePayloadWithXOR(payload: Record<string, any>): string {
|
||||
const jsonString = JSON.stringify(payload);
|
||||
const dataBytes = new TextEncoder().encode(jsonString);
|
||||
const keyBytes = new TextEncoder().encode(SECRET_XOR_KEY);
|
||||
|
||||
const result = new Uint8Array(dataBytes.length);
|
||||
for (let i = 0; i < dataBytes.length; i++) {
|
||||
result[i] = dataBytes[i] ^ keyBytes[i % keyBytes.length];
|
||||
}
|
||||
|
||||
return btoa(String.fromCharCode(...result));
|
||||
}
|
||||
|
||||
export interface AuthInfo {
|
||||
accessToken: string;
|
||||
/** Headers required for /webapi/* endpoints (Oidc-Auth for authentication) */
|
||||
/** Headers required for /webapi/* endpoints (includes both X-lobe-chat-auth and Oidc-Auth) */
|
||||
headers: Record<string, string>;
|
||||
serverUrl: string;
|
||||
}
|
||||
@@ -32,43 +52,8 @@ export async function getAuthInfo(): Promise<AuthInfo> {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Oidc-Auth': accessToken,
|
||||
'X-lobe-chat-auth': obfuscatePayloadWithXOR({}),
|
||||
},
|
||||
serverUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAgentStreamAuthInfo(): Promise<Pick<AuthInfo, 'headers' | 'serverUrl'>> {
|
||||
const serverUrl = resolveServerUrl();
|
||||
|
||||
const envJwt = process.env.LOBEHUB_JWT;
|
||||
if (envJwt) {
|
||||
return {
|
||||
headers: { 'Oidc-Auth': envJwt },
|
||||
serverUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const envApiKey = process.env[CLI_API_KEY_ENV];
|
||||
if (envApiKey) {
|
||||
return {
|
||||
headers: { 'X-API-Key': envApiKey },
|
||||
serverUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await getValidToken();
|
||||
if (!result) {
|
||||
log.error(`No authentication found. Run 'lh login' first, or set ${CLI_API_KEY_ENV}.`);
|
||||
process.exit(1);
|
||||
|
||||
return {
|
||||
headers: {},
|
||||
serverUrl,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
headers: { 'Oidc-Auth': result.credentials.accessToken },
|
||||
serverUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,9 +27,6 @@ const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
execAgent: { mutate: vi.fn() },
|
||||
getOperationStatus: { query: vi.fn() },
|
||||
},
|
||||
device: {
|
||||
listDevices: { query: vi.fn() },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -41,18 +38,13 @@ const { mockStreamAgentEvents } = vi.hoisted(() => ({
|
||||
mockStreamAgentEvents: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockGetAgentStreamAuthInfo } = vi.hoisted(() => ({
|
||||
mockGetAgentStreamAuthInfo: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockResolveLocalDeviceId } = vi.hoisted(() => ({
|
||||
mockResolveLocalDeviceId: vi.fn(),
|
||||
const { mockGetAuthInfo } = vi.hoisted(() => ({
|
||||
mockGetAuthInfo: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient }));
|
||||
vi.mock('../api/http', () => ({ getAgentStreamAuthInfo: mockGetAgentStreamAuthInfo }));
|
||||
vi.mock('../api/http', () => ({ getAuthInfo: mockGetAuthInfo }));
|
||||
vi.mock('../utils/agentStream', () => ({ streamAgentEvents: mockStreamAgentEvents }));
|
||||
vi.mock('../utils/device', () => ({ resolveLocalDeviceId: mockResolveLocalDeviceId }));
|
||||
vi.mock('../utils/logger', () => ({
|
||||
log: { debug: vi.fn(), error: vi.fn(), heartbeat: vi.fn(), info: vi.fn(), warn: vi.fn() },
|
||||
setVerbose: vi.fn(),
|
||||
@@ -66,12 +58,12 @@ describe('agent command', () => {
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
|
||||
mockGetAgentStreamAuthInfo.mockResolvedValue({
|
||||
headers: { 'Oidc-Auth': 'test-token' },
|
||||
mockGetAuthInfo.mockResolvedValue({
|
||||
accessToken: 'test-token',
|
||||
headers: { 'Content-Type': 'application/json', 'Oidc-Auth': 'test-token' },
|
||||
serverUrl: 'https://example.com',
|
||||
});
|
||||
mockStreamAgentEvents.mockResolvedValue(undefined);
|
||||
mockResolveLocalDeviceId.mockReset();
|
||||
for (const method of Object.values(mockTrpcClient.agent)) {
|
||||
for (const fn of Object.values(method)) {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
@@ -82,11 +74,6 @@ describe('agent command', () => {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
}
|
||||
}
|
||||
for (const method of Object.values(mockTrpcClient.device)) {
|
||||
for (const fn of Object.values(method)) {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -310,6 +297,7 @@ describe('agent command', () => {
|
||||
expect.objectContaining({ json: undefined, verbose: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should support --slug option', async () => {
|
||||
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
|
||||
operationId: 'op-456',
|
||||
@@ -396,186 +384,6 @@ describe('agent command', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass --device local as deviceId', async () => {
|
||||
mockResolveLocalDeviceId.mockReturnValue('local-device-1');
|
||||
mockTrpcClient.device.listDevices.query.mockResolvedValue([
|
||||
{ deviceId: 'local-device-1', online: true },
|
||||
]);
|
||||
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
|
||||
operationId: 'op-device',
|
||||
success: true,
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'run',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--prompt',
|
||||
'Hi',
|
||||
'--device',
|
||||
'local',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.aiAgent.execAgent.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'a1', deviceId: 'local-device-1', prompt: 'Hi' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass --topic-id and --device local together', async () => {
|
||||
mockResolveLocalDeviceId.mockReturnValue('local-device-1');
|
||||
mockTrpcClient.device.listDevices.query.mockResolvedValue([
|
||||
{ deviceId: 'local-device-1', online: true },
|
||||
]);
|
||||
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
|
||||
operationId: 'op-topic-device',
|
||||
success: true,
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'run',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--prompt',
|
||||
'Hi',
|
||||
'--topic-id',
|
||||
't1',
|
||||
'--device',
|
||||
'local',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.aiAgent.execAgent.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ appContext: { topicId: 't1' }, deviceId: 'local-device-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass explicit --device id as deviceId', async () => {
|
||||
mockTrpcClient.device.listDevices.query.mockResolvedValue([
|
||||
{ deviceId: 'device-remote-1', online: true },
|
||||
]);
|
||||
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
|
||||
operationId: 'op-explicit-device',
|
||||
success: true,
|
||||
});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'run',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--prompt',
|
||||
'Hi',
|
||||
'--device',
|
||||
'device-remote-1',
|
||||
]);
|
||||
|
||||
expect(mockResolveLocalDeviceId).not.toHaveBeenCalled();
|
||||
expect(mockTrpcClient.aiAgent.execAgent.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'a1', deviceId: 'device-remote-1', prompt: 'Hi' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should exit when explicit device is not found', async () => {
|
||||
mockTrpcClient.device.listDevices.query.mockResolvedValue([
|
||||
{ deviceId: 'other-device', online: true },
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'run',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--prompt',
|
||||
'Hi',
|
||||
'--device',
|
||||
'device-remote-1',
|
||||
]);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('was not found'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should exit when local device cannot be resolved', async () => {
|
||||
mockResolveLocalDeviceId.mockReturnValue(undefined);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'run',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--prompt',
|
||||
'Hi',
|
||||
'--device',
|
||||
'local',
|
||||
]);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining("Run 'lh connect' first"));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should exit when local device is offline', async () => {
|
||||
mockResolveLocalDeviceId.mockReturnValue('local-device-1');
|
||||
mockTrpcClient.device.listDevices.query.mockResolvedValue([
|
||||
{ deviceId: 'local-device-1', online: false },
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'run',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--prompt',
|
||||
'Hi',
|
||||
'--device',
|
||||
'local',
|
||||
]);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('is not online'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should exit when explicit device is offline', async () => {
|
||||
mockTrpcClient.device.listDevices.query.mockResolvedValue([
|
||||
{ deviceId: 'device-remote-1', online: false },
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'agent',
|
||||
'run',
|
||||
'--agent-id',
|
||||
'a1',
|
||||
'--prompt',
|
||||
'Hi',
|
||||
'--device',
|
||||
'device-remote-1',
|
||||
]);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Bring it online'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should pass --json to stream options', async () => {
|
||||
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
|
||||
operationId: 'op-j',
|
||||
|
||||
@@ -4,14 +4,8 @@ import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { getAgentStreamAuthInfo } from '../api/http';
|
||||
import { resolveAgentGatewayUrl } from '../settings';
|
||||
import {
|
||||
replayAgentEvents,
|
||||
streamAgentEvents,
|
||||
streamAgentEventsViaWebSocket,
|
||||
} from '../utils/agentStream';
|
||||
import { resolveLocalDeviceId } from '../utils/device';
|
||||
import { getAuthInfo } from '../api/http';
|
||||
import { replayAgentEvents, streamAgentEvents } from '../utils/agentStream';
|
||||
import { confirm, outputJson, printTable, truncate } from '../utils/format';
|
||||
import { log, setVerbose } from '../utils/logger';
|
||||
|
||||
@@ -254,24 +248,17 @@ export function registerAgentCommand(program: Command) {
|
||||
.option('-p, --prompt <text>', 'User prompt')
|
||||
.option('-t, --topic-id <id>', 'Reuse an existing topic')
|
||||
.option('--no-auto-start', 'Do not auto-start the agent')
|
||||
.option(
|
||||
'--device <target>',
|
||||
'Target device ID, or use "local" for the current connected device',
|
||||
)
|
||||
.option('--json', 'Output full JSON event stream')
|
||||
.option('-v, --verbose', 'Show detailed tool call info')
|
||||
.option('--replay <file>', 'Replay events from a saved JSON file (offline)')
|
||||
.option('--sse', 'Force SSE stream instead of WebSocket gateway')
|
||||
.action(
|
||||
async (options: {
|
||||
agentId?: string;
|
||||
autoStart?: boolean;
|
||||
device?: string;
|
||||
json?: boolean;
|
||||
prompt?: string;
|
||||
replay?: string;
|
||||
slug?: string;
|
||||
sse?: boolean;
|
||||
topicId?: string;
|
||||
verbose?: boolean;
|
||||
}) => {
|
||||
@@ -298,45 +285,9 @@ export function registerAgentCommand(program: Command) {
|
||||
|
||||
const client = await getTrpcClient();
|
||||
|
||||
let deviceId: string | undefined;
|
||||
if (options.device !== undefined) {
|
||||
if (options.device === 'local') {
|
||||
deviceId = resolveLocalDeviceId();
|
||||
if (!deviceId) {
|
||||
log.error(
|
||||
"No local device found. Run 'lh connect' first, then retry with --device local.",
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
deviceId = options.device;
|
||||
}
|
||||
|
||||
const devices = await client.device.listDevices.query();
|
||||
const matchedDevice = devices.find(
|
||||
(device: { deviceId?: string; online?: boolean }) => device.deviceId === deviceId,
|
||||
);
|
||||
if (!matchedDevice) {
|
||||
log.error(`Device "${deviceId}" was not found. Check 'lh device list' and try again.`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
if (!matchedDevice.online) {
|
||||
log.error(
|
||||
options.device === 'local'
|
||||
? `Local device "${deviceId}" is not online. Reconnect with 'lh connect' and try again.`
|
||||
: `Device "${deviceId}" is not online. Bring it online and try again.`,
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Exec agent to get operationId
|
||||
const input: Record<string, any> = { prompt: options.prompt };
|
||||
if (options.agentId) input.agentId = options.agentId;
|
||||
if (deviceId) input.deviceId = deviceId;
|
||||
if (options.slug) input.slug = options.slug;
|
||||
if (options.topicId) input.appContext = { topicId: options.topicId };
|
||||
if (options.autoStart === false) input.autoStart = false;
|
||||
@@ -354,26 +305,14 @@ export function registerAgentCommand(program: Command) {
|
||||
log.info(`Operation: ${pc.dim(operationId)} · Topic: ${pc.dim(r.topicId || 'n/a')}`);
|
||||
}
|
||||
|
||||
// 2. Connect to stream (WebSocket via Gateway, or fallback to SSE)
|
||||
const { serverUrl, headers } = await getAgentStreamAuthInfo();
|
||||
const agentGatewayUrl = options.sse ? undefined : resolveAgentGatewayUrl();
|
||||
// 2. Connect to SSE stream
|
||||
const { serverUrl, headers } = await getAuthInfo();
|
||||
const streamUrl = `${serverUrl}/api/agent/stream?operationId=${encodeURIComponent(operationId)}`;
|
||||
|
||||
if (agentGatewayUrl) {
|
||||
const token = headers['Oidc-Auth'] || headers['X-API-Key'] || '';
|
||||
await streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: agentGatewayUrl,
|
||||
json: options.json,
|
||||
operationId,
|
||||
token,
|
||||
verbose: options.verbose,
|
||||
});
|
||||
} else {
|
||||
const streamUrl = `${serverUrl}/api/agent/stream?operationId=${encodeURIComponent(operationId)}`;
|
||||
await streamAgentEvents(streamUrl, headers, {
|
||||
json: options.json,
|
||||
verbose: options.verbose,
|
||||
});
|
||||
}
|
||||
await streamAgentEvents(streamUrl, headers, {
|
||||
json: options.json,
|
||||
verbose: options.verbose,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ vi.mock('@lobechat/device-gateway-client', () => ({
|
||||
// eslint-disable-next-line import-x/first
|
||||
import { resolveToken } from '../auth/resolveToken';
|
||||
// eslint-disable-next-line import-x/first
|
||||
import { removeStatus, spawnDaemon, stopDaemon, writeStatus } from '../daemon/manager';
|
||||
import { spawnDaemon, stopDaemon } from '../daemon/manager';
|
||||
// eslint-disable-next-line import-x/first
|
||||
import { loadSettings, saveSettings } from '../settings';
|
||||
// eslint-disable-next-line import-x/first
|
||||
@@ -130,36 +130,6 @@ describe('connect command', () => {
|
||||
return program;
|
||||
}
|
||||
|
||||
it('should persist deviceId in status for foreground connections', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'connect']);
|
||||
|
||||
expect(writeStatus).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ connectionStatus: 'connecting', deviceId: 'mock-device-id' }),
|
||||
);
|
||||
|
||||
clientEventHandlers.connected?.();
|
||||
|
||||
expect(writeStatus).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ connectionStatus: 'connected', deviceId: 'mock-device-id' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should persist deviceId in status for daemon child connections', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'connect', '--daemon-child']);
|
||||
|
||||
expect(writeStatus).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ connectionStatus: 'connecting', deviceId: 'mock-device-id' }),
|
||||
);
|
||||
|
||||
clientEventHandlers.connected?.();
|
||||
|
||||
expect(writeStatus).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ connectionStatus: 'connected', deviceId: 'mock-device-id' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should connect to gateway', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'connect']);
|
||||
@@ -318,7 +288,6 @@ describe('connect command', () => {
|
||||
}
|
||||
|
||||
expect(cleanupAllProcesses).toHaveBeenCalled();
|
||||
expect(removeStatus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle auth_expired when refresh fails', async () => {
|
||||
|
||||
@@ -221,15 +221,16 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
info(` Mode : ${isDaemonChild ? 'daemon' : 'foreground'}`);
|
||||
info('───────────────────');
|
||||
|
||||
// Update local connection status so other CLI commands can resolve the current device
|
||||
// Update status file for daemon mode
|
||||
const updateStatus = (connectionStatus: string) => {
|
||||
writeStatus({
|
||||
connectionStatus,
|
||||
deviceId: client.currentDeviceId,
|
||||
gatewayUrl: resolvedGatewayUrl,
|
||||
pid: process.pid,
|
||||
startedAt: startedAt.toISOString(),
|
||||
});
|
||||
if (isDaemonChild) {
|
||||
writeStatus({
|
||||
connectionStatus,
|
||||
gatewayUrl: resolvedGatewayUrl,
|
||||
pid: process.pid,
|
||||
startedAt: startedAt.toISOString(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const startedAt = new Date();
|
||||
@@ -332,8 +333,8 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
info('Shutting down...');
|
||||
cleanupAllProcesses();
|
||||
client.disconnect();
|
||||
removeStatus();
|
||||
if (isDaemonChild) {
|
||||
removeStatus();
|
||||
removePid();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -61,6 +61,7 @@ describe('generate command', () => {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Oidc-Auth': 'test-token',
|
||||
'X-lobe-chat-auth': 'test-xor-token',
|
||||
},
|
||||
serverUrl: 'https://app.lobehub.com',
|
||||
});
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { registerOpenClawMigration } from './openclaw';
|
||||
|
||||
export function registerMigrateCommand(program: Command) {
|
||||
const migrate = program
|
||||
.command('migrate')
|
||||
.description('Migrate data from external tools (OpenClaw, ChatGPT, Claude, etc.)');
|
||||
|
||||
registerOpenClawMigration(migrate);
|
||||
}
|
||||
@@ -1,588 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// ── Mocks ──────────────────────────────────────────────
|
||||
|
||||
const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
mockTrpcClient: {
|
||||
agent: {
|
||||
createAgent: { mutate: vi.fn() },
|
||||
getBuiltinAgent: { query: vi.fn() },
|
||||
},
|
||||
agentDocument: {
|
||||
upsertDocument: { mutate: vi.fn() },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({
|
||||
getTrpcClient: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockConfirm } = vi.hoisted(() => ({
|
||||
mockConfirm: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
getTrpcClient: mockGetTrpcClient,
|
||||
}));
|
||||
|
||||
vi.mock('../../settings', () => ({
|
||||
resolveServerUrl: () => 'https://app.lobehub.com',
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/format', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, confirm: mockConfirm };
|
||||
});
|
||||
|
||||
vi.mock('../../utils/logger', () => ({
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
setVerbose: vi.fn(),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import-x/first
|
||||
import { log } from '../../utils/logger';
|
||||
// eslint-disable-next-line import-x/first
|
||||
import { registerOpenClawMigration } from './openclaw';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
function createProgram() {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
const migrate = program.command('migrate');
|
||||
registerOpenClawMigration(migrate);
|
||||
return program;
|
||||
}
|
||||
|
||||
function writeFile(relativePath: string, content: string) {
|
||||
const fullPath = path.join(tmpDir, relativePath);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, content);
|
||||
}
|
||||
|
||||
// ── Setup / teardown ───────────────────────────────────
|
||||
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openclaw-test-'));
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
|
||||
throw new Error('process.exit');
|
||||
}) as any);
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
consoleSpy.mockRestore();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────
|
||||
|
||||
describe('migrate openclaw', () => {
|
||||
// ── Profile parsing ────────────────────────────────
|
||||
|
||||
describe('agent profile from workspace', () => {
|
||||
it('should read name, description, and emoji from IDENTITY.md', async () => {
|
||||
writeFile(
|
||||
'IDENTITY.md',
|
||||
['# IDENTITY.md', '- **Name:** 龙虾', '- **Creature:** AI 助手', '- **Emoji:** 🦞'].join(
|
||||
'\n',
|
||||
),
|
||||
);
|
||||
writeFile('hello.md', 'hello');
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agent.createAgent.mutate).toHaveBeenCalledWith({
|
||||
config: {
|
||||
avatar: '🦞',
|
||||
description: 'AI 助手',
|
||||
title: '龙虾',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter out placeholder emoji like (待定)', async () => {
|
||||
writeFile(
|
||||
'IDENTITY.md',
|
||||
['# IDENTITY.md', '- **Name:** TestBot', '- **Emoji:**', ' _(待定)_'].join('\n'),
|
||||
);
|
||||
writeFile('hello.md', 'hello');
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agent.createAgent.mutate).toHaveBeenCalledWith({
|
||||
config: {
|
||||
avatar: undefined,
|
||||
description: undefined,
|
||||
title: 'TestBot',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should fall back to "OpenClaw" when no identity files exist', async () => {
|
||||
writeFile('doc.md', 'content');
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agent.createAgent.mutate).toHaveBeenCalledWith({
|
||||
config: {
|
||||
avatar: undefined,
|
||||
description: undefined,
|
||||
title: 'OpenClaw',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── File filtering ─────────────────────────────────
|
||||
|
||||
describe('file collection and filtering', () => {
|
||||
it('should exclude common directories like node_modules and .git', async () => {
|
||||
writeFile('README.md', 'readme');
|
||||
writeFile('node_modules/pkg/index.js', 'module');
|
||||
writeFile('.git/config', 'git');
|
||||
writeFile('.idea/workspace.xml', 'ide');
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ filename: 'README.md' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should exclude files matching glob patterns like *.pyc and *.log', async () => {
|
||||
writeFile('main.py', 'print("hi")');
|
||||
writeFile('main.pyc', 'bytecode');
|
||||
writeFile('app.log', 'log data');
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ filename: 'main.py' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should respect workspace .gitignore', async () => {
|
||||
writeFile('.gitignore', 'secret.txt\ndata/\n');
|
||||
writeFile('README.md', 'readme');
|
||||
writeFile('secret.txt', 'password');
|
||||
writeFile('data/dump.sql', 'sql');
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
const filenames = mockTrpcClient.agentDocument.upsertDocument.mutate.mock.calls.map(
|
||||
(c: any[]) => c[0].filename,
|
||||
);
|
||||
expect(filenames).toContain('README.md');
|
||||
expect(filenames).not.toContain('secret.txt');
|
||||
expect(filenames).not.toContain('data/dump.sql');
|
||||
});
|
||||
|
||||
it('should skip binary files during import', async () => {
|
||||
writeFile('readme.md', 'text content');
|
||||
// Write a file with null bytes (binary)
|
||||
const binPath = path.join(tmpDir, 'image.dat');
|
||||
fs.writeFileSync(binPath, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x00, 0x01]));
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
// Only the text file should be upserted
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ filename: 'readme.md' }),
|
||||
);
|
||||
// Binary file should show as skipped in output
|
||||
const allOutput = consoleSpy.mock.calls.map((c: any[]) => c[0]).join('\n');
|
||||
expect(allOutput).toContain('skipped');
|
||||
});
|
||||
|
||||
it('should exclude database files by extension', async () => {
|
||||
writeFile('data.md', 'notes');
|
||||
writeFile('local.sqlite', 'fake-sqlite');
|
||||
writeFile('app.db', 'fake-db');
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ filename: 'data.md' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should collect files in subdirectories', async () => {
|
||||
writeFile('docs/guide.md', 'guide');
|
||||
writeFile('docs/api.md', 'api');
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
const filenames = mockTrpcClient.agentDocument.upsertDocument.mutate.mock.calls
|
||||
.map((c: any[]) => c[0].filename)
|
||||
.sort();
|
||||
expect(filenames).toEqual(['docs/api.md', 'docs/guide.md']);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Dry run ────────────────────────────────────────
|
||||
|
||||
describe('--dry-run', () => {
|
||||
it('should list files without calling API', async () => {
|
||||
writeFile('file.md', 'content');
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--dry-run',
|
||||
]);
|
||||
|
||||
expect(mockGetTrpcClient).not.toHaveBeenCalled();
|
||||
expect(mockTrpcClient.agent.createAgent.mutate).not.toHaveBeenCalled();
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).not.toHaveBeenCalled();
|
||||
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Dry run'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── Agent resolution ───────────────────────────────
|
||||
|
||||
describe('agent resolution', () => {
|
||||
it('should use --agent-id directly when provided', async () => {
|
||||
writeFile('file.md', 'content');
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--agent-id',
|
||||
'agt_existing',
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agent.createAgent.mutate).not.toHaveBeenCalled();
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'agt_existing' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve agent by --slug', async () => {
|
||||
writeFile('file.md', 'content');
|
||||
mockTrpcClient.agent.getBuiltinAgent.query.mockResolvedValue({ id: 'agt_inbox' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--slug',
|
||||
'inbox',
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agent.getBuiltinAgent.query).toHaveBeenCalledWith({ slug: 'inbox' });
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'agt_inbox' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should create a new agent by default', async () => {
|
||||
writeFile('file.md', 'content');
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_new' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agent.createAgent.mutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agentId: 'agt_new' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Confirmation ───────────────────────────────────
|
||||
|
||||
describe('confirmation', () => {
|
||||
it('should cancel when user declines', async () => {
|
||||
writeFile('file.md', 'content');
|
||||
mockConfirm.mockResolvedValue(false);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'migrate', 'openclaw', '--source', tmpDir]);
|
||||
|
||||
expect(mockTrpcClient.agent.createAgent.mutate).not.toHaveBeenCalled();
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Cancelled.');
|
||||
});
|
||||
|
||||
it('should skip confirmation with --yes', async () => {
|
||||
writeFile('file.md', 'content');
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Error handling ─────────────────────────────────
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should exit when source path does not exist', async () => {
|
||||
const program = createProgram();
|
||||
await program
|
||||
.parseAsync(['node', 'test', 'migrate', 'openclaw', '--source', '/nonexistent/path'])
|
||||
.catch(() => {}); // process.exit throws
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found'));
|
||||
});
|
||||
|
||||
it('should report failed files without aborting', async () => {
|
||||
writeFile('a.md', 'ok');
|
||||
writeFile('b.md', 'fail');
|
||||
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
// Files are iterated in readdir order; mock first success then failure
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate
|
||||
.mockResolvedValueOnce({})
|
||||
.mockRejectedValueOnce(new Error('upload error'));
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledTimes(2);
|
||||
const allOutput = consoleSpy.mock.calls.map((c: any[]) => c[0]).join('\n');
|
||||
expect(allOutput).toContain('1 imported');
|
||||
expect(allOutput).toContain('1 failed');
|
||||
});
|
||||
|
||||
it('should show no files message for empty workspace', async () => {
|
||||
// Only excluded items
|
||||
writeFile('.git/config', 'git');
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--dry-run',
|
||||
]);
|
||||
|
||||
expect(log.info).toHaveBeenCalledWith('No files found in workspace.');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Output ─────────────────────────────────────────
|
||||
|
||||
describe('output', () => {
|
||||
it('should print agent URL on completion', async () => {
|
||||
writeFile('file.md', 'content');
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_abc123' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
const allOutput = consoleSpy.mock.calls.map((c: any[]) => c[0]).join('\n');
|
||||
expect(allOutput).toContain('https://app.lobehub.com/agent/agt_abc123');
|
||||
});
|
||||
|
||||
it('should show friendly completion message on success', async () => {
|
||||
writeFile('file.md', 'content');
|
||||
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
|
||||
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'migrate',
|
||||
'openclaw',
|
||||
'--source',
|
||||
tmpDir,
|
||||
'--yes',
|
||||
]);
|
||||
|
||||
const allOutput = consoleSpy.mock.calls.map((c: any[]) => c[0]).join('\n');
|
||||
expect(allOutput).toContain('Migration complete');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,466 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { Command } from 'commander';
|
||||
import ignore from 'ignore';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import type { TrpcClient } from '../../api/client';
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
import { resolveServerUrl } from '../../settings';
|
||||
import { confirm } from '../../utils/format';
|
||||
import { log } from '../../utils/logger';
|
||||
|
||||
const DEFAULT_AGENT_NAME = 'OpenClaw';
|
||||
|
||||
// Files to look for agent identity (tried in order)
|
||||
const IDENTITY_FILES = ['IDENTITY.md', 'SOUL.md'];
|
||||
|
||||
// Default ignore rules (gitignore syntax) applied when no .gitignore is found
|
||||
const DEFAULT_IGNORE_RULES = [
|
||||
// VCS
|
||||
'.git',
|
||||
'.svn',
|
||||
'.hg',
|
||||
|
||||
// OpenClaw internal
|
||||
'.openclaw',
|
||||
|
||||
// OS artifacts
|
||||
'.DS_Store',
|
||||
'Thumbs.db',
|
||||
'desktop.ini',
|
||||
|
||||
// IDE / editor
|
||||
'.idea',
|
||||
'.vscode',
|
||||
'.fleet',
|
||||
'.cursor',
|
||||
'.zed',
|
||||
'*.swp',
|
||||
'*.swo',
|
||||
'*~',
|
||||
|
||||
// Dependencies
|
||||
'node_modules',
|
||||
'.pnp',
|
||||
'.yarn',
|
||||
'bower_components',
|
||||
'vendor',
|
||||
'jspm_packages',
|
||||
|
||||
// Python
|
||||
'.venv',
|
||||
'venv',
|
||||
'env',
|
||||
'__pycache__',
|
||||
'*.pyc',
|
||||
'*.pyo',
|
||||
'.mypy_cache',
|
||||
'.ruff_cache',
|
||||
'.pytest_cache',
|
||||
'.tox',
|
||||
'.eggs',
|
||||
'*.egg-info',
|
||||
|
||||
// Ruby
|
||||
'.bundle',
|
||||
|
||||
// Rust
|
||||
'target',
|
||||
|
||||
// Go
|
||||
'go.sum',
|
||||
|
||||
// Java / JVM
|
||||
'.gradle',
|
||||
'.m2',
|
||||
|
||||
// .NET
|
||||
'bin',
|
||||
'obj',
|
||||
'packages',
|
||||
|
||||
// Build / cache / output
|
||||
'.cache',
|
||||
'.parcel-cache',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.turbo',
|
||||
'.output',
|
||||
'dist',
|
||||
'build',
|
||||
'out',
|
||||
'.sass-cache',
|
||||
|
||||
// Env / secrets
|
||||
'.env',
|
||||
'.env.*',
|
||||
|
||||
// Test / coverage
|
||||
'coverage',
|
||||
'.nyc_output',
|
||||
|
||||
// Infra
|
||||
'.terraform',
|
||||
|
||||
// Temp
|
||||
'tmp',
|
||||
'.tmp',
|
||||
|
||||
// Logs
|
||||
'*.log',
|
||||
'logs',
|
||||
|
||||
// Databases
|
||||
'*.sqlite',
|
||||
'*.sqlite3',
|
||||
'*.db',
|
||||
'*.db-shm',
|
||||
'*.db-wal',
|
||||
'*.ldb',
|
||||
'*.mdb',
|
||||
'*.accdb',
|
||||
|
||||
// Archives / binaries
|
||||
'*.zip',
|
||||
'*.tar',
|
||||
'*.tar.gz',
|
||||
'*.tgz',
|
||||
'*.gz',
|
||||
'*.bz2',
|
||||
'*.xz',
|
||||
'*.rar',
|
||||
'*.7z',
|
||||
'*.jar',
|
||||
'*.war',
|
||||
'*.dll',
|
||||
'*.so',
|
||||
'*.dylib',
|
||||
'*.exe',
|
||||
'*.bin',
|
||||
'*.o',
|
||||
'*.a',
|
||||
'*.lib',
|
||||
'*.class',
|
||||
|
||||
// Images / media / fonts
|
||||
'*.png',
|
||||
'*.jpg',
|
||||
'*.jpeg',
|
||||
'*.gif',
|
||||
'*.bmp',
|
||||
'*.ico',
|
||||
'*.webp',
|
||||
'*.svg',
|
||||
'*.mp3',
|
||||
'*.mp4',
|
||||
'*.wav',
|
||||
'*.avi',
|
||||
'*.mov',
|
||||
'*.mkv',
|
||||
'*.flac',
|
||||
'*.ogg',
|
||||
'*.pdf',
|
||||
'*.woff',
|
||||
'*.woff2',
|
||||
'*.ttf',
|
||||
'*.otf',
|
||||
'*.eot',
|
||||
|
||||
// Lock files
|
||||
'package-lock.json',
|
||||
'yarn.lock',
|
||||
'pnpm-lock.yaml',
|
||||
'Gemfile.lock',
|
||||
'Cargo.lock',
|
||||
'poetry.lock',
|
||||
'composer.lock',
|
||||
];
|
||||
|
||||
interface AgentProfile {
|
||||
avatar?: string;
|
||||
description?: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to extract the agent name, description, and avatar emoji from
|
||||
* IDENTITY.md or SOUL.md. Falls back to "OpenClaw" if neither file
|
||||
* exists or parsing fails.
|
||||
*/
|
||||
function readAgentProfile(workspacePath: string): AgentProfile {
|
||||
for (const filename of IDENTITY_FILES) {
|
||||
const filePath = path.join(workspacePath, filename);
|
||||
if (!fs.existsSync(filePath)) continue;
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
|
||||
// Try to extract **Name:** value
|
||||
const nameMatch = content.match(/\*{0,2}Name:?\*{0,2}\s*(.+)/i);
|
||||
const title = nameMatch ? nameMatch[1].trim() : DEFAULT_AGENT_NAME;
|
||||
|
||||
// Try to extract **Creature:** or **Vibe:** or **Description:** as description
|
||||
const descMatch = content.match(/\*{0,2}(?:Creature|Vibe|Description):?\*{0,2}\s*(.+)/i);
|
||||
const description = descMatch ? descMatch[1].trim() : undefined;
|
||||
|
||||
// Try to extract **Emoji:** value (single emoji)
|
||||
const emojiMatch = content.match(/\*{0,2}Emoji:?\*{0,2}\s*(.+)/i);
|
||||
const rawAvatar = emojiMatch ? emojiMatch[1].trim() : undefined;
|
||||
// Filter out placeholder text like (待定), _(待定)_, (TBD), N/A, etc.
|
||||
const isPlaceholder =
|
||||
rawAvatar && /^[_*((].*[))_*]$|^(?:tbd|todo|n\/?a|none|待定|未定)$/i.test(rawAvatar);
|
||||
const avatar = rawAvatar && !isPlaceholder ? rawAvatar : undefined;
|
||||
|
||||
return { avatar, description, title };
|
||||
}
|
||||
|
||||
return { title: DEFAULT_AGENT_NAME };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an ignore filter for the workspace. Uses .gitignore if present,
|
||||
* otherwise falls back to a comprehensive default rule set.
|
||||
*/
|
||||
function buildIgnoreFilter(workspacePath: string) {
|
||||
const ig = ignore();
|
||||
|
||||
const gitignorePath = path.join(workspacePath, '.gitignore');
|
||||
if (fs.existsSync(gitignorePath)) {
|
||||
ig.add(fs.readFileSync(gitignorePath, 'utf8'));
|
||||
}
|
||||
|
||||
// Always apply default rules on top
|
||||
ig.add(DEFAULT_IGNORE_RULES);
|
||||
|
||||
return ig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collect all files under `dir`, filtered by ignore rules.
|
||||
* Returns paths relative to `baseDir`.
|
||||
*/
|
||||
function collectFiles(dir: string, baseDir: string, ig: ReturnType<typeof ignore>): string[] {
|
||||
const results: string[] = [];
|
||||
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const relativePath = path.relative(baseDir, path.join(dir, entry.name));
|
||||
|
||||
// Directories need a trailing slash for ignore to match correctly
|
||||
const testPath = entry.isDirectory() ? `${relativePath}/` : relativePath;
|
||||
if (ig.ignores(testPath)) continue;
|
||||
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...collectFiles(fullPath, baseDir, ig));
|
||||
} else if (entry.isFile()) {
|
||||
results.push(relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick check: read the first 8KB and look for null bytes.
|
||||
* If found, the file is likely binary and should be skipped.
|
||||
*/
|
||||
function isBinaryFile(filePath: string): boolean {
|
||||
const fd = fs.openSync(filePath, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(8192);
|
||||
const bytesRead = fs.readSync(fd, buf, 0, 8192, 0);
|
||||
for (let i = 0; i < bytesRead; i++) {
|
||||
if (buf[i] === 0) return true;
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
function formatAgentLabel(profile: AgentProfile): string {
|
||||
return profile.avatar ? `${profile.avatar} ${profile.title}` : profile.title;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the target agent ID.
|
||||
* Priority: --agent-id > --slug > create new agent from workspace profile.
|
||||
*/
|
||||
async function resolveAgentId(
|
||||
client: TrpcClient,
|
||||
opts: { agentId?: string; slug?: string },
|
||||
profile: AgentProfile,
|
||||
): Promise<string> {
|
||||
if (opts.agentId) return opts.agentId;
|
||||
|
||||
if (opts.slug) {
|
||||
const agent = await client.agent.getBuiltinAgent.query({ slug: opts.slug });
|
||||
if (!agent) {
|
||||
log.error(`Agent not found for slug: ${opts.slug}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return agent.id;
|
||||
}
|
||||
|
||||
const label = formatAgentLabel(profile);
|
||||
log.info(`Creating new agent ${pc.bold(label)}...`);
|
||||
const result = await client.agent.createAgent.mutate({
|
||||
config: {
|
||||
avatar: profile.avatar,
|
||||
description: profile.description,
|
||||
title: profile.title,
|
||||
},
|
||||
});
|
||||
|
||||
const id = result.agentId;
|
||||
if (!id) {
|
||||
log.error('Failed to create agent — no agentId returned.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`${pc.green('✓')} Agent created: ${pc.bold(label)}`);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function registerOpenClawMigration(migrate: Command) {
|
||||
migrate
|
||||
.command('openclaw')
|
||||
.description('Import OpenClaw workspace files as agent documents')
|
||||
.option(
|
||||
'--source <path>',
|
||||
'Path to OpenClaw workspace',
|
||||
path.join(os.homedir(), '.openclaw', 'workspace'),
|
||||
)
|
||||
.option('--agent-id <id>', 'Import into an existing agent by ID')
|
||||
.option('--slug <slug>', 'Import into an existing agent by slug (e.g. "inbox")')
|
||||
.option('--dry-run', 'Preview files without importing')
|
||||
.option('--yes', 'Skip confirmation prompt')
|
||||
.action(
|
||||
async (options: {
|
||||
agentId?: string;
|
||||
dryRun?: boolean;
|
||||
slug?: string;
|
||||
source: string;
|
||||
yes?: boolean;
|
||||
}) => {
|
||||
// Check auth early so users don't scan files only to find out they're not logged in
|
||||
if (!options.dryRun) {
|
||||
await getTrpcClient();
|
||||
}
|
||||
|
||||
const workspacePath = path.resolve(options.source);
|
||||
|
||||
// Validate source directory
|
||||
if (!fs.existsSync(workspacePath)) {
|
||||
log.error(`OpenClaw workspace not found: ${workspacePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.statSync(workspacePath).isDirectory()) {
|
||||
log.error(`Not a directory: ${workspacePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Read agent profile from workspace identity files
|
||||
const profile = readAgentProfile(workspacePath);
|
||||
const label = formatAgentLabel(profile);
|
||||
|
||||
// Collect files (respects .gitignore + default rules)
|
||||
const ig = buildIgnoreFilter(workspacePath);
|
||||
const files = collectFiles(workspacePath, workspacePath, ig);
|
||||
|
||||
if (files.length === 0) {
|
||||
log.info('No files found in workspace.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Found ${pc.bold(String(files.length))} file(s) in ${pc.dim(workspacePath)}:\n`,
|
||||
);
|
||||
for (const f of files) {
|
||||
console.log(` ${pc.dim('•')} ${f}`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
if (options.dryRun) {
|
||||
log.info('Dry run — no changes made.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm
|
||||
if (!options.yes) {
|
||||
const target = options.agentId
|
||||
? `agent ${pc.bold(options.agentId)}`
|
||||
: options.slug
|
||||
? `agent slug "${pc.bold(options.slug)}"`
|
||||
: `a new ${pc.bold(label)} agent`;
|
||||
const confirmed = await confirm(
|
||||
`Import ${files.length} file(s) as agent documents into ${target}?`,
|
||||
);
|
||||
if (!confirmed) {
|
||||
console.log('Cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// Create or reuse agent
|
||||
const agentId = await resolveAgentId(client, options, profile);
|
||||
|
||||
console.log(`\nImporting to ${pc.bold(label)}...\n`);
|
||||
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
|
||||
let skipped = 0;
|
||||
|
||||
for (const relativePath of files) {
|
||||
const fullPath = path.join(workspacePath, relativePath);
|
||||
|
||||
try {
|
||||
// Skip binary files that slipped through the extension filter
|
||||
if (isBinaryFile(fullPath)) {
|
||||
console.log(` ${pc.dim('○')} ${relativePath} ${pc.dim('(binary, skipped)')}`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(fullPath, 'utf8');
|
||||
const stat = fs.statSync(fullPath);
|
||||
|
||||
await client.agentDocument.upsertDocument.mutate({
|
||||
agentId,
|
||||
content,
|
||||
createdAt: stat.birthtime,
|
||||
filename: relativePath,
|
||||
updatedAt: stat.mtime,
|
||||
});
|
||||
console.log(` ${pc.green('✓')} ${relativePath}`);
|
||||
success++;
|
||||
} catch (err: any) {
|
||||
console.log(` ${pc.red('✗')} ${relativePath} — ${err.message || err}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
const agentUrl = `${resolveServerUrl()}/agent/${agentId}`;
|
||||
const skippedInfo = skipped > 0 ? `, ${skipped} skipped` : '';
|
||||
console.log();
|
||||
if (failed === 0) {
|
||||
console.log(
|
||||
`${pc.green('✓')} Migration complete! ${pc.bold(String(success))} file(s) imported to ${pc.bold(label)}.${skippedInfo}`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`${pc.yellow('⚠')} Migration finished with issues: ${pc.bold(String(success))} imported, ${pc.red(String(failed))} failed${skippedInfo}.`,
|
||||
);
|
||||
}
|
||||
console.log(`\n ${pc.dim('→')} ${pc.underline(agentUrl)}`);
|
||||
console.log();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -2,12 +2,10 @@ import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
import type { KanbanColumn } from '../../utils/format';
|
||||
import {
|
||||
confirm,
|
||||
displayWidth,
|
||||
outputJson,
|
||||
printKanban,
|
||||
printTable,
|
||||
timeAgo,
|
||||
truncate,
|
||||
@@ -39,12 +37,10 @@ export function registerTaskCommand(program: Command) {
|
||||
.option('-L, --limit <n>', 'Page size', '50')
|
||||
.option('--offset <n>', 'Offset', '0')
|
||||
.option('--tree', 'Display as tree structure')
|
||||
.option('--board', 'Display as kanban board grouped by status')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(
|
||||
async (options: {
|
||||
agent?: string;
|
||||
board?: boolean;
|
||||
json?: string | boolean;
|
||||
limit?: string;
|
||||
offset?: string;
|
||||
@@ -63,8 +59,8 @@ export function registerTaskCommand(program: Command) {
|
||||
if (options.limit) input.limit = Number.parseInt(options.limit, 10);
|
||||
if (options.offset) input.offset = Number.parseInt(options.offset, 10);
|
||||
|
||||
// For tree/board mode, fetch all tasks (no pagination limit)
|
||||
if (options.tree || options.board) {
|
||||
// For tree mode, fetch all tasks (no pagination limit)
|
||||
if (options.tree) {
|
||||
input.limit = 100;
|
||||
delete input.offset;
|
||||
}
|
||||
@@ -81,58 +77,6 @@ export function registerTaskCommand(program: Command) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.board) {
|
||||
// Kanban board grouped by status
|
||||
const statusOrder = [
|
||||
'backlog',
|
||||
'blocked',
|
||||
'running',
|
||||
'paused',
|
||||
'completed',
|
||||
'failed',
|
||||
'timeout',
|
||||
'canceled',
|
||||
];
|
||||
|
||||
const statusColors: Record<string, (s: string) => string> = {
|
||||
backlog: pc.dim,
|
||||
blocked: pc.red,
|
||||
canceled: pc.dim,
|
||||
completed: pc.green,
|
||||
failed: pc.red,
|
||||
paused: pc.yellow,
|
||||
running: pc.blue,
|
||||
timeout: pc.red,
|
||||
};
|
||||
|
||||
// Group tasks by status
|
||||
const grouped = new Map<string, any[]>();
|
||||
for (const t of result.data) {
|
||||
const status = t.status || 'backlog';
|
||||
const list = grouped.get(status) || [];
|
||||
list.push(t);
|
||||
grouped.set(status, list);
|
||||
}
|
||||
|
||||
const kanbanColumns: KanbanColumn[] = statusOrder
|
||||
.filter((s) => grouped.has(s))
|
||||
.map((status) => ({
|
||||
color: statusColors[status],
|
||||
items: grouped.get(status)!.map((t: any) => ({
|
||||
badge: pc.dim(t.identifier),
|
||||
meta: t.assigneeAgentId ? `agent: ${t.assigneeAgentId}` : undefined,
|
||||
title: t.name || t.instruction,
|
||||
})),
|
||||
title: status.toUpperCase(),
|
||||
}));
|
||||
|
||||
console.log();
|
||||
printKanban(kanbanColumns);
|
||||
console.log();
|
||||
log.info(`Total: ${result.total}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.tree) {
|
||||
// Build tree display
|
||||
const taskMap = new Map<string, any>();
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export const OFFICIAL_AGENT_GATEWAY_URL = 'https://agent-gateway.lobehub.com';
|
||||
export const OFFICIAL_SERVER_URL = 'https://app.lobehub.com';
|
||||
export const OFFICIAL_GATEWAY_URL = 'https://device-gateway.lobehub.com';
|
||||
|
||||
@@ -23,7 +23,6 @@ function getLogFilePath() {
|
||||
|
||||
export interface DaemonStatus {
|
||||
connectionStatus: string;
|
||||
deviceId?: string;
|
||||
gatewayUrl: string;
|
||||
pid: number;
|
||||
startedAt: string;
|
||||
|
||||
@@ -20,7 +20,6 @@ import { registerLogoutCommand } from './commands/logout';
|
||||
import { registerManCommand } from './commands/man';
|
||||
import { registerMemoryCommand } from './commands/memory';
|
||||
import { registerMessageCommand } from './commands/message';
|
||||
import { registerMigrateCommand } from './commands/migrate';
|
||||
import { registerModelCommand } from './commands/model';
|
||||
import { registerPluginCommand } from './commands/plugin';
|
||||
import { registerProviderCommand } from './commands/provider';
|
||||
@@ -28,7 +27,6 @@ import { registerSearchCommand } from './commands/search';
|
||||
import { registerSessionGroupCommand } from './commands/session-group';
|
||||
import { registerSkillCommand } from './commands/skill';
|
||||
import { registerStatusCommand } from './commands/status';
|
||||
import { registerTaskCommand } from './commands/task';
|
||||
import { registerThreadCommand } from './commands/thread';
|
||||
import { registerTopicCommand } from './commands/topic';
|
||||
import { registerUserCommand } from './commands/user';
|
||||
@@ -63,7 +61,6 @@ export function createProgram() {
|
||||
registerFileCommand(program);
|
||||
registerSkillCommand(program);
|
||||
registerSessionGroupCommand(program);
|
||||
registerTaskCommand(program);
|
||||
registerThreadCommand(program);
|
||||
registerTopicCommand(program);
|
||||
registerMessageCommand(program);
|
||||
@@ -73,7 +70,6 @@ export function createProgram() {
|
||||
registerUserCommand(program);
|
||||
registerConfigCommand(program);
|
||||
registerEvalCommand(program);
|
||||
registerMigrateCommand(program);
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,10 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { OFFICIAL_AGENT_GATEWAY_URL, OFFICIAL_SERVER_URL } from '../constants/urls';
|
||||
import { OFFICIAL_SERVER_URL } from '../constants/urls';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
export interface StoredSettings {
|
||||
agentGatewayUrl?: string;
|
||||
gatewayUrl?: string;
|
||||
serverUrl?: string;
|
||||
}
|
||||
@@ -26,24 +25,15 @@ export function resolveServerUrl(): string {
|
||||
return envServerUrl || settingsServerUrl || OFFICIAL_SERVER_URL;
|
||||
}
|
||||
|
||||
export function resolveAgentGatewayUrl(): string | undefined {
|
||||
const envUrl = normalizeUrl(process.env.AGENT_GATEWAY_URL);
|
||||
const settingsUrl = normalizeUrl(loadSettings()?.agentGatewayUrl);
|
||||
|
||||
return envUrl || settingsUrl || OFFICIAL_AGENT_GATEWAY_URL;
|
||||
}
|
||||
|
||||
export function saveSettings(settings: StoredSettings): void {
|
||||
const agentGatewayUrl = normalizeUrl(settings.agentGatewayUrl);
|
||||
const gatewayUrl = normalizeUrl(settings.gatewayUrl);
|
||||
const serverUrl = normalizeUrl(settings.serverUrl);
|
||||
const gatewayUrl = normalizeUrl(settings.gatewayUrl);
|
||||
const normalized: StoredSettings = {
|
||||
agentGatewayUrl: agentGatewayUrl === OFFICIAL_AGENT_GATEWAY_URL ? undefined : agentGatewayUrl,
|
||||
gatewayUrl,
|
||||
serverUrl: serverUrl === OFFICIAL_SERVER_URL ? undefined : serverUrl,
|
||||
};
|
||||
|
||||
if (!normalized.serverUrl && !normalized.gatewayUrl && !normalized.agentGatewayUrl) {
|
||||
if (!normalized.serverUrl && !normalized.gatewayUrl) {
|
||||
try {
|
||||
fs.unlinkSync(SETTINGS_FILE);
|
||||
} catch {}
|
||||
@@ -60,16 +50,14 @@ export function loadSettings(): StoredSettings | null {
|
||||
try {
|
||||
const data = fs.readFileSync(SETTINGS_FILE, 'utf8');
|
||||
const parsed = JSON.parse(data) as StoredSettings;
|
||||
const agentGatewayUrl = normalizeUrl(parsed.agentGatewayUrl);
|
||||
const gatewayUrl = normalizeUrl(parsed.gatewayUrl);
|
||||
const serverUrl = normalizeUrl(parsed.serverUrl);
|
||||
const normalized: StoredSettings = {
|
||||
agentGatewayUrl: agentGatewayUrl === OFFICIAL_AGENT_GATEWAY_URL ? undefined : agentGatewayUrl,
|
||||
gatewayUrl,
|
||||
serverUrl: serverUrl === OFFICIAL_SERVER_URL ? undefined : serverUrl,
|
||||
};
|
||||
|
||||
if (!normalized.serverUrl && !normalized.gatewayUrl && !normalized.agentGatewayUrl) return null;
|
||||
if (!normalized.serverUrl && !normalized.gatewayUrl) return null;
|
||||
|
||||
return normalized;
|
||||
} catch {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { streamAgentEvents, streamAgentEventsViaWebSocket } from './agentStream';
|
||||
import { streamAgentEvents } from './agentStream';
|
||||
|
||||
vi.mock('./logger', () => ({
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
heartbeat: vi.fn(),
|
||||
info: vi.fn(),
|
||||
@@ -194,391 +193,3 @@ describe('streamAgentEvents', () => {
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ── WebSocket stream tests ──────────────────────────────
|
||||
|
||||
let capturedWs: MockWebSocket | undefined;
|
||||
|
||||
class MockWebSocket {
|
||||
static OPEN = 1;
|
||||
static CONNECTING = 0;
|
||||
static CLOSED = 3;
|
||||
|
||||
readyState = MockWebSocket.CONNECTING;
|
||||
onopen: ((ev: any) => void) | null = null;
|
||||
onmessage: ((ev: any) => void) | null = null;
|
||||
onerror: ((ev: any) => void) | null = null;
|
||||
onclose: ((ev: any) => void) | null = null;
|
||||
|
||||
sent: string[] = [];
|
||||
private autoAuthSuccess = true;
|
||||
|
||||
constructor(
|
||||
public url: string,
|
||||
autoAuth = true,
|
||||
) {
|
||||
this.autoAuthSuccess = autoAuth;
|
||||
capturedWs = this; // eslint-disable-line @typescript-eslint/no-this-alias
|
||||
// Trigger onopen on next microtask (after handlers are assigned)
|
||||
queueMicrotask(() => {
|
||||
this.readyState = MockWebSocket.OPEN;
|
||||
this.onopen?.({ type: 'open' });
|
||||
});
|
||||
}
|
||||
|
||||
send(data: string) {
|
||||
this.sent.push(data);
|
||||
const msg = JSON.parse(data);
|
||||
|
||||
if (msg.type === 'auth' && this.autoAuthSuccess) {
|
||||
queueMicrotask(() => {
|
||||
this.onmessage?.({ data: JSON.stringify({ type: 'auth_success' }) });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = MockWebSocket.CLOSED;
|
||||
// Async like real WebSocket — fires after current microtask
|
||||
queueMicrotask(() => this.onclose?.({ code: 1000, reason: '' }));
|
||||
}
|
||||
|
||||
simulateMessage(msg: Record<string, unknown>) {
|
||||
this.onmessage?.({ data: JSON.stringify(msg) });
|
||||
}
|
||||
}
|
||||
|
||||
describe('streamAgentEventsViaWebSocket', () => {
|
||||
let stdoutSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
const originalWebSocket = globalThis.WebSocket;
|
||||
|
||||
beforeEach(() => {
|
||||
capturedWs = undefined;
|
||||
stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
(globalThis as any).WebSocket = MockWebSocket;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stdoutSpy.mockRestore();
|
||||
consoleSpy.mockRestore();
|
||||
globalThis.WebSocket = originalWebSocket;
|
||||
});
|
||||
|
||||
/** Wait for microtasks + short delay so WS open/auth cycle completes */
|
||||
const flush = () => new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
it('should connect, authenticate, and send resume', async () => {
|
||||
const promise = streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://gw.test.com',
|
||||
operationId: 'op-1',
|
||||
token: 'test-token',
|
||||
});
|
||||
|
||||
await flush();
|
||||
|
||||
const ws = capturedWs!;
|
||||
expect(ws.sent.map((s) => JSON.parse(s))).toEqual([
|
||||
{ token: 'test-token', type: 'auth' },
|
||||
{ lastEventId: '', type: 'resume' },
|
||||
]);
|
||||
|
||||
ws.simulateMessage({ id: '1', type: 'session_complete' });
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('should render agent_event messages using existing renderEvent', async () => {
|
||||
const promise = streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://gw.test.com',
|
||||
operationId: 'op-1',
|
||||
token: 'test-token',
|
||||
});
|
||||
|
||||
await flush();
|
||||
const ws = capturedWs!;
|
||||
|
||||
ws.simulateMessage({
|
||||
event: { data: null, operationId: 'op-1', stepIndex: 0, timestamp: 1, type: 'step_start' },
|
||||
id: '1',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: { chunkType: 'text', content: 'Hello WS!' },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 2,
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
id: '2',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: { stepCount: 1 },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 3,
|
||||
type: 'agent_runtime_end',
|
||||
},
|
||||
id: '3',
|
||||
type: 'agent_event',
|
||||
});
|
||||
|
||||
await promise;
|
||||
expect(stdoutSpy).toHaveBeenCalledWith('Hello WS!');
|
||||
});
|
||||
|
||||
it('should output JSON when json option is set', async () => {
|
||||
const promise = streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://gw.test.com',
|
||||
json: true,
|
||||
operationId: 'op-1',
|
||||
token: 'test-token',
|
||||
});
|
||||
|
||||
await flush();
|
||||
const ws = capturedWs!;
|
||||
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: null,
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 1,
|
||||
type: 'agent_runtime_init',
|
||||
},
|
||||
id: '1',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: { stepCount: 1 },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 2,
|
||||
type: 'agent_runtime_end',
|
||||
},
|
||||
id: '2',
|
||||
type: 'agent_event',
|
||||
});
|
||||
|
||||
await promise;
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('"agent_runtime_init"'));
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('"agent_runtime_end"'));
|
||||
});
|
||||
|
||||
it('should reject on auth failure', async () => {
|
||||
// Override mock to return auth_failed instead of auth_success
|
||||
(globalThis as any).WebSocket = class extends MockWebSocket {
|
||||
constructor(url: string) {
|
||||
super(url, false); // disable auto auth_success
|
||||
capturedWs = this; // eslint-disable-line @typescript-eslint/no-this-alias
|
||||
}
|
||||
|
||||
override send(data: string) {
|
||||
this.sent.push(data);
|
||||
const msg = JSON.parse(data);
|
||||
if (msg.type === 'auth') {
|
||||
queueMicrotask(() => {
|
||||
this.onmessage?.({
|
||||
data: JSON.stringify({ reason: 'invalid token', type: 'auth_failed' }),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await expect(
|
||||
streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://gw.test.com',
|
||||
operationId: 'op-1',
|
||||
token: 'bad-token',
|
||||
}),
|
||||
).rejects.toThrow('Gateway auth failed');
|
||||
});
|
||||
|
||||
it('should resolve on session_complete', async () => {
|
||||
const promise = streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://gw.test.com',
|
||||
operationId: 'op-1',
|
||||
token: 'test-token',
|
||||
});
|
||||
|
||||
await flush();
|
||||
capturedWs!.simulateMessage({ id: '1', summary: 'All done', type: 'session_complete' });
|
||||
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should ignore heartbeat_ack messages', async () => {
|
||||
const promise = streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://gw.test.com',
|
||||
operationId: 'op-1',
|
||||
token: 'test-token',
|
||||
});
|
||||
|
||||
await flush();
|
||||
const ws = capturedWs!;
|
||||
|
||||
ws.simulateMessage({ type: 'heartbeat_ack' });
|
||||
expect(stdoutSpy).not.toHaveBeenCalled();
|
||||
|
||||
ws.simulateMessage({ id: '1', type: 'session_complete' });
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('should construct correct WebSocket URL from HTTPS gateway URL', async () => {
|
||||
const promise = streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://agent-gateway.lobehub.com',
|
||||
operationId: 'op-123',
|
||||
token: 'tok',
|
||||
});
|
||||
|
||||
await flush();
|
||||
expect(capturedWs!.url).toBe('wss://agent-gateway.lobehub.com/ws?operationId=op-123');
|
||||
|
||||
capturedWs!.simulateMessage({ id: '1', type: 'session_complete' });
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('should render a multi-step agent run with tool calls', async () => {
|
||||
const promise = streamAgentEventsViaWebSocket({
|
||||
gatewayUrl: 'https://gw.test.com',
|
||||
operationId: 'op-1',
|
||||
token: 'tok',
|
||||
verbose: true,
|
||||
});
|
||||
|
||||
await flush();
|
||||
const ws = capturedWs!;
|
||||
const { log } = await import('./logger');
|
||||
|
||||
// Step 1: thinking + text + tool call
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: null,
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 1,
|
||||
type: 'agent_runtime_init',
|
||||
},
|
||||
id: '1',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: { data: null, operationId: 'op-1', stepIndex: 0, timestamp: 2, type: 'step_start' },
|
||||
id: '2',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: { chunkType: 'reasoning', reasoning: 'Let me search...' },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 3,
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
id: '3',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: { chunkType: 'text', content: 'Searching for news.' },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 4,
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
id: '4',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: { toolCalling: { apiName: 'search', id: 'tc-1' } },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 5,
|
||||
type: 'tool_start',
|
||||
},
|
||||
id: '5',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: { data: null, operationId: 'op-1', stepIndex: 0, timestamp: 6, type: 'stream_end' },
|
||||
id: '6',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: { stepIndex: 0 },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 0,
|
||||
timestamp: 7,
|
||||
type: 'step_complete',
|
||||
},
|
||||
id: '7',
|
||||
type: 'agent_event',
|
||||
});
|
||||
|
||||
// Step 2: tool result + final text
|
||||
ws.simulateMessage({
|
||||
event: { data: null, operationId: 'op-1', stepIndex: 1, timestamp: 8, type: 'step_start' },
|
||||
id: '8',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: {
|
||||
isSuccess: true,
|
||||
payload: { toolCalling: { id: 'tc-1' } },
|
||||
result: { content: 'Results...' },
|
||||
},
|
||||
operationId: 'op-1',
|
||||
stepIndex: 1,
|
||||
timestamp: 9,
|
||||
type: 'tool_end',
|
||||
},
|
||||
id: '9',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: { chunkType: 'text', content: 'Here are the results.' },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 1,
|
||||
timestamp: 10,
|
||||
type: 'stream_chunk',
|
||||
},
|
||||
id: '10',
|
||||
type: 'agent_event',
|
||||
});
|
||||
ws.simulateMessage({
|
||||
event: {
|
||||
data: { cost: { total: 0.05 }, stepCount: 2, usage: { total_tokens: 500 } },
|
||||
operationId: 'op-1',
|
||||
stepIndex: 1,
|
||||
timestamp: 11,
|
||||
type: 'agent_runtime_end',
|
||||
},
|
||||
id: '11',
|
||||
type: 'agent_event',
|
||||
});
|
||||
|
||||
await promise;
|
||||
|
||||
// Verify reasoning was rendered (dim)
|
||||
expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('Let me search...'));
|
||||
// Verify text chunks
|
||||
expect(stdoutSpy).toHaveBeenCalledWith('Searching for news.');
|
||||
expect(stdoutSpy).toHaveBeenCalledWith('Here are the results.');
|
||||
// Verify tool call was logged
|
||||
expect(log.toolCall).toHaveBeenCalledWith('search', 'tc-1', undefined);
|
||||
// Verify tool result was logged
|
||||
expect(log.toolResult).toHaveBeenCalled();
|
||||
// Verify finish line
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Agent finished'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import pc from 'picocolors';
|
||||
import urlJoin from 'url-join';
|
||||
|
||||
import { log } from './logger';
|
||||
|
||||
@@ -17,12 +16,6 @@ interface StreamOptions {
|
||||
verbose?: boolean;
|
||||
}
|
||||
|
||||
interface WebSocketStreamOptions extends StreamOptions {
|
||||
gatewayUrl: string;
|
||||
operationId: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the agent SSE stream and render events to the terminal.
|
||||
* Resolves when the stream ends (agent_runtime_end or connection close).
|
||||
@@ -159,126 +152,6 @@ export function replayAgentEvents(events: AgentStreamEvent[], options: StreamOpt
|
||||
}
|
||||
}
|
||||
|
||||
const HEARTBEAT_INTERVAL = 30_000;
|
||||
|
||||
/**
|
||||
* Connect to the Agent Gateway via WebSocket and render events to the terminal.
|
||||
* Resolves when the session completes or the connection closes.
|
||||
*/
|
||||
export async function streamAgentEventsViaWebSocket(
|
||||
options: WebSocketStreamOptions,
|
||||
): Promise<void> {
|
||||
const { gatewayUrl, operationId, token, ...streamOpts } = options;
|
||||
const wsUrl = urlJoin(
|
||||
gatewayUrl.replace(/^http/, 'ws'),
|
||||
`/ws?operationId=${encodeURIComponent(operationId)}`,
|
||||
);
|
||||
|
||||
log.debug(`Connecting to gateway: ${wsUrl}`);
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
const jsonEvents: AgentStreamEvent[] = [];
|
||||
const ctx = createRenderContext();
|
||||
let lastEventId = '';
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let jsonPrinted = false;
|
||||
|
||||
const cleanup = () => {
|
||||
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
||||
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
||||
ws.close();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ token, type: 'auth' }));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data as string);
|
||||
|
||||
if (msg.type === 'auth_success') {
|
||||
log.debug('Gateway authenticated');
|
||||
// Request all buffered events (covers events pushed before WS connected)
|
||||
ws.send(JSON.stringify({ lastEventId: '', type: 'resume' }));
|
||||
heartbeatTimer = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'heartbeat' }));
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'auth_failed') {
|
||||
cleanup();
|
||||
reject(new Error(`Gateway auth failed: ${msg.reason}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'heartbeat_ack') return;
|
||||
|
||||
if (msg.type === 'agent_event') {
|
||||
const agentEvent: AgentStreamEvent = msg.event;
|
||||
if (msg.id) lastEventId = msg.id;
|
||||
|
||||
if (streamOpts.json) {
|
||||
jsonEvents.push(agentEvent);
|
||||
} else {
|
||||
renderEvent(agentEvent, ctx, streamOpts);
|
||||
}
|
||||
|
||||
if (agentEvent.type === 'agent_runtime_end') {
|
||||
if (streamOpts.json && !jsonPrinted) {
|
||||
jsonPrinted = true;
|
||||
console.log(JSON.stringify(jsonEvents, null, 2));
|
||||
} else if (!streamOpts.json) {
|
||||
renderEnd(agentEvent);
|
||||
}
|
||||
cleanup();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (agentEvent.type === 'error') {
|
||||
if (streamOpts.json && !jsonPrinted) {
|
||||
jsonPrinted = true;
|
||||
console.log(JSON.stringify(jsonEvents, null, 2));
|
||||
}
|
||||
log.error(
|
||||
`Agent error: ${agentEvent.data?.message || agentEvent.data?.error || 'Unknown error'}`,
|
||||
);
|
||||
cleanup();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.type === 'session_complete') {
|
||||
if (streamOpts.json && jsonEvents.length > 0 && !jsonPrinted) {
|
||||
jsonPrinted = true;
|
||||
console.log(JSON.stringify(jsonEvents, null, 2));
|
||||
}
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (err) => {
|
||||
cleanup();
|
||||
reject(err);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
||||
if (streamOpts.json && jsonEvents.length > 0 && !jsonPrinted) {
|
||||
jsonPrinted = true;
|
||||
console.log(JSON.stringify(jsonEvents, null, 2));
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ── Render helpers ──────────────────────────────────────
|
||||
|
||||
interface RenderContext {
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { readStatus } from '../daemon/manager';
|
||||
|
||||
export function resolveLocalDeviceId(): string | undefined {
|
||||
return readStatus()?.deviceId;
|
||||
}
|
||||
@@ -387,102 +387,6 @@ export function printCalendarHeatmap(
|
||||
console.log();
|
||||
}
|
||||
|
||||
// ── Kanban Board ─────────────────────────────────────
|
||||
|
||||
export interface KanbanColumn {
|
||||
color?: (s: string) => string;
|
||||
items: KanbanCard[];
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface KanbanCard {
|
||||
badge?: string;
|
||||
meta?: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a kanban board with side-by-side columns.
|
||||
* Adapts column width to terminal width automatically.
|
||||
*/
|
||||
export function printKanban(columns: KanbanColumn[]) {
|
||||
// Filter out empty columns
|
||||
const cols = columns.filter((c) => c.items.length > 0);
|
||||
if (cols.length === 0) return;
|
||||
|
||||
const termWidth = process.stdout.columns || 100;
|
||||
// Each column gets equal width, with 1-char gap between
|
||||
const colWidth = Math.max(20, Math.floor((termWidth - (cols.length - 1)) / cols.length));
|
||||
const innerWidth = colWidth - 4; // 2 chars border + 2 padding
|
||||
|
||||
const maxRows = Math.max(...cols.map((c) => c.items.length));
|
||||
|
||||
// ── Header ──
|
||||
const topBorder = cols
|
||||
.map((c) => {
|
||||
const titleStr = ` ${c.title} (${c.items.length}) `;
|
||||
const color = c.color || pc.white;
|
||||
const remaining = colWidth - 2 - displayWidth(titleStr);
|
||||
const left = Math.floor(remaining / 2);
|
||||
const right = remaining - left;
|
||||
return color(
|
||||
'┌' + '─'.repeat(Math.max(0, left)) + titleStr + '─'.repeat(Math.max(0, right)) + '┐',
|
||||
);
|
||||
})
|
||||
.join(' ');
|
||||
console.log(topBorder);
|
||||
|
||||
// ── Rows ──
|
||||
for (let row = 0; row < maxRows; row++) {
|
||||
const line = cols
|
||||
.map((c) => {
|
||||
const color = c.color || pc.white;
|
||||
const item = c.items[row];
|
||||
if (!item) {
|
||||
return color('│') + ' '.repeat(colWidth - 2) + color('│');
|
||||
}
|
||||
|
||||
const badge = item.badge ? item.badge + ' ' : '';
|
||||
const badgeWidth = displayWidth(badge);
|
||||
const titleMaxWidth = innerWidth - badgeWidth;
|
||||
const title = truncate(item.title, titleMaxWidth);
|
||||
const titleWidth = displayWidth(title);
|
||||
const pad = ' '.repeat(Math.max(0, colWidth - 2 - badgeWidth - titleWidth - 2));
|
||||
return color('│') + ' ' + badge + title + pad + ' ' + color('│');
|
||||
})
|
||||
.join(' ');
|
||||
console.log(line);
|
||||
|
||||
// Print meta line if any card in this row has meta
|
||||
const hasMeta = cols.some((c) => c.items[row]?.meta);
|
||||
if (hasMeta) {
|
||||
const metaLine = cols
|
||||
.map((c) => {
|
||||
const color = c.color || pc.white;
|
||||
const item = c.items[row];
|
||||
if (!item?.meta) {
|
||||
return color('│') + ' '.repeat(colWidth - 2) + color('│');
|
||||
}
|
||||
const meta = truncate(item.meta, innerWidth);
|
||||
const metaWidth = displayWidth(meta);
|
||||
const pad = ' '.repeat(Math.max(0, colWidth - 2 - metaWidth - 2));
|
||||
return color('│') + ' ' + pc.dim(meta) + pad + ' ' + color('│');
|
||||
})
|
||||
.join(' ');
|
||||
console.log(metaLine);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bottom border ──
|
||||
const bottomBorder = cols
|
||||
.map((c) => {
|
||||
const color = c.color || pc.white;
|
||||
return color('└' + '─'.repeat(colWidth - 2) + '┘');
|
||||
})
|
||||
.join(' ');
|
||||
console.log(bottomBorder);
|
||||
}
|
||||
|
||||
export function confirm(message: string): Promise<boolean> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
||||
return new Promise((resolve) => {
|
||||
|
||||
@@ -68,9 +68,7 @@
|
||||
if (resolvedTheme === 'dark' || resolvedTheme === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', resolvedTheme);
|
||||
}
|
||||
// Check URL query parameter for locale (set by Electron main process from stored settings)
|
||||
var urlParams = new URLSearchParams(window.location.search);
|
||||
var locale = urlParams.get('lng') || navigator.language || 'en-US';
|
||||
var locale = navigator.language || 'en-US';
|
||||
document.documentElement.lang = locale;
|
||||
var rtl = ['ar', 'arc', 'dv', 'fa', 'ha', 'he', 'khw', 'ks', 'ku', 'ps', 'ur', 'yi'];
|
||||
document.documentElement.dir =
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
"cookie": "^1.1.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"diff": "^8.0.4",
|
||||
"electron": "41.1.0",
|
||||
"electron": "41.0.2",
|
||||
"electron-builder": "^26.8.1",
|
||||
"electron-devtools-installer": "4.0.0",
|
||||
"electron-is": "^3.0.0",
|
||||
|
||||
@@ -5,7 +5,7 @@ import path from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const VERSION = '0.24.0';
|
||||
const VERSION = '0.20.1';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const binDir = path.join(__dirname, '..', 'resources', 'bin');
|
||||
|
||||
@@ -9,7 +9,7 @@ import { tagWhite, writeJSON } from './utils';
|
||||
export const genDefaultLocale = () => {
|
||||
consola.info(`默认语言为 ${i18nConfig.entryLocale}...`);
|
||||
|
||||
// Ensure entry locale directory exists
|
||||
// 确保入口语言目录存在
|
||||
const entryLocaleDir = localeDir(i18nConfig.entryLocale);
|
||||
if (!existsSync(entryLocaleDir)) {
|
||||
mkdirSync(entryLocaleDir, { recursive: true });
|
||||
@@ -23,7 +23,7 @@ export const genDefaultLocale = () => {
|
||||
for (const [ns, value] of data) {
|
||||
const filepath = entryLocaleJsonFilepath(`${ns}.json`);
|
||||
|
||||
// Ensure directory exists
|
||||
// 确保目录存在
|
||||
const dir = dirname(filepath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
@@ -5,7 +5,7 @@ import { genDefaultLocale } from './genDefaultLocale';
|
||||
import { genDiff } from './genDiff';
|
||||
import { split } from './utils';
|
||||
|
||||
// Ensure all locale directories exist
|
||||
// 确保所有语言目录存在
|
||||
const ensureLocalesDirs = () => {
|
||||
[i18nConfig.entryLocale, ...i18nConfig.outputLocales].forEach((locale) => {
|
||||
const dir = localeDir(locale);
|
||||
@@ -15,20 +15,20 @@ const ensureLocalesDirs = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// Run workflow
|
||||
// 运行工作流
|
||||
const run = async () => {
|
||||
// Ensure directories exist
|
||||
// 确保目录存在
|
||||
ensureLocalesDirs();
|
||||
|
||||
// Diff analysis
|
||||
// 差异分析
|
||||
split('差异分析');
|
||||
genDiff();
|
||||
|
||||
// Generate default locale files
|
||||
// 生成默认语言文件
|
||||
split('生成默认语言文件');
|
||||
genDefaultLocale();
|
||||
|
||||
// Generate i18n files
|
||||
// 生成国际化文件
|
||||
split('生成国际化文件');
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { UpdateChannel, UpdaterState } from '@lobechat/electron-client-ipc';
|
||||
|
||||
import { UPDATE_CHANNEL } from '@/modules/updater/configs';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
@@ -47,11 +46,11 @@ export default class UpdaterCtr extends ControllerModule {
|
||||
|
||||
@IpcMethod()
|
||||
async getUpdateChannel(): Promise<UpdateChannel> {
|
||||
return this.app.storeManager.get('updateChannel') ?? UPDATE_CHANNEL;
|
||||
return this.app.storeManager.get('updateChannel') ?? 'stable';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the build-time channel (stable, canary, beta, or legacy nightly).
|
||||
* Get the build-time channel (stable, nightly, canary, beta).
|
||||
* Used for display in About page to distinguish pre-release builds.
|
||||
*/
|
||||
@IpcMethod()
|
||||
@@ -62,12 +61,11 @@ export default class UpdaterCtr extends ControllerModule {
|
||||
|
||||
@IpcMethod()
|
||||
async setUpdateChannel(channel: UpdateChannel): Promise<void> {
|
||||
const validChannels = new Set<UpdateChannel>(['stable', 'canary']);
|
||||
const validChannels = new Set(['stable', 'nightly', 'canary']);
|
||||
if (!validChannels.has(channel)) {
|
||||
logger.warn(`Invalid update channel: ${channel}, ignoring`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Set update channel requested: ${channel}`);
|
||||
this.app.storeManager.set('updateChannel', channel);
|
||||
this.app.updaterManager.switchChannel(channel);
|
||||
|
||||
@@ -8,14 +8,9 @@ import UpdaterCtr from '../UpdaterCtr';
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/modules/updater/configs', () => ({
|
||||
UPDATE_CHANNEL: 'stable',
|
||||
}));
|
||||
|
||||
const { ipcMainHandleMock } = vi.hoisted(() => ({
|
||||
ipcMainHandleMock: vi.fn(),
|
||||
}));
|
||||
@@ -31,23 +26,13 @@ const mockCheckForUpdates = vi.fn();
|
||||
const mockDownloadUpdate = vi.fn();
|
||||
const mockInstallNow = vi.fn();
|
||||
const mockInstallLater = vi.fn();
|
||||
const mockGetUpdaterState = vi.fn();
|
||||
const mockSwitchChannel = vi.fn();
|
||||
const mockStoreGet = vi.fn();
|
||||
const mockStoreSet = vi.fn();
|
||||
|
||||
const mockApp = {
|
||||
storeManager: {
|
||||
get: mockStoreGet,
|
||||
set: mockStoreSet,
|
||||
},
|
||||
updaterManager: {
|
||||
checkForUpdates: mockCheckForUpdates,
|
||||
downloadUpdate: mockDownloadUpdate,
|
||||
getUpdaterState: mockGetUpdaterState,
|
||||
installNow: mockInstallNow,
|
||||
installLater: mockInstallLater,
|
||||
switchChannel: mockSwitchChannel,
|
||||
},
|
||||
} as unknown as App;
|
||||
|
||||
@@ -57,8 +42,6 @@ describe('UpdaterCtr', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ipcMainHandleMock.mockClear();
|
||||
mockStoreGet.mockReset();
|
||||
mockStoreSet.mockReset();
|
||||
updaterCtr = new UpdaterCtr(mockApp);
|
||||
});
|
||||
|
||||
@@ -90,36 +73,6 @@ describe('UpdaterCtr', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('update channel', () => {
|
||||
it('should return stored update channel', async () => {
|
||||
mockStoreGet.mockReturnValueOnce('canary');
|
||||
|
||||
await expect(updaterCtr.getUpdateChannel()).resolves.toBe('canary');
|
||||
});
|
||||
|
||||
it('should return default update channel when store is empty', async () => {
|
||||
mockStoreGet.mockReturnValueOnce(undefined);
|
||||
|
||||
await expect(updaterCtr.getUpdateChannel()).resolves.toBe('stable');
|
||||
});
|
||||
|
||||
it('should keep canary input unchanged', async () => {
|
||||
await updaterCtr.setUpdateChannel('canary');
|
||||
|
||||
expect(mockStoreSet).toHaveBeenCalledWith('updateChannel', 'canary');
|
||||
expect(mockSwitchChannel).toHaveBeenCalledWith('canary');
|
||||
});
|
||||
|
||||
it('should ignore invalid legacy input', async () => {
|
||||
await updaterCtr.setUpdateChannel(
|
||||
'nightly' as unknown as Parameters<UpdaterCtr['setUpdateChannel']>[0],
|
||||
);
|
||||
|
||||
expect(mockStoreSet).not.toHaveBeenCalled();
|
||||
expect(mockSwitchChannel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// 测试错误处理
|
||||
describe('error handling', () => {
|
||||
it('should handle errors when checking for updates', async () => {
|
||||
|
||||
@@ -160,13 +160,14 @@ export class BackendProxyProtocolManager {
|
||||
responseHeaders.set('Access-Control-Allow-Headers', '*');
|
||||
responseHeaders.set('X-Src-Url', rewrittenUrl);
|
||||
|
||||
// Re-auth prompt: rely on X-Auth-Required (set by tRPC responseMeta for UNAUTHORIZED).
|
||||
// Batched tRPC responses can use HTTP 207 when calls mix success (200) and UNAUTHORIZED (401);
|
||||
// checking only status === 401 misses that case and the login modal never opens.
|
||||
// Other failures keep 401 without this header (e.g., invalid API keys) and must not notify here.
|
||||
const authRequired = upstreamResponse.headers.get(AUTH_REQUIRED_HEADER) === 'true';
|
||||
if (authRequired) {
|
||||
this.notifyAuthorizationRequired();
|
||||
// Handle 401 Unauthorized: only notify authorization required for real auth failures
|
||||
// The server sets X-Auth-Required header for real authentication failures (e.g., token expired)
|
||||
// Other 401 errors (e.g., invalid API keys) should not trigger re-authentication
|
||||
if (upstreamResponse.status === 401) {
|
||||
const authRequired = upstreamResponse.headers.get(AUTH_REQUIRED_HEADER) === 'true';
|
||||
if (authRequired) {
|
||||
this.notifyAuthorizationRequired();
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(upstreamResponse.body, {
|
||||
|
||||
@@ -6,7 +6,6 @@ import { makeSureDirExist } from '@/utils/file-system';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import type { App } from '../App';
|
||||
import { runStoreMigrations } from './migration';
|
||||
|
||||
// Create logger
|
||||
const logger = createLogger('core:StoreManager');
|
||||
@@ -28,7 +27,6 @@ export class StoreManager {
|
||||
defaults: STORE_DEFAULTS,
|
||||
name: STORE_NAME,
|
||||
});
|
||||
runStoreMigrations(this.store);
|
||||
logger.info('StoreManager initialized with store name:', STORE_NAME);
|
||||
|
||||
const storagePath = this.store.get('storagePath');
|
||||
|
||||
@@ -139,7 +139,9 @@ export class UpdaterManager {
|
||||
public switchChannel = (channel: UpdateChannel) => {
|
||||
logger.info(`Switching update channel: ${this.currentChannel} -> ${channel}`);
|
||||
|
||||
const isDowngrade = this.currentChannel === 'canary' && channel === 'stable';
|
||||
const isDowngrade =
|
||||
(this.currentChannel === 'canary' && channel !== 'canary') ||
|
||||
(this.currentChannel === 'nightly' && channel === 'stable');
|
||||
|
||||
this.currentChannel = channel;
|
||||
autoUpdater.allowDowngrade = isDowngrade;
|
||||
@@ -364,7 +366,7 @@ export class UpdaterManager {
|
||||
|
||||
/**
|
||||
* Strip trailing channel path from URL so we can re-append the correct channel.
|
||||
* Handles both base URL (https://cdn.example.com) and legacy URLs with channel suffixes.
|
||||
* Handles both base URL (https://cdn.example.com) and legacy URL with channel (https://cdn.example.com/stable)
|
||||
*/
|
||||
private getBaseUpdateUrl(): string | undefined {
|
||||
if (!UPDATE_SERVER_URL) return undefined;
|
||||
|
||||
+1
-50
@@ -1,6 +1,4 @@
|
||||
import { AUTH_REQUIRED_HEADER } from '@lobechat/desktop-bridge';
|
||||
import { BrowserWindow } from 'electron';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { BackendProxyProtocolManager } from '../BackendProxyProtocolManager';
|
||||
|
||||
@@ -39,22 +37,12 @@ vi.mock('@/utils/logger', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: {
|
||||
getAllWindows: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('BackendProxyProtocolManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
protocolHandlerRef.current = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should rewrite url to remote base and inject Oidc-Auth token', async () => {
|
||||
const manager = new BackendProxyProtocolManager();
|
||||
const session = { protocol: mockProtocol } as any;
|
||||
@@ -221,41 +209,4 @@ describe('BackendProxyProtocolManager', () => {
|
||||
} as any),
|
||||
).rejects.toThrow('network down');
|
||||
});
|
||||
|
||||
it('should broadcast authorizationRequired when X-Auth-Required is set on HTTP 207 (batched tRPC)', async () => {
|
||||
vi.useFakeTimers();
|
||||
const send = vi.fn();
|
||||
vi.mocked(BrowserWindow.getAllWindows).mockReturnValue([
|
||||
{ isDestroyed: () => false, webContents: { send } },
|
||||
] as any);
|
||||
|
||||
const manager = new BackendProxyProtocolManager();
|
||||
const session = { protocol: mockProtocol } as any;
|
||||
|
||||
const headers = new Headers({
|
||||
[AUTH_REQUIRED_HEADER]: 'true',
|
||||
'Content-Type': 'application/json',
|
||||
});
|
||||
const fetchMock = vi.fn<FetchMock>(
|
||||
async () => new Response('[]', { headers, status: 207, statusText: 'Multi-Status' }),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock as any);
|
||||
|
||||
manager.registerWithRemoteBaseUrl(session, {
|
||||
getAccessToken: async () => null,
|
||||
getRemoteBaseUrl: async () => 'https://remote.example.com',
|
||||
scheme: 'lobe-backend',
|
||||
});
|
||||
|
||||
const handler = protocolHandlerRef.current;
|
||||
await handler({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url: 'lobe-backend://app/trpc/lambda/batch?batch=1',
|
||||
} as any);
|
||||
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(send).toHaveBeenCalledWith('authorizationRequired');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App as AppCore } from '../../App';
|
||||
import { APPLIED_STORE_MIGRATIONS_KEY, getStoreMigrations, runStoreMigrations } from '../migration';
|
||||
import { StoreManager } from '../StoreManager';
|
||||
|
||||
// Use vi.hoisted to define mocks before hoisting
|
||||
@@ -47,11 +46,6 @@ vi.mock('@/utils/file-system', () => ({
|
||||
makeSureDirExist: mockMakeSureDirExist,
|
||||
}));
|
||||
|
||||
vi.mock('@/modules/updater/configs', () => ({
|
||||
coerceStoredUpdateChannel: (channel?: string | null) =>
|
||||
channel === 'canary' ? 'canary' : 'stable',
|
||||
}));
|
||||
|
||||
// Mock store constants
|
||||
vi.mock('@/const/store', () => ({
|
||||
STORE_DEFAULTS: {
|
||||
@@ -83,52 +77,18 @@ describe('StoreManager', () => {
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should create electron-store with correct options', () => {
|
||||
expect(MockStore).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
defaults: {
|
||||
locale: 'auto',
|
||||
storagePath: '/default/storage/path',
|
||||
},
|
||||
name: 'test-config',
|
||||
}),
|
||||
);
|
||||
expect(MockStore).toHaveBeenCalledWith({
|
||||
defaults: {
|
||||
locale: 'auto',
|
||||
storagePath: '/default/storage/path',
|
||||
},
|
||||
name: 'test-config',
|
||||
});
|
||||
});
|
||||
|
||||
it('should ensure storage directory exists', () => {
|
||||
expect(mockMakeSureDirExist).toHaveBeenCalledWith('/mock/storage/path');
|
||||
});
|
||||
|
||||
it('should migrate legacy nightly channel and record applied migration ids', () => {
|
||||
const store = {
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === APPLIED_STORE_MIGRATIONS_KEY) return undefined;
|
||||
if (key === 'updateChannel') return 'nightly';
|
||||
}),
|
||||
set: vi.fn(),
|
||||
} as any;
|
||||
|
||||
runStoreMigrations(store);
|
||||
|
||||
expect(store.set).toHaveBeenCalledWith('updateChannel', 'stable');
|
||||
expect(store.set).toHaveBeenCalledWith(APPLIED_STORE_MIGRATIONS_KEY, [
|
||||
getStoreMigrations()[0].id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should skip already applied migrations', () => {
|
||||
const appliedMigrationId = getStoreMigrations()[0].id;
|
||||
const store = {
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === APPLIED_STORE_MIGRATIONS_KEY) return [appliedMigrationId];
|
||||
if (key === 'updateChannel') return 'nightly';
|
||||
}),
|
||||
set: vi.fn(),
|
||||
} as any;
|
||||
|
||||
runStoreMigrations(store);
|
||||
|
||||
expect(store.set).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { coerceStoredUpdateChannel } from '@/modules/updater/configs';
|
||||
|
||||
import { defineMigration } from './defineMigration';
|
||||
|
||||
export default defineMigration({
|
||||
id: '001-normalize-update-channel',
|
||||
up: (store) => {
|
||||
const storedChannel = store.get('updateChannel');
|
||||
const normalizedChannel = coerceStoredUpdateChannel(storedChannel);
|
||||
|
||||
if (storedChannel && storedChannel !== normalizedChannel) {
|
||||
store.set('updateChannel', normalizedChannel);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
import type Store from 'electron-store';
|
||||
|
||||
import type { ElectronMainStore } from '@/types/store';
|
||||
|
||||
export interface StoreMigration {
|
||||
id: string;
|
||||
up: (store: Store<ElectronMainStore>) => void;
|
||||
}
|
||||
|
||||
export const defineMigration = (migration: StoreMigration): StoreMigration => migration;
|
||||
@@ -1,55 +0,0 @@
|
||||
import type Store from 'electron-store';
|
||||
|
||||
import type { ElectronMainStore } from '@/types/store';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import normalizeUpdateChannelMigration from './001-normalize-update-channel';
|
||||
import type { StoreMigration } from './defineMigration';
|
||||
|
||||
export const APPLIED_STORE_MIGRATIONS_KEY = 'lobeDesktopAppliedStoreMigrations';
|
||||
|
||||
const logger = createLogger('core:storeMigration');
|
||||
|
||||
const migrations: StoreMigration[] = [normalizeUpdateChannelMigration];
|
||||
|
||||
const getAppliedMigrationIds = (store: Store<ElectronMainStore>): string[] => {
|
||||
return (
|
||||
(store.get(APPLIED_STORE_MIGRATIONS_KEY as keyof ElectronMainStore) as string[] | undefined) ??
|
||||
[]
|
||||
);
|
||||
};
|
||||
|
||||
const setAppliedMigrationIds = (store: Store<ElectronMainStore>, ids: string[]) => {
|
||||
store.set(
|
||||
APPLIED_STORE_MIGRATIONS_KEY as keyof ElectronMainStore,
|
||||
ids as ElectronMainStore[keyof ElectronMainStore],
|
||||
);
|
||||
};
|
||||
|
||||
export const getStoreMigrations = () => migrations;
|
||||
|
||||
export const runStoreMigrations = (store: Store<ElectronMainStore>) => {
|
||||
logger.info('Store migrations started');
|
||||
|
||||
const appliedMigrationIds = new Set(getAppliedMigrationIds(store));
|
||||
let hasNewMigrationApplied = false;
|
||||
|
||||
for (const migration of migrations) {
|
||||
if (appliedMigrationIds.has(migration.id)) continue;
|
||||
|
||||
logger.info(`Running store migration: ${migration.id}`);
|
||||
migration.up(store);
|
||||
appliedMigrationIds.add(migration.id);
|
||||
hasNewMigrationApplied = true;
|
||||
}
|
||||
|
||||
if (hasNewMigrationApplied) {
|
||||
setAppliedMigrationIds(store, [...appliedMigrationIds]);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
hasNewMigrationApplied
|
||||
? 'Store migrations finished (updates applied)'
|
||||
: 'Store migrations finished (nothing pending)',
|
||||
);
|
||||
};
|
||||
@@ -5,13 +5,14 @@ import { getDesktopEnv } from '@/env';
|
||||
|
||||
// Build-time default channel, can be overridden at runtime via store
|
||||
const rawChannel = getDesktopEnv().UPDATE_CHANNEL || 'stable';
|
||||
export const coerceStoredUpdateChannel = (channel?: string | null): UpdateChannel =>
|
||||
channel === 'canary' ? 'canary' : 'stable';
|
||||
|
||||
/** Raw build channel for display (stable, canary, beta, or legacy nightly). */
|
||||
const VALID_CHANNELS = new Set<UpdateChannel>(['stable', 'nightly', 'canary']);
|
||||
/** Raw build channel for display (stable, nightly, canary, beta) */
|
||||
export const BUILD_CHANNEL: string = rawChannel;
|
||||
export const UPDATE_CHANNEL: UpdateChannel =
|
||||
rawChannel === 'canary' || rawChannel === 'beta' ? 'canary' : 'stable';
|
||||
export const UPDATE_CHANNEL: UpdateChannel = VALID_CHANNELS.has(rawChannel as UpdateChannel)
|
||||
? (rawChannel as UpdateChannel)
|
||||
: rawChannel === 'beta'
|
||||
? 'nightly'
|
||||
: 'stable';
|
||||
|
||||
// S3 base URL for all channels
|
||||
// e.g., https://releases.lobehub.com
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('setupElectronApi', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should expose lobeEnv with darwinMajorVersion, isMacTahoe, platform and version info', () => {
|
||||
it('should expose lobeEnv with darwinMajorVersion, isMacTahoe and platform', () => {
|
||||
setupElectronApi();
|
||||
|
||||
const call = mockContextBridgeExposeInMainWorld.mock.calls.find((i) => i[0] === 'lobeEnv');
|
||||
@@ -69,20 +69,6 @@ describe('setupElectronApi', () => {
|
||||
|
||||
expect(Object.prototype.hasOwnProperty.call(exposedEnv, 'platform')).toBe(true);
|
||||
expect(['darwin', 'linux', 'win32'].includes(exposedEnv.platform)).toBe(true);
|
||||
|
||||
// electronVersion and chromeVersion may be undefined in Node.js test env
|
||||
expect(Object.prototype.hasOwnProperty.call(exposedEnv, 'electronVersion')).toBe(true);
|
||||
expect(
|
||||
exposedEnv.electronVersion === undefined || typeof exposedEnv.electronVersion === 'string',
|
||||
).toBe(true);
|
||||
|
||||
expect(Object.prototype.hasOwnProperty.call(exposedEnv, 'chromeVersion')).toBe(true);
|
||||
expect(
|
||||
exposedEnv.chromeVersion === undefined || typeof exposedEnv.chromeVersion === 'string',
|
||||
).toBe(true);
|
||||
|
||||
expect(Object.prototype.hasOwnProperty.call(exposedEnv, 'nodeVersion')).toBe(true);
|
||||
expect(typeof exposedEnv.nodeVersion).toBe('string');
|
||||
});
|
||||
|
||||
it('should expose both APIs in correct order', () => {
|
||||
|
||||
@@ -25,11 +25,8 @@ export const setupElectronApi = () => {
|
||||
const darwinMajorVersion = Number(osInfo.split('.')[0]);
|
||||
|
||||
contextBridge.exposeInMainWorld('lobeEnv', {
|
||||
chromeVersion: process.versions.chrome,
|
||||
darwinMajorVersion,
|
||||
electronVersion: process.versions.electron,
|
||||
isMacTahoe: process.platform === 'darwin' && darwinMajorVersion >= 25,
|
||||
nodeVersion: process.versions.node,
|
||||
platform: process.platform,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -179,7 +179,7 @@ This system is expected to be gradually deprecated
|
||||
in favor of the MCP tool system.
|
||||
|
||||
- Frontend calls them via the
|
||||
`invokeBuiltinTool` method
|
||||
`invokeDefaultTypePlugin` method
|
||||
- Retrieves plugin settings and manifest,
|
||||
creates authentication headers,
|
||||
and sends requests to the plugin gateway
|
||||
|
||||
@@ -159,7 +159,7 @@ while (state.status !== 'done' && state.status !== 'error') {
|
||||
**Plugin 工具**:传统插件体系,通过 API 网关调用。
|
||||
该体系预期将逐步废弃,由 MCP 工具体系替代。
|
||||
|
||||
- 前端通过 `invokeBuiltinTool` 方法调用
|
||||
- 前端通过 `invokeDefaultTypePlugin` 方法调用
|
||||
- 获取插件设置和清单、创建认证请求头、
|
||||
发送请求到插件网关
|
||||
|
||||
|
||||
@@ -21,10 +21,6 @@ tags:
|
||||
|
||||
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.
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> WeChat currently requires an active subscription. If you are using the community edition without a subscription, the WeChat channel option may not appear in the Channels settings yet.
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
| Platform | Description |
|
||||
@@ -33,7 +29,7 @@ Channels allow you to connect your LobeHub agents to external messaging platform
|
||||
| [Slack](/docs/usage/channels/slack) | Connect to Slack for channel and direct message conversations |
|
||||
| [Telegram](/docs/usage/channels/telegram) | Connect to Telegram for private and group conversations |
|
||||
| [QQ](/docs/usage/channels/qq) | Connect to QQ for group chats and direct messages |
|
||||
| [WeChat (微信)](/docs/usage/channels/wechat) | Connect to WeChat via iLink Bot for private and group chats (requires an active subscription) |
|
||||
| [WeChat (微信)](/docs/usage/channels/wechat) | Connect to WeChat via iLink Bot for private and group chats |
|
||||
| [Feishu (飞书)](/docs/usage/channels/feishu) | Connect to Feishu for team collaboration (Chinese version) |
|
||||
| [Lark](/docs/usage/channels/lark) | Connect to Lark for team collaboration (international version) |
|
||||
|
||||
@@ -57,8 +53,6 @@ Each channel integration works by linking a bot account on the target platform t
|
||||
- [Feishu (飞书)](/docs/usage/channels/feishu)
|
||||
- [Lark](/docs/usage/channels/lark)
|
||||
|
||||
If you do not see **WeChat** in the channel list, check that your account has an active subscription first.
|
||||
|
||||
## Feature Support
|
||||
|
||||
Text messages are supported across all platforms. Some features vary by platform:
|
||||
|
||||
@@ -20,10 +20,6 @@ tags:
|
||||
|
||||
渠道功能允许您将 LobeHub 代理连接到外部消息平台。一旦连接,用户可以直接在他们已经使用的聊天应用中与您的 AI 助手互动,无需访问 LobeHub。
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> 微信渠道目前需要有效订阅。如果您使用的是没有订阅的社区版,**渠道**设置中可能暂时不会显示微信选项。
|
||||
|
||||
## 支持的平台
|
||||
|
||||
| 平台 | 描述 |
|
||||
@@ -32,7 +28,7 @@ tags:
|
||||
| [Slack](/docs/usage/channels/slack) | 连接到 Slack,用于频道和私信对话 |
|
||||
| [Telegram](/docs/usage/channels/telegram) | 连接到 Telegram,用于私人和群组对话 |
|
||||
| [QQ](/docs/usage/channels/qq) | 连接到 QQ,用于群聊和私信 |
|
||||
| [微信](/docs/usage/channels/wechat) | 通过 iLink Bot 连接到微信,用于私聊和群聊(需要有效订阅) |
|
||||
| [微信](/docs/usage/channels/wechat) | 通过 iLink Bot 连接到微信,用于私聊和群聊 |
|
||||
| [飞书](/docs/usage/channels/feishu) | 连接到飞书,用于团队协作(中国版) |
|
||||
| [Lark](/docs/usage/channels/lark) | 连接到 Lark,用于团队协作(国际版) |
|
||||
|
||||
@@ -56,8 +52,6 @@ tags:
|
||||
- [飞书](/docs/usage/channels/feishu)
|
||||
- [Lark](/docs/usage/channels/lark)
|
||||
|
||||
如果您在渠道列表中看不到 **微信**,请先确认当前账户是否拥有有效订阅。
|
||||
|
||||
## 功能支持
|
||||
|
||||
所有平台均支持文本消息。某些功能因平台而异:
|
||||
|
||||
@@ -652,11 +652,6 @@
|
||||
"settingSystem.oauth.signout.confirm": "هل تريد تأكيد تسجيل الخروج؟",
|
||||
"settingSystem.oauth.signout.success": "تم تسجيل الخروج بنجاح",
|
||||
"settingSystem.title": "إعدادات النظام",
|
||||
"settingSystemTools.appEnvironment.chromium.desc": "إصدار محرك متصفح Chromium",
|
||||
"settingSystemTools.appEnvironment.desc": "إصدارات وقت التشغيل المدمجة في تطبيق سطح المكتب",
|
||||
"settingSystemTools.appEnvironment.electron.desc": "إصدار إطار Electron",
|
||||
"settingSystemTools.appEnvironment.node.desc": "إصدار Node.js المدمج",
|
||||
"settingSystemTools.appEnvironment.title": "بيئة التطبيق",
|
||||
"settingSystemTools.autoSelectDesc": "سيتم اختيار أفضل أداة متاحة تلقائيًا",
|
||||
"settingSystemTools.category.browserAutomation": "أتمتة المتصفح",
|
||||
"settingSystemTools.category.browserAutomation.desc": "أدوات لأتمتة المتصفح بدون واجهة والتفاعل مع الويب",
|
||||
|
||||
@@ -359,6 +359,7 @@
|
||||
"referral.table.columns.inviterRewardAmount": "مكافأتي",
|
||||
"referral.table.columns.rewardedAt": "وقت المكافأة",
|
||||
"referral.table.columns.status": "الحالة",
|
||||
"referral.table.columns.suspectedReason": "سبب الشك",
|
||||
"referral.table.status.pending_reward": "المكافأة المعلقة",
|
||||
"referral.table.status.registered": "مسجل",
|
||||
"referral.table.status.revoked": "تم الإلغاء",
|
||||
|
||||
@@ -652,11 +652,6 @@
|
||||
"settingSystem.oauth.signout.confirm": "Потвърждавате ли изход?",
|
||||
"settingSystem.oauth.signout.success": "Успешен изход",
|
||||
"settingSystem.title": "Системни настройки",
|
||||
"settingSystemTools.appEnvironment.chromium.desc": "Версия на браузърния двигател Chromium",
|
||||
"settingSystemTools.appEnvironment.desc": "Вградени версии на средата за изпълнение в настолното приложение",
|
||||
"settingSystemTools.appEnvironment.electron.desc": "Версия на рамката Electron",
|
||||
"settingSystemTools.appEnvironment.node.desc": "Вградена версия на Node.js",
|
||||
"settingSystemTools.appEnvironment.title": "Среда на приложението",
|
||||
"settingSystemTools.autoSelectDesc": "Най-добрият наличен инструмент ще бъде избран автоматично",
|
||||
"settingSystemTools.category.browserAutomation": "Автоматизация на браузъра",
|
||||
"settingSystemTools.category.browserAutomation.desc": "Инструменти за автоматизация на браузъра без графичен интерфейс и уеб взаимодействие",
|
||||
|
||||
@@ -359,6 +359,7 @@
|
||||
"referral.table.columns.inviterRewardAmount": "Моята награда",
|
||||
"referral.table.columns.rewardedAt": "Време на награждаване",
|
||||
"referral.table.columns.status": "Статус",
|
||||
"referral.table.columns.suspectedReason": "Причина за аномалия",
|
||||
"referral.table.status.pending_reward": "Очаквана награда",
|
||||
"referral.table.status.registered": "Регистриран",
|
||||
"referral.table.status.revoked": "Отменен",
|
||||
|
||||
@@ -652,11 +652,6 @@
|
||||
"settingSystem.oauth.signout.confirm": "Abmeldung bestätigen?",
|
||||
"settingSystem.oauth.signout.success": "Erfolgreich abgemeldet",
|
||||
"settingSystem.title": "Systemeinstellungen",
|
||||
"settingSystemTools.appEnvironment.chromium.desc": "Chromium-Browser-Engine-Version",
|
||||
"settingSystemTools.appEnvironment.desc": "Integrierte Laufzeitversionen in der Desktop-App",
|
||||
"settingSystemTools.appEnvironment.electron.desc": "Electron-Framework-Version",
|
||||
"settingSystemTools.appEnvironment.node.desc": "Eingebettete Node.js-Version",
|
||||
"settingSystemTools.appEnvironment.title": "App-Umgebung",
|
||||
"settingSystemTools.autoSelectDesc": "Das beste verfügbare Tool wird automatisch ausgewählt",
|
||||
"settingSystemTools.category.browserAutomation": "Browser-Automatisierung",
|
||||
"settingSystemTools.category.browserAutomation.desc": "Werkzeuge für headless Browser-Automatisierung und Web-Interaktion",
|
||||
|
||||
@@ -359,6 +359,7 @@
|
||||
"referral.table.columns.inviterRewardAmount": "Meine Belohnung",
|
||||
"referral.table.columns.rewardedAt": "Belohnungszeitpunkt",
|
||||
"referral.table.columns.status": "Status",
|
||||
"referral.table.columns.suspectedReason": "Grund für Anomalie",
|
||||
"referral.table.status.pending_reward": "Ausstehende Belohnung",
|
||||
"referral.table.status.registered": "Registriert",
|
||||
"referral.table.status.revoked": "Widerrufen",
|
||||
|
||||
@@ -38,8 +38,6 @@
|
||||
"channel.devWebhookProxyUrlHint": "Optional. HTTPS tunnel URL for forwarding webhook requests to local dev server.",
|
||||
"channel.disabled": "Disabled",
|
||||
"channel.discord.description": "Connect this assistant to Discord server for channel chat and direct messages.",
|
||||
"channel.displayToolCalls": "Display Tool Calls",
|
||||
"channel.displayToolCallsHint": "Show tool call details during AI responses. When disabled, only the final response is displayed for a cleaner experience.",
|
||||
"channel.dm": "Direct Messages",
|
||||
"channel.dmEnabled": "Enable DMs",
|
||||
"channel.dmEnabledHint": "Allow the bot to receive and respond to direct messages",
|
||||
|
||||
@@ -547,6 +547,14 @@
|
||||
"skills.categories.transportation.name": "Transportation",
|
||||
"skills.categories.web-frontend-development.description": "Web development, frontend frameworks, and UI tooling skills",
|
||||
"skills.categories.web-frontend-development.name": "Web & Frontend Development",
|
||||
"skills.collection.editorCollection": "EDITOR'S COLLECTION",
|
||||
"skills.collection.get": "Get",
|
||||
"skills.collection.installAll": "Install all skills",
|
||||
"skills.collection.installDesc": "Get all {{count}} skills installed at once. Your agent will walk you through each one.",
|
||||
"skills.collection.installTitle": "Install the full collection",
|
||||
"skills.collection.moreCollections": "More collections",
|
||||
"skills.collection.skillCount": "{{count}} skills",
|
||||
"skills.collection.skillsInCollection": "Skills in this collection",
|
||||
"skills.details.nav.needHelp": "Need Help?",
|
||||
"skills.details.nav.reportIssue": "Report Issue",
|
||||
"skills.details.nav.viewSourceCode": "View Source Code",
|
||||
@@ -590,11 +598,28 @@
|
||||
"skills.details.versions.title": "Version History",
|
||||
"skills.hero.guide.agent": "I am Agent",
|
||||
"skills.hero.guide.human": "I am Human",
|
||||
"skills.sections.collection": "Collection",
|
||||
"skills.sections.collection1Desc": "A curated starter pack for developers building their first end-to-end coding agent workflow.",
|
||||
"skills.sections.collection1Title": "Get started: your first autonomous coding agent",
|
||||
"skills.sections.collection2Desc": "5 must-have skills to guard your agents against injection, leakage, and runaway permissions.",
|
||||
"skills.sections.collection2Title": "Essential security skills for production agents",
|
||||
"skills.sections.collections": "Editor's collections",
|
||||
"skills.sections.editorsPick": "EDITOR'S PICK",
|
||||
"skills.sections.editorsPickDesc": "Security-first vetting for every skill before it touches your agent. Checks permission scope, red flags, and injection risks automatically.",
|
||||
"skills.sections.editorsPickTitle": "Build safer agents with skill-vetter",
|
||||
"skills.sections.featured": "Featured",
|
||||
"skills.sections.getSkill": "Get skill",
|
||||
"skills.sections.learnMore": "Learn more",
|
||||
"skills.sections.seeAll": "See all →",
|
||||
"skills.sections.trending": "Trending this week",
|
||||
"skills.sorts.createdAt": "Recently Published",
|
||||
"skills.sorts.installCount": "Downloads",
|
||||
"skills.sorts.name": "Name",
|
||||
"skills.sorts.stars": "GitHub Stars",
|
||||
"skills.sorts.updatedAt": "Recently Updated",
|
||||
"skills.tabs.discover": "Discover",
|
||||
"skills.tabs.new": "New",
|
||||
"skills.tabs.trending": "Trending",
|
||||
"tab.assistant": "Agent",
|
||||
"tab.home": "Home",
|
||||
"tab.model": "Model",
|
||||
|
||||
@@ -652,11 +652,6 @@
|
||||
"settingSystem.oauth.signout.confirm": "Confirm sign out?",
|
||||
"settingSystem.oauth.signout.success": "Sign out successful",
|
||||
"settingSystem.title": "System Settings",
|
||||
"settingSystemTools.appEnvironment.chromium.desc": "Chromium browser engine version",
|
||||
"settingSystemTools.appEnvironment.desc": "Built-in runtime versions in the desktop app",
|
||||
"settingSystemTools.appEnvironment.electron.desc": "Electron framework version",
|
||||
"settingSystemTools.appEnvironment.node.desc": "Embedded Node.js version",
|
||||
"settingSystemTools.appEnvironment.title": "App Environment",
|
||||
"settingSystemTools.autoSelectDesc": "The best available tool will be automatically selected",
|
||||
"settingSystemTools.category.browserAutomation": "Browser Automation",
|
||||
"settingSystemTools.category.browserAutomation.desc": "Tools for headless browser automation and web interaction",
|
||||
@@ -710,8 +705,6 @@
|
||||
"skillStore.tabs.community": "Community",
|
||||
"skillStore.tabs.custom": "Custom",
|
||||
"skillStore.tabs.lobehub": "LobeHub",
|
||||
"skillStore.tabs.mcp": "MCP",
|
||||
"skillStore.tabs.skills": "Skills",
|
||||
"skillStore.title": "Skill Store",
|
||||
"skillStore.wantMore.action": "Submit a request →",
|
||||
"skillStore.wantMore.feedback.message": "## Skill Name\n[Please fill in]\n\n## Use Case\nWhen I am ___, I need ___\n\n## Expected Features\n1.\n2.\n3.\n\n## Reference Examples\n(Optional) Are there any similar tools or features for reference?\n\n---\n💡 Tip: The more specific your description, the better we can meet your needs",
|
||||
@@ -775,9 +768,6 @@
|
||||
"systemAgent.historyCompress.label": "Model",
|
||||
"systemAgent.historyCompress.modelDesc": "Specify the model used to compress conversation history",
|
||||
"systemAgent.historyCompress.title": "Conversation History Compression Agent",
|
||||
"systemAgent.inputCompletion.label": "Model",
|
||||
"systemAgent.inputCompletion.modelDesc": "Model used for input auto-completion suggestions (like GitHub Copilot ghost text)",
|
||||
"systemAgent.inputCompletion.title": "Input Auto-Completion Agent",
|
||||
"systemAgent.queryRewrite.label": "Model",
|
||||
"systemAgent.queryRewrite.modelDesc": "Specify the model used to optimize user inquiries",
|
||||
"systemAgent.queryRewrite.title": "Library query rewrite Agent",
|
||||
|
||||
@@ -359,6 +359,7 @@
|
||||
"referral.table.columns.inviterRewardAmount": "My Reward",
|
||||
"referral.table.columns.rewardedAt": "Reward Time",
|
||||
"referral.table.columns.status": "Status",
|
||||
"referral.table.columns.suspectedReason": "Anomaly Reason",
|
||||
"referral.table.status.pending_reward": "Under Review",
|
||||
"referral.table.status.registered": "Registered",
|
||||
"referral.table.status.revoked": "Revoked",
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
"config.resolution.label": "Resolution",
|
||||
"config.seed.label": "Seed",
|
||||
"config.seed.random": "Random",
|
||||
"config.size.label": "Size",
|
||||
"generation.actions.copyError": "Copy Error Message",
|
||||
"generation.actions.errorCopied": "Error Message Copied to Clipboard",
|
||||
"generation.actions.errorCopyFailed": "Failed to Copy Error Message",
|
||||
|
||||
@@ -652,11 +652,6 @@
|
||||
"settingSystem.oauth.signout.confirm": "¿Confirmar cierre de sesión?",
|
||||
"settingSystem.oauth.signout.success": "Sesión cerrada con éxito",
|
||||
"settingSystem.title": "Configuración del Sistema",
|
||||
"settingSystemTools.appEnvironment.chromium.desc": "Versión del motor del navegador Chromium",
|
||||
"settingSystemTools.appEnvironment.desc": "Versiones de tiempo de ejecución integradas en la aplicación de escritorio",
|
||||
"settingSystemTools.appEnvironment.electron.desc": "Versión del framework Electron",
|
||||
"settingSystemTools.appEnvironment.node.desc": "Versión de Node.js integrada",
|
||||
"settingSystemTools.appEnvironment.title": "Entorno de la aplicación",
|
||||
"settingSystemTools.autoSelectDesc": "La mejor herramienta disponible se seleccionará automáticamente",
|
||||
"settingSystemTools.category.browserAutomation": "Automatización del Navegador",
|
||||
"settingSystemTools.category.browserAutomation.desc": "Herramientas para la automatización de navegadores sin cabeza e interacción web",
|
||||
|
||||
@@ -359,6 +359,7 @@
|
||||
"referral.table.columns.inviterRewardAmount": "Mi Recompensa",
|
||||
"referral.table.columns.rewardedAt": "Fecha de Recompensa",
|
||||
"referral.table.columns.status": "Estado",
|
||||
"referral.table.columns.suspectedReason": "Motivo de Anomalía",
|
||||
"referral.table.status.pending_reward": "Recompensa Pendiente",
|
||||
"referral.table.status.registered": "Registrado",
|
||||
"referral.table.status.revoked": "Revocado",
|
||||
|
||||
@@ -652,11 +652,6 @@
|
||||
"settingSystem.oauth.signout.confirm": "آیا از خروج اطمینان دارید؟",
|
||||
"settingSystem.oauth.signout.success": "خروج با موفقیت انجام شد",
|
||||
"settingSystem.title": "تنظیمات سیستم",
|
||||
"settingSystemTools.appEnvironment.chromium.desc": "نسخهٔ موتور مرورگر Chromium",
|
||||
"settingSystemTools.appEnvironment.desc": "نسخههای زمان اجرای تعبیهشده در اپلیکیشن دسکتاپ",
|
||||
"settingSystemTools.appEnvironment.electron.desc": "نسخهٔ چارچوب Electron",
|
||||
"settingSystemTools.appEnvironment.node.desc": "نسخهٔ Node.js تعبیهشده",
|
||||
"settingSystemTools.appEnvironment.title": "محیط برنامه",
|
||||
"settingSystemTools.autoSelectDesc": "بهترین ابزار موجود بهصورت خودکار انتخاب خواهد شد",
|
||||
"settingSystemTools.category.browserAutomation": "اتوماسیون مرورگر",
|
||||
"settingSystemTools.category.browserAutomation.desc": "ابزارهایی برای اتوماسیون مرورگر بدون رابط کاربری و تعامل وب",
|
||||
|
||||
@@ -359,6 +359,7 @@
|
||||
"referral.table.columns.inviterRewardAmount": "پاداش من",
|
||||
"referral.table.columns.rewardedAt": "زمان دریافت پاداش",
|
||||
"referral.table.columns.status": "وضعیت",
|
||||
"referral.table.columns.suspectedReason": "دلیل مشکوک بودن",
|
||||
"referral.table.status.pending_reward": "پاداش در انتظار",
|
||||
"referral.table.status.registered": "ثبتنام شده",
|
||||
"referral.table.status.revoked": "لغو شده",
|
||||
|
||||
@@ -652,11 +652,6 @@
|
||||
"settingSystem.oauth.signout.confirm": "Confirmer la déconnexion ?",
|
||||
"settingSystem.oauth.signout.success": "Déconnexion réussie",
|
||||
"settingSystem.title": "Paramètres système",
|
||||
"settingSystemTools.appEnvironment.chromium.desc": "Version du moteur de navigateur Chromium",
|
||||
"settingSystemTools.appEnvironment.desc": "Versions d'exécution intégrées dans l'application de bureau",
|
||||
"settingSystemTools.appEnvironment.electron.desc": "Version du framework Electron",
|
||||
"settingSystemTools.appEnvironment.node.desc": "Version de Node.js intégrée",
|
||||
"settingSystemTools.appEnvironment.title": "Environnement de l'application",
|
||||
"settingSystemTools.autoSelectDesc": "L'outil le plus performant sera sélectionné automatiquement",
|
||||
"settingSystemTools.category.browserAutomation": "Automatisation du navigateur",
|
||||
"settingSystemTools.category.browserAutomation.desc": "Outils pour l'automatisation de navigateur sans interface et l'interaction web",
|
||||
|
||||
@@ -359,6 +359,7 @@
|
||||
"referral.table.columns.inviterRewardAmount": "Ma récompense",
|
||||
"referral.table.columns.rewardedAt": "Date de récompense",
|
||||
"referral.table.columns.status": "Statut",
|
||||
"referral.table.columns.suspectedReason": "Raison de l’anomalie",
|
||||
"referral.table.status.pending_reward": "Récompense en attente",
|
||||
"referral.table.status.registered": "Inscrit",
|
||||
"referral.table.status.revoked": "Révoqué",
|
||||
|
||||
@@ -652,11 +652,6 @@
|
||||
"settingSystem.oauth.signout.confirm": "Confermi l'uscita?",
|
||||
"settingSystem.oauth.signout.success": "Uscita effettuata con successo",
|
||||
"settingSystem.title": "Impostazioni di Sistema",
|
||||
"settingSystemTools.appEnvironment.chromium.desc": "Versione del motore del browser Chromium",
|
||||
"settingSystemTools.appEnvironment.desc": "Versioni runtime integrate nell'app desktop",
|
||||
"settingSystemTools.appEnvironment.electron.desc": "Versione del framework Electron",
|
||||
"settingSystemTools.appEnvironment.node.desc": "Versione di Node.js integrata",
|
||||
"settingSystemTools.appEnvironment.title": "Ambiente app",
|
||||
"settingSystemTools.autoSelectDesc": "Lo strumento migliore disponibile verrà selezionato automaticamente",
|
||||
"settingSystemTools.category.browserAutomation": "Automazione del browser",
|
||||
"settingSystemTools.category.browserAutomation.desc": "Strumenti per l'automazione del browser senza interfaccia grafica e l'interazione web",
|
||||
|
||||
@@ -359,6 +359,7 @@
|
||||
"referral.table.columns.inviterRewardAmount": "Mia Ricompensa",
|
||||
"referral.table.columns.rewardedAt": "Data Ricompensa",
|
||||
"referral.table.columns.status": "Stato",
|
||||
"referral.table.columns.suspectedReason": "Motivo Anomalia",
|
||||
"referral.table.status.pending_reward": "Ricompensa in sospeso",
|
||||
"referral.table.status.registered": "Registrato",
|
||||
"referral.table.status.revoked": "Revocato",
|
||||
|
||||
@@ -652,11 +652,6 @@
|
||||
"settingSystem.oauth.signout.confirm": "ログアウトしますか?",
|
||||
"settingSystem.oauth.signout.success": "ログアウトに成功しました",
|
||||
"settingSystem.title": "システム設定",
|
||||
"settingSystemTools.appEnvironment.chromium.desc": "Chromium ブラウザーエンジンのバージョン",
|
||||
"settingSystemTools.appEnvironment.desc": "デスクトップアプリに組み込まれたランタイムのバージョン",
|
||||
"settingSystemTools.appEnvironment.electron.desc": "Electron フレームワークのバージョン",
|
||||
"settingSystemTools.appEnvironment.node.desc": "同梱 Node.js のバージョン",
|
||||
"settingSystemTools.appEnvironment.title": "アプリ環境",
|
||||
"settingSystemTools.autoSelectDesc": "最適な利用可能ツールが自動的に選択されます",
|
||||
"settingSystemTools.category.browserAutomation": "ブラウザー自動化",
|
||||
"settingSystemTools.category.browserAutomation.desc": "ヘッドレスブラウザーの自動化とウェブ操作のためのツール",
|
||||
|
||||
@@ -359,6 +359,7 @@
|
||||
"referral.table.columns.inviterRewardAmount": "自分の報酬",
|
||||
"referral.table.columns.rewardedAt": "報酬付与日時",
|
||||
"referral.table.columns.status": "ステータス",
|
||||
"referral.table.columns.suspectedReason": "異常理由",
|
||||
"referral.table.status.pending_reward": "保留中の報酬",
|
||||
"referral.table.status.registered": "登録済み",
|
||||
"referral.table.status.revoked": "取り消し",
|
||||
|
||||
@@ -652,11 +652,6 @@
|
||||
"settingSystem.oauth.signout.confirm": "로그아웃 하시겠습니까?",
|
||||
"settingSystem.oauth.signout.success": "로그아웃 성공",
|
||||
"settingSystem.title": "시스템 설정",
|
||||
"settingSystemTools.appEnvironment.chromium.desc": "Chromium 브라우저 엔진 버전",
|
||||
"settingSystemTools.appEnvironment.desc": "데스크톱 앱에 내장된 런타임 버전",
|
||||
"settingSystemTools.appEnvironment.electron.desc": "Electron 프레임워크 버전",
|
||||
"settingSystemTools.appEnvironment.node.desc": "내장 Node.js 버전",
|
||||
"settingSystemTools.appEnvironment.title": "앱 환경",
|
||||
"settingSystemTools.autoSelectDesc": "가장 적합한 도구가 자동으로 선택됩니다",
|
||||
"settingSystemTools.category.browserAutomation": "브라우저 자동화",
|
||||
"settingSystemTools.category.browserAutomation.desc": "헤드리스 브라우저 자동화 및 웹 상호작용을 위한 도구",
|
||||
|
||||
@@ -359,6 +359,7 @@
|
||||
"referral.table.columns.inviterRewardAmount": "내 보상",
|
||||
"referral.table.columns.rewardedAt": "보상 시간",
|
||||
"referral.table.columns.status": "상태",
|
||||
"referral.table.columns.suspectedReason": "이상 사유",
|
||||
"referral.table.status.pending_reward": "보상 대기 중",
|
||||
"referral.table.status.registered": "가입 완료",
|
||||
"referral.table.status.revoked": "취소됨",
|
||||
|
||||
@@ -652,11 +652,6 @@
|
||||
"settingSystem.oauth.signout.confirm": "Weet je zeker dat je wilt uitloggen?",
|
||||
"settingSystem.oauth.signout.success": "Succesvol uitgelogd",
|
||||
"settingSystem.title": "Systeeminstellingen",
|
||||
"settingSystemTools.appEnvironment.chromium.desc": "Chromium-browserengineversie",
|
||||
"settingSystemTools.appEnvironment.desc": "Ingebouwde runtimeversies in de desktop-app",
|
||||
"settingSystemTools.appEnvironment.electron.desc": "Electron-frameworkversie",
|
||||
"settingSystemTools.appEnvironment.node.desc": "Ingesloten Node.js-versie",
|
||||
"settingSystemTools.appEnvironment.title": "App-omgeving",
|
||||
"settingSystemTools.autoSelectDesc": "Het best beschikbare hulpmiddel wordt automatisch geselecteerd",
|
||||
"settingSystemTools.category.browserAutomation": "Browserautomatisering",
|
||||
"settingSystemTools.category.browserAutomation.desc": "Tools voor headless browserautomatisering en webinteractie",
|
||||
|
||||
@@ -359,6 +359,7 @@
|
||||
"referral.table.columns.inviterRewardAmount": "Mijn Beloning",
|
||||
"referral.table.columns.rewardedAt": "Beloningstijd",
|
||||
"referral.table.columns.status": "Status",
|
||||
"referral.table.columns.suspectedReason": "Reden Afwijking",
|
||||
"referral.table.status.pending_reward": "In afwachting van beloning",
|
||||
"referral.table.status.registered": "Geregistreerd",
|
||||
"referral.table.status.revoked": "Ingetrokken",
|
||||
|
||||
@@ -652,11 +652,6 @@
|
||||
"settingSystem.oauth.signout.confirm": "Potwierdzić wylogowanie?",
|
||||
"settingSystem.oauth.signout.success": "Wylogowano pomyślnie",
|
||||
"settingSystem.title": "Ustawienia systemowe",
|
||||
"settingSystemTools.appEnvironment.chromium.desc": "Wersja silnika przeglądarki Chromium",
|
||||
"settingSystemTools.appEnvironment.desc": "Wbudowane wersje środowiska uruchomieniowego w aplikacji komputerowej",
|
||||
"settingSystemTools.appEnvironment.electron.desc": "Wersja frameworka Electron",
|
||||
"settingSystemTools.appEnvironment.node.desc": "Wersja wbudowanego Node.js",
|
||||
"settingSystemTools.appEnvironment.title": "Środowisko aplikacji",
|
||||
"settingSystemTools.autoSelectDesc": "Najlepsze dostępne narzędzie zostanie wybrane automatycznie",
|
||||
"settingSystemTools.category.browserAutomation": "Automatyzacja przeglądarki",
|
||||
"settingSystemTools.category.browserAutomation.desc": "Narzędzia do automatyzacji przeglądarki bez interfejsu graficznego i interakcji z siecią",
|
||||
|
||||
@@ -359,6 +359,7 @@
|
||||
"referral.table.columns.inviterRewardAmount": "Moja Nagroda",
|
||||
"referral.table.columns.rewardedAt": "Czas Przyznania Nagrody",
|
||||
"referral.table.columns.status": "Status",
|
||||
"referral.table.columns.suspectedReason": "Powód Nieprawidłowości",
|
||||
"referral.table.status.pending_reward": "Oczekująca Nagroda",
|
||||
"referral.table.status.registered": "Zarejestrowany",
|
||||
"referral.table.status.revoked": "Cofnięty",
|
||||
|
||||
@@ -652,11 +652,6 @@
|
||||
"settingSystem.oauth.signout.confirm": "Confirmar saída?",
|
||||
"settingSystem.oauth.signout.success": "Saída realizada com sucesso",
|
||||
"settingSystem.title": "Configurações do Sistema",
|
||||
"settingSystemTools.appEnvironment.chromium.desc": "Versão do mecanismo do navegador Chromium",
|
||||
"settingSystemTools.appEnvironment.desc": "Versões de runtime integradas ao aplicativo desktop",
|
||||
"settingSystemTools.appEnvironment.electron.desc": "Versão do framework Electron",
|
||||
"settingSystemTools.appEnvironment.node.desc": "Versão do Node.js integrada",
|
||||
"settingSystemTools.appEnvironment.title": "Ambiente do aplicativo",
|
||||
"settingSystemTools.autoSelectDesc": "A melhor ferramenta disponível será selecionada automaticamente",
|
||||
"settingSystemTools.category.browserAutomation": "Automação de Navegador",
|
||||
"settingSystemTools.category.browserAutomation.desc": "Ferramentas para automação de navegador sem interface gráfica e interação com a web",
|
||||
|
||||
@@ -359,6 +359,7 @@
|
||||
"referral.table.columns.inviterRewardAmount": "Minha Recompensa",
|
||||
"referral.table.columns.rewardedAt": "Data da Recompensa",
|
||||
"referral.table.columns.status": "Status",
|
||||
"referral.table.columns.suspectedReason": "Motivo da Anomalia",
|
||||
"referral.table.status.pending_reward": "Recompensa Pendente",
|
||||
"referral.table.status.registered": "Registrado",
|
||||
"referral.table.status.revoked": "Revogado",
|
||||
|
||||
@@ -652,11 +652,6 @@
|
||||
"settingSystem.oauth.signout.confirm": "Подтвердить выход?",
|
||||
"settingSystem.oauth.signout.success": "Выход выполнен успешно",
|
||||
"settingSystem.title": "Системные настройки",
|
||||
"settingSystemTools.appEnvironment.chromium.desc": "Версия движка браузера Chromium",
|
||||
"settingSystemTools.appEnvironment.desc": "Встроенные версии среды выполнения в настольном приложении",
|
||||
"settingSystemTools.appEnvironment.electron.desc": "Версия фреймворка Electron",
|
||||
"settingSystemTools.appEnvironment.node.desc": "Версия встроенного Node.js",
|
||||
"settingSystemTools.appEnvironment.title": "Среда приложения",
|
||||
"settingSystemTools.autoSelectDesc": "Лучший доступный инструмент будет выбран автоматически",
|
||||
"settingSystemTools.category.browserAutomation": "Автоматизация браузера",
|
||||
"settingSystemTools.category.browserAutomation.desc": "Инструменты для автоматизации безголового браузера и взаимодействия с вебом",
|
||||
|
||||
@@ -359,6 +359,7 @@
|
||||
"referral.table.columns.inviterRewardAmount": "Моя награда",
|
||||
"referral.table.columns.rewardedAt": "Дата награды",
|
||||
"referral.table.columns.status": "Статус",
|
||||
"referral.table.columns.suspectedReason": "Причина подозрения",
|
||||
"referral.table.status.pending_reward": "Ожидаемое вознаграждение",
|
||||
"referral.table.status.registered": "Зарегистрирован",
|
||||
"referral.table.status.revoked": "Отменено",
|
||||
|
||||
@@ -652,11 +652,6 @@
|
||||
"settingSystem.oauth.signout.confirm": "Çıkış yapmak istediğinize emin misiniz?",
|
||||
"settingSystem.oauth.signout.success": "Başarıyla çıkış yapıldı",
|
||||
"settingSystem.title": "Sistem Ayarları",
|
||||
"settingSystemTools.appEnvironment.chromium.desc": "Chromium tarayıcı motoru sürümü",
|
||||
"settingSystemTools.appEnvironment.desc": "Masaüstü uygulamasındaki yerleşik çalışma zamanı sürümleri",
|
||||
"settingSystemTools.appEnvironment.electron.desc": "Electron framework sürümü",
|
||||
"settingSystemTools.appEnvironment.node.desc": "Gömülü Node.js sürümü",
|
||||
"settingSystemTools.appEnvironment.title": "Uygulama ortamı",
|
||||
"settingSystemTools.autoSelectDesc": "En iyi mevcut araç otomatik olarak seçilecektir",
|
||||
"settingSystemTools.category.browserAutomation": "Tarayıcı Otomasyonu",
|
||||
"settingSystemTools.category.browserAutomation.desc": "Başsız tarayıcı otomasyonu ve web etkileşimi için araçlar",
|
||||
|
||||
@@ -359,6 +359,7 @@
|
||||
"referral.table.columns.inviterRewardAmount": "Benim Ödülüm",
|
||||
"referral.table.columns.rewardedAt": "Ödül Zamanı",
|
||||
"referral.table.columns.status": "Durum",
|
||||
"referral.table.columns.suspectedReason": "Anomali Nedeni",
|
||||
"referral.table.status.pending_reward": "Bekleyen Ödül",
|
||||
"referral.table.status.registered": "Kayıtlı",
|
||||
"referral.table.status.revoked": "İptal Edildi",
|
||||
|
||||
@@ -652,11 +652,6 @@
|
||||
"settingSystem.oauth.signout.confirm": "Xác nhận đăng xuất?",
|
||||
"settingSystem.oauth.signout.success": "Đăng xuất thành công",
|
||||
"settingSystem.title": "Cài Đặt Hệ Thống",
|
||||
"settingSystemTools.appEnvironment.chromium.desc": "Phiên bản engine trình duyệt Chromium",
|
||||
"settingSystemTools.appEnvironment.desc": "Phiên bản runtime tích hợp trong ứng dụng desktop",
|
||||
"settingSystemTools.appEnvironment.electron.desc": "Phiên bản framework Electron",
|
||||
"settingSystemTools.appEnvironment.node.desc": "Phiên bản Node.js nhúng",
|
||||
"settingSystemTools.appEnvironment.title": "Môi trường ứng dụng",
|
||||
"settingSystemTools.autoSelectDesc": "Công cụ tốt nhất sẽ được tự động chọn",
|
||||
"settingSystemTools.category.browserAutomation": "Tự động hóa trình duyệt",
|
||||
"settingSystemTools.category.browserAutomation.desc": "Công cụ cho tự động hóa trình duyệt không giao diện và tương tác web",
|
||||
|
||||
@@ -359,6 +359,7 @@
|
||||
"referral.table.columns.inviterRewardAmount": "Phần thưởng của tôi",
|
||||
"referral.table.columns.rewardedAt": "Thời gian nhận thưởng",
|
||||
"referral.table.columns.status": "Trạng thái",
|
||||
"referral.table.columns.suspectedReason": "Lý do nghi ngờ",
|
||||
"referral.table.status.pending_reward": "Phần thưởng đang chờ",
|
||||
"referral.table.status.registered": "Đã đăng ký",
|
||||
"referral.table.status.revoked": "Đã thu hồi",
|
||||
|
||||
@@ -38,8 +38,6 @@
|
||||
"channel.devWebhookProxyUrlHint": "可选。用于将 webhook 请求转发到本地开发服务器的 HTTPS 隧道 URL。",
|
||||
"channel.disabled": "已禁用",
|
||||
"channel.discord.description": "将助手连接到 Discord 服务器,支持频道聊天和私信。",
|
||||
"channel.displayToolCalls": "展示工具调用",
|
||||
"channel.displayToolCallsHint": "在 AI 回复过程中展示工具调用详情。关闭后仅展示最终回复,获得更简洁的体验。",
|
||||
"channel.dm": "私信",
|
||||
"channel.dmEnabled": "启用私信",
|
||||
"channel.dmEnabledHint": "允许机器人接收和回复私信",
|
||||
|
||||
@@ -547,6 +547,14 @@
|
||||
"skills.categories.transportation.name": "交通运输",
|
||||
"skills.categories.web-frontend-development.description": "Web开发、前端框架和UI工具技能",
|
||||
"skills.categories.web-frontend-development.name": "Web与前端开发",
|
||||
"skills.collection.editorCollection": "编辑精选",
|
||||
"skills.collection.get": "获取",
|
||||
"skills.collection.installAll": "安装全部技能",
|
||||
"skills.collection.installDesc": "一次性安装全部 {{count}} 个技能。你的智能体将逐一引导你完成配置。",
|
||||
"skills.collection.installTitle": "安装完整合集",
|
||||
"skills.collection.moreCollections": "更多合集",
|
||||
"skills.collection.skillCount": "{{count}} 个技能",
|
||||
"skills.collection.skillsInCollection": "合集中的技能",
|
||||
"skills.details.nav.needHelp": "需要帮助?",
|
||||
"skills.details.nav.reportIssue": "报告问题",
|
||||
"skills.details.nav.viewSourceCode": "查看源码",
|
||||
@@ -590,11 +598,28 @@
|
||||
"skills.details.versions.title": "版本历史",
|
||||
"skills.hero.guide.agent": "我是 Agent",
|
||||
"skills.hero.guide.human": "我是人类",
|
||||
"skills.sections.collection": "精选集",
|
||||
"skills.sections.collection1Desc": "为开发者打造的入门套件,帮助你构建第一个端到端的编程智能体工作流。",
|
||||
"skills.sections.collection1Title": "入门指南:你的第一个自主编程智能体",
|
||||
"skills.sections.collection2Desc": "5 个必备技能,保护你的智能体免受注入、泄露和权限失控的威胁。",
|
||||
"skills.sections.collection2Title": "生产环境智能体必备安全技能",
|
||||
"skills.sections.collections": "编辑精选集",
|
||||
"skills.sections.editorsPick": "编辑推荐",
|
||||
"skills.sections.editorsPickDesc": "在技能触及你的智能体之前,进行安全优先的审查。自动检查权限范围、危险信号和注入风险。",
|
||||
"skills.sections.editorsPickTitle": "使用 skill-vetter 构建更安全的智能体",
|
||||
"skills.sections.featured": "精选",
|
||||
"skills.sections.getSkill": "获取技能",
|
||||
"skills.sections.learnMore": "了解更多",
|
||||
"skills.sections.seeAll": "查看全部 →",
|
||||
"skills.sections.trending": "本周热门",
|
||||
"skills.sorts.createdAt": "最近发布",
|
||||
"skills.sorts.installCount": "最多下载",
|
||||
"skills.sorts.name": "名称",
|
||||
"skills.sorts.stars": "GitHub 星标",
|
||||
"skills.sorts.updatedAt": "最近更新",
|
||||
"skills.tabs.discover": "发现",
|
||||
"skills.tabs.new": "最新",
|
||||
"skills.tabs.trending": "热门",
|
||||
"tab.assistant": "助理",
|
||||
"tab.home": "首页",
|
||||
"tab.model": "模型",
|
||||
|
||||
@@ -226,7 +226,6 @@
|
||||
"builtins.lobe-user-memory.apiName.addExperienceMemory": "添加经验记忆",
|
||||
"builtins.lobe-user-memory.apiName.addIdentityMemory": "添加身份记忆",
|
||||
"builtins.lobe-user-memory.apiName.addPreferenceMemory": "添加偏好记忆",
|
||||
"builtins.lobe-user-memory.apiName.queryTaxonomyOptions": "查询分类",
|
||||
"builtins.lobe-user-memory.apiName.removeIdentityMemory": "删除身份记忆",
|
||||
"builtins.lobe-user-memory.apiName.searchUserMemory": "搜索记忆",
|
||||
"builtins.lobe-user-memory.apiName.updateIdentityMemory": "更新身份记忆",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user