Compare commits

..

5 Commits

Author SHA1 Message Date
ONLY-yours 8e96924774 fix: merge canayr 2026-04-01 17:27:58 +08:00
ONLY-yours 4b9ddc4fc2 ♻️ refactor: align skill collection types with API schema
- Update SkillCollectionItem type: coverUrl → cover, description → summary
- Add pagination fields to SkillCollectionDetail: currentPage, pageSize, totalCount, totalPages
- Update Hero, CollectionsSection, EditorsPick, MoreCollections components to use correct field names

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-31 16:49:09 +08:00
ONLY-yours f0b16a5565 fix: merge canary 2026-03-31 15:58:33 +08:00
ONLY-yours 97987229a8 feat: skills collection list & detail connect pages init 2026-03-27 15:45:41 +08:00
ONLY-yours 4aa6dcd01a refactor: change the skills to new vision 2026-03-26 17:38:15 +08:00
757 changed files with 14451 additions and 40314 deletions
+3 -5
View File
@@ -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
+64 -254
View File
@@ -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
-5
View File
@@ -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)
```
+4 -12
View File
@@ -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
-8
View File
@@ -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
```
+10 -13
View File
@@ -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.
```
-5
View File
@@ -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)
```
-13
View File
@@ -1,13 +0,0 @@
AmAzing129
arvinxx
canisminor1990
ilimei
Innei
lobehubbot
nekomeowww
ONLY-yours
rdmclin2
rivertwilight
sudongyuer
tcmonster
tjx666
+1 -11
View File
@@ -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.**
-12
View File
@@ -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 }}
+1 -13
View File
@@ -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: |
+8 -8
View File
@@ -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
+3 -3
View File
@@ -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/rcnightly 标签已停用
# 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"
+22 -65
View File
@@ -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 }}
-89
View File
@@ -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
View File
@@ -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
View File
@@ -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 -7
View File
@@ -1,6 +1,6 @@
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
.\" Manual command details come from the Commander command tree.
.TH LH 1 "" "@lobehub/cli 0.0.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 -2
View File
@@ -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",
+1 -3
View File
@@ -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
View File
@@ -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,
};
}
+7 -199
View File
@@ -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',
+9 -70
View File
@@ -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,
});
},
);
+1 -32
View File
@@ -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 () => {
+10 -9
View File
@@ -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();
}
};
+1
View File
@@ -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',
});
-11
View File
@@ -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');
});
});
});
-466
View File
@@ -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 -58
View File
@@ -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
View File
@@ -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';
-1
View File
@@ -23,7 +23,6 @@ function getLogFilePath() {
export interface DaemonStatus {
connectionStatus: string;
deviceId?: string;
gatewayUrl: string;
pid: number;
startedAt: string;
-4
View File
@@ -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;
}
+4 -16
View File
@@ -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 -390
View File
@@ -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'));
});
});
-127
View File
@@ -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 {
-5
View File
@@ -1,5 +0,0 @@
import { readStatus } from '../daemon/manager';
export function resolveLocalDeviceId(): string | undefined {
return readStatus()?.deviceId;
}
-96
View File
@@ -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) => {
+1 -3
View File
@@ -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 =
+1 -1
View File
@@ -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 });
+6 -6
View File
@@ -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,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
+1 -15
View File
@@ -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', () => {
-3
View File
@@ -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,
});
};
+1 -1
View File
@@ -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
+1 -1
View File
@@ -159,7 +159,7 @@ while (state.status !== 'done' && state.status !== 'error') {
**Plugin 工具**:传统插件体系,通过 API 网关调用。
该体系预期将逐步废弃,由 MCP 工具体系替代。
- 前端通过 `invokeBuiltinTool` 方法调用
- 前端通过 `invokeDefaultTypePlugin` 方法调用
- 获取插件设置和清单、创建认证请求头、
发送请求到插件网关
+1 -7
View File
@@ -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:
+1 -7
View File
@@ -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)
如果您在渠道列表中看不到 **微信**,请先确认当前账户是否拥有有效订阅。
## 功能支持
所有平台均支持文本消息。某些功能因平台而异:
-5
View File
@@ -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": "أدوات لأتمتة المتصفح بدون واجهة والتفاعل مع الويب",
+1
View File
@@ -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": "تم الإلغاء",
-5
View File
@@ -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": "Инструменти за автоматизация на браузъра без графичен интерфейс и уеб взаимодействие",
+1
View File
@@ -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": "Отменен",
-5
View File
@@ -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",
+1
View File
@@ -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",
-2
View File
@@ -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",
+25
View File
@@ -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",
-10
View File
@@ -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",
+1
View File
@@ -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",
-1
View File
@@ -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",
-5
View File
@@ -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",
+1
View File
@@ -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",
-5
View File
@@ -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": "ابزارهایی برای اتوماسیون مرورگر بدون رابط کاربری و تعامل وب",
+1
View File
@@ -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": "لغو شده",
-5
View File
@@ -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",
+1
View File
@@ -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 lanomalie",
"referral.table.status.pending_reward": "Récompense en attente",
"referral.table.status.registered": "Inscrit",
"referral.table.status.revoked": "Révoqué",
-5
View File
@@ -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",
+1
View File
@@ -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",
-5
View File
@@ -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": "ヘッドレスブラウザーの自動化とウェブ操作のためのツール",
+1
View File
@@ -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": "取り消し",
-5
View File
@@ -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": "헤드리스 브라우저 자동화 및 웹 상호작용을 위한 도구",
+1
View File
@@ -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": "취소됨",
-5
View File
@@ -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",
+1
View File
@@ -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",
-5
View File
@@ -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ą",
+1
View File
@@ -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",
-5
View File
@@ -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",
+1
View File
@@ -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",
-5
View File
@@ -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": "Инструменты для автоматизации безголового браузера и взаимодействия с вебом",
+1
View File
@@ -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": "Отменено",
-5
View File
@@ -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",
+1
View File
@@ -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",
-5
View File
@@ -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",
+1
View File
@@ -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",
-2
View File
@@ -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": "允许机器人接收和回复私信",
+25
View File
@@ -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": "模型",
-1
View File
@@ -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