Compare commits

...

60 Commits

Author SHA1 Message Date
rdmclin2 e2d55e3433 chore: avoid loading all platform bots with signle webhook 2026-03-09 00:43:18 +08:00
rdmclin2 9f0ae47eab chore: add chat sdk debug 2026-03-09 00:03:56 +08:00
rdmclin2 c59552757c chore: add debug module 2026-03-08 22:56:33 +08:00
rdmclin2 647065402c chore: adjust discord form 2026-03-08 20:12:27 +08:00
rdmclin2 c67ef5f8d6 chore: use test id 2026-03-08 18:58:34 +08:00
rdmclin2 9760d0d458 chore: update feishu and lark color 2026-03-08 15:57:40 +08:00
rdmclin2 1db7487afd chore: verificationToken optional 2026-03-08 15:47:25 +08:00
rdmclin2 7cc55dd1b7 chore: update docs permission list 2026-03-08 15:19:50 +08:00
rdmclin2 b80fa29bcd chore: update verfication comment 2026-03-08 15:06:12 +08:00
rdmclin2 94f71160b8 chore: update feishu docs 2026-03-08 15:02:56 +08:00
rdmclin2 ca699bc58b chore: make verfication code required 2026-03-08 14:56:41 +08:00
rdmclin2 5c6c3c9d10 chore: update docs 2026-03-08 14:17:32 +08:00
rdmclin2 20c2b2d75a chore: update feishu and lark tenant 2026-03-08 14:12:31 +08:00
rdmclin2 5ad0c77264 docs: update feishu doc 2026-03-08 13:45:32 +08:00
rdmclin2 f887110b8c chore: add persist logic 2026-03-08 12:54:58 +08:00
rdmclin2 0f458a4504 fix: telegram webhook not set 2026-03-08 12:32:58 +08:00
rdmclin2 d3b9e9ddc2 chore: update copy text 2026-03-08 12:19:52 +08:00
rdmclin2 79e25b458b chore: optimize webhook url trim 2026-03-08 11:58:16 +08:00
rdmclin2 c35d606e7c fix: tsgo error 2026-03-08 01:15:04 +08:00
rdmclin2 fdbfa71635 fix: udpate variable import 2026-03-08 00:17:18 +08:00
rdmclin2 807cfd49a2 chore: update intergration to channel 2026-03-07 18:07:57 +08:00
rdmclin2 dd653f936e fix: create bot with wrong platform 2026-03-07 12:34:05 +08:00
rdmclin2 2f5be753c2 chore: remove unused import 2026-03-07 12:28:56 +08:00
rdmclin2 c0097ca036 chore: add channel docs 2026-03-07 12:15:24 +08:00
rdmclin2 a9f1c8abce chore: add doc link 2026-03-07 12:15:23 +08:00
rdmclin2 66ffbfda8b chore: remove webhook mode for discord 2026-03-07 12:15:23 +08:00
rdmclin2 c55a40c2ed fix: vercel function appId 2026-03-07 12:15:23 +08:00
rdmclin2 bd667b0677 fix: encrpted risk 2026-03-07 12:15:23 +08:00
rdmclin2 cb8b4d9f65 fix: token check logic 2026-03-07 12:15:23 +08:00
rdmclin2 71e6fdacbc fix: detail style 2026-03-07 12:15:23 +08:00
rdmclin2 1935edae10 chore: add lark icon 2026-03-07 12:15:23 +08:00
rdmclin2 d07587e61f chore: move developer mode to advanced setting 2026-03-07 12:15:23 +08:00
rdmclin2 b1f565e62a chore: adjust topic channel icon 2026-03-07 12:15:21 +08:00
rdmclin2 28256b45bc chore: clean speaker tag & add username api adapter 2026-03-07 12:13:17 +08:00
rdmclin2 42bc9edd63 feat: add lark chat adapter 2026-03-07 12:13:17 +08:00
rdmclin2 4dc9863c31 style: hide required mark 2026-03-07 12:13:17 +08:00
rdmclin2 5d315a1346 chore: update form item description 2026-03-07 12:13:17 +08:00
rdmclin2 fe525975c8 chore: update i18n keys to channel 2026-03-07 12:13:12 +08:00
rdmclin2 07f3928502 chore: channel form refact 2026-03-07 12:13:03 +08:00
rdmclin2 517a92b866 chore: update webhook url 2026-03-07 12:13:03 +08:00
rdmclin2 3287130eac feat: add topic list channel provider icon 2026-03-07 12:12:59 +08:00
rdmclin2 456e7b8ed0 fix: channel router 2026-03-07 12:07:56 +08:00
rdmclin2 b6587ec5d7 chore: rename from integration to channel 2026-03-07 12:07:56 +08:00
rdmclin2 e18437b2f9 chore: change integration to channel 2026-03-07 12:07:56 +08:00
rdmclin2 e4c90fc6f3 feat: support lark and feishu 2026-03-07 12:07:56 +08:00
lobehubbot 42ed155944 Merge remote-tracking branch 'origin/main' into canary 2026-03-06 13:41:48 +00:00
Innei 2dc7b15c31 🚀 release: 20260306 (#12757)
This release includes **31 commits**. Key updates are below.

### New Features and Enhancements

- Added **Telegram bot access** support.
- Added **electron page tabs** functionality for desktop.
- Added **device code auth flow** for authentication.
- Added **GPT-5.4** model support.
- Show **last used auth provider** on sign-in page for better UX.
- Support **clearing hotkey bindings** in desktop ShortcutManager.
- Added **Gemini 3.1 Flash Lite Preview** model and thinkingLevel5
extend param.
- Added **auto aspect ratio and image search** support for Nano Banana
2.
- User memories now default to inject user persona instead of
identities.

### Desktop Improvements

- Unified **update channel switching** with S3 distribution.
- Added **S3 publish for canary/nightly** and S3 cleanup (keep latest
15).
- Added electron page tabs functionality.

### Stability and Fixes

- Fixed agents fork not working in community deploy.
- Fixed animation for single-line messages between reasoning and tool
calls.
- Fixed Discord bot conflict with keyPrefix.
- Fixed skew plugin issue.
- Fixed `userMemories` database failure on extra structure mismatch.
- Fixed old LobeHub plugins update issue.
- Fixed context-engine tool type recovery from manifest when models
strip suffixes.
- Added `await` to `handleResponseAPIMode` for proper error handling.
- Fixed M2M token for community agents/MCP/skill list.
- Fixed scripts to support Win32.
- Improved gateway and device gateway CI.

### Credits

Huge thanks to these contributors (alphabetical):

@arvinxx @huangkairan @Innei @LiJian @Luis-Sambrano @nekomeowww
@rdmclin2 @ReneWang @sxjeru @tjx666
2026-03-06 21:41:07 +08:00
Innei 5391ceda7d 🐛 fix(ci): add version prefix to S3 update manifest URLs (#12772)
🐛 fix(ci): target channel yml files instead of latest*.yml for version prefix

The merge-mac-files step already renames latest*.yml to {channel}*.yml
(e.g., canary-mac.yml). The previous fix targeted release/latest*.yml
which matched nothing, so the sed was a no-op.

Now targets release/${CHANNEL}*.yml directly, with latest*.yml as fallback.
2026-03-06 19:34:32 +08:00
Innei a2bf627531 🐛 fix(ci): add version prefix to latest*.yml URLs in S3 upload (#12770)
The latest*.yml files uploaded to S3 channel root lacked the $VERSION/
prefix in their URLs, causing electron-updater to request files at
the wrong path (e.g., /canary/LobeHub-Canary-xxx.zip instead of
/canary/2.1.38-canary.1/LobeHub-Canary-xxx.zip), resulting in 404.

Now sed -i modifies latest*.yml in-place before uploading, and
channel-specific yml files are copied from the already-modified ones.
2026-03-06 18:41:26 +08:00
Innei 0b7c917745 👷 build(ci): fix changelog auto-generation in release workflow (#12765)
After auto-tag-release.yml was introduced, semantic-release in release.yml
stopped working because the tag already exists when it runs. This caused
CHANGELOG.md to never be updated.

Fix: move changelog generation into auto-tag-release.yml with a custom
script that parses git log and generates gitmoji-formatted entries,
matching the existing CHANGELOG.md format. Remove the broken
semantic-release step from release.yml.
2026-03-06 17:25:44 +08:00
YuTengjing 716c27df12 🐛 fix: resolve message reordering in Responses API input conversion (#12764) 2026-03-06 17:14:26 +08:00
Innei 0dd0d11731 👷 build(ci): fix changelog auto-generation in release workflow (#12763)
After auto-tag-release.yml was introduced, semantic-release in release.yml
stopped working because the tag already exists when it runs. This caused
CHANGELOG.md to never be updated.

Fix: move changelog generation into auto-tag-release.yml with a custom
script that parses git log and generates gitmoji-formatted entries,
matching the existing CHANGELOG.md format. Remove the broken
semantic-release step from release.yml.
2026-03-06 17:08:47 +08:00
LiJian 400a0205a3 🐛 fix: when use trustclient not register market m2m token (#12762)
fix: when use trust client not take inject token
2026-03-06 17:03:34 +08:00
lobehubbot 86889b81bd 🔖 chore(release): release version v2.1.37 [skip ci] 2026-03-06 06:25:38 +00:00
Innei d3550afe05 🐛 hotfix(ci): correct stable renderer tar source path (#12755)
🐛 fix(ci): correct stable renderer tar source path

Use the current Electron renderer output directory when creating the stable renderer archive so Linux desktop release builds stop failing after packaging succeeds.

Made-with: Cursor
2026-03-06 14:24:06 +08:00
LiJian 4d240cf7fa 🐛 fix: slove the agnets fork not work in communtiy deploy (#12750)
* fix: slove the agnets fork not work in communtiy deploy

* fix: slove the secure token set & registerM2MToken not batch

* Revert "fix: slove the secure token set & registerM2MToken not batch"

This reverts commit 4485e57165.
2026-03-06 14:12:48 +08:00
YuTengjing db45907ab8 feat: add GPT-5.4 model support (#12744)
*  feat: add GPT-5.4 model support and fix reasoning payload pruning

- Add GPT-5.4 model card to model-bank
- Update planCardModels to use gpt-5.4
- Add gpt-5.4 to responsesAPIModels
- Fix pruneReasoningPayload to strip logprobs/top_logprobs for reasoning models
- Add logprobs, top_logprobs to ChatStreamPayload type
- Extend reasoning_effort to include none and xhigh
- Add success log for non-fallback requests in RouterRuntime
- Fix log parameter mismatch in RouterRuntime

Fixes LOBE-5735

* 🐛 fix: match gpt-5.4 to gpt5_2ReasoningEffort in openrouter and vercelaigateway

* 🐛 fix: update OpenRouterReasoning effort type to include none and xhigh

* 🐛 fix: use tiered pricing for gpt-5.4 based on 272K token threshold

* 🌐 chore: update i18n translations

* 🐛 fix: update claude-sonnet model version to 4-6 in planCardModels

*  feat: add GPT-5.4 Pro model support

* 🐛 fix: remove dated snapshot for gpt-5.4-pro in responsesAPIModels

* 🐛 fix: add tierBy support for cross-unit tiered pricing threshold

OpenAI charges output at 1.5x when INPUT exceeds 272K tokens.
The tiered strategy previously only checked the unit's own quantity
to select a tier. Added optional tierBy field to TieredPricingUnit
so output/cacheRead tiers can reference input quantity for selection.

* 🐛 fix: use totalInputTokens for tiered pricing tier selection

Tiered pricing tiers should be determined by total prompt size
(totalInputTokens), not each unit's own quantity. This fixes output
and cacheRead being charged at the wrong tier rate when the prompt
exceeds the threshold but the individual unit quantity does not.
2026-03-06 13:47:31 +08:00
Arvin Xu 76a07d811b feat: init lobehub-cli (#12735)
* init cli project

* Potential fix for code scanning alert no. 184: Uncontrolled command line

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* update

* Potential fix for code scanning alert no. 185: Uncontrolled command line

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-03-06 11:42:29 +08:00
LobeHub Bot 616d53e2ec 🌐 chore: translate non-English comments to English in ChatInput/ActionBar/Tools (#12663)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 11:27:27 +08:00
lobehubbot 6c1c60ee27 🔖 chore(release): release version v2.1.36 [skip ci] 2026-03-05 12:45:01 +00:00
271 changed files with 9770 additions and 1281 deletions
@@ -83,17 +83,15 @@ runs:
fi
done
# 2. 创建 {channel}*.yml (从 latest*.yml 复制,URL 加版本目录前缀)
# electron-builder 始终生成 latest*.yml,不区分 channel
# electron-updater 在对应 channel 时会找 {channel}-mac.yml
# 2. 为所有 yml manifest 的 URL 加版本目录前缀
# merge-mac-files 步骤已生成 {channel}*.yml (如 canary-mac.yml)
# 安装包在 s3://$BUCKET/$CHANNEL/$VERSION/ 下,URL 需加 $VERSION/ 前缀
echo ""
echo "📋 Creating ${CHANNEL}*.yml files from latest*.yml..."
for yml in release/latest*.yml; do
echo "📋 Adding version prefix to yml manifest URLs..."
for yml in release/${CHANNEL}*.yml release/latest*.yml; do
if [ -f "$yml" ]; then
channel_name=$(basename "$yml" | sed "s/latest/$CHANNEL/")
# url: xxx.dmg -> url: {VERSION}/xxx.dmg
sed "s|url: |url: $VERSION/|g" "$yml" > "release/$channel_name"
echo " 📄 Created $channel_name from $(basename $yml) with URL prefix: $VERSION/"
sed -i "s|url: |url: $VERSION/|g" "$yml"
echo " 📄 Updated $(basename $yml) with URL prefix: $VERSION/"
fi
done
+37 -8
View File
@@ -72,6 +72,23 @@ jobs:
git checkout main
git pull --rebase origin main
- name: Setup Node.js
if: steps.release.outputs.should_tag == 'true' || steps.patch.outputs.should_tag == 'true'
uses: actions/setup-node@v6
with:
node-version: 24.11.1
package-manager-cache: false
- name: Install bun
if: steps.release.outputs.should_tag == 'true' || steps.patch.outputs.should_tag == 'true'
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install deps
if: steps.release.outputs.should_tag == 'true' || steps.patch.outputs.should_tag == 'true'
run: bun i
- name: Resolve patch version (patch bump)
id: patch-version
if: steps.patch.outputs.should_tag == 'true'
@@ -117,12 +134,10 @@ jobs:
echo "✅ Tag v$VERSION does not exist, can create"
fi
- name: Bump package.json version (before tagging)
- name: Bump package.json version
if: env.SHOULD_TAG == 'true' && steps.check-tag.outputs.exists == 'false'
id: bump-version
run: |
VERSION="${{ env.VERSION }}"
KIND="${{ env.KIND }}"
echo "📝 Bumping package.json version to: $VERSION"
# Validate VERSION is strict semver before writing
@@ -131,10 +146,6 @@ jobs:
exit 1
fi
# Configure git
git config --global user.name "lobehubbot"
git config --global user.email "i@lobehub.com"
# Update package.json using Node.js
node -e "
const fs = require('fs');
@@ -149,8 +160,26 @@ jobs:
console.log('✅ package.json updated to', target);
"
- name: Generate changelog
if: env.SHOULD_TAG == 'true' && steps.check-tag.outputs.exists == 'false'
run: bun run workflow:changelog:gen
- name: Build static changelog
if: env.SHOULD_TAG == 'true' && steps.check-tag.outputs.exists == 'false'
run: bun run workflow:changelog
- name: Commit release changes and push
if: env.SHOULD_TAG == 'true' && steps.check-tag.outputs.exists == 'false'
id: bump-version
run: |
VERSION="${{ env.VERSION }}"
# Configure git
git config --global user.name "lobehubbot"
git config --global user.email "i@lobehub.com"
# Commit changes (if any) and push
git add package.json
git add package.json CHANGELOG.md changelog/
COMMIT_MSG="🔖 chore(release): release version v$VERSION [skip ci]"
git commit -m "$COMMIT_MSG" || echo "Nothing to commit"
git push origin HEAD:main
+2 -1
View File
@@ -236,7 +236,8 @@ jobs:
if: runner.os == 'Linux'
run: |
npm run desktop:package:app
tar -czf apps/desktop/release/lobehub-renderer.tar.gz -C out .
test -d apps/desktop/dist/renderer
tar -czf apps/desktop/release/lobehub-renderer.tar.gz -C apps/desktop/dist/renderer .
env:
UPDATE_CHANNEL: stable
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
-32
View File
@@ -66,38 +66,6 @@ jobs:
- name: Test App
run: bun run test-app
- name: Extract version from tag
id: get-version
run: |
# Extract version from github.ref (refs/tags/v1.0.0 -> 1.0.0)
VERSION=${GITHUB_REF#refs/tags/v}
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "📦 Release version: v$VERSION"
- name: Verify package.json version matches tag
run: |
VERSION="${{ steps.get-version.outputs.version }}"
echo "🔎 Checking package.json version equals tag: $VERSION"
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8'));
const expected = '$VERSION';
const actual = pkg.version;
if (actual !== expected) {
console.error('❌ Version mismatch: package.json=' + actual + ' tag=' + expected);
process.exit(1);
}
console.log('✅ Version OK:', actual);
"
- name: Release
run: bun run release
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
# Pass version to semantic-release
SEMANTIC_RELEASE_VERSION: ${{ steps.get-version.outputs.version }}
- name: Workflow
run: bun run workflow:readme
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@lobehub/cli",
"version": "0.0.1-canary.1",
"private": true,
"bin": {
"lh": "./src/index.ts"
},
"scripts": {
"test": "bunx vitest run --config vitest.config.mts --silent='passed-only'",
"test:coverage": "bunx vitest run --config vitest.config.mts --coverage",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@lobechat/device-gateway-client": "workspace:*",
"commander": "^13.1.0",
"diff": "^7.0.0",
"fast-glob": "^3.3.3",
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/diff": "^6.0.0",
"@types/node": "^22.13.5",
"typescript": "^5.9.3"
}
}
+132
View File
@@ -0,0 +1,132 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
clearCredentials,
loadCredentials,
saveCredentials,
type StoredCredentials,
} from './credentials';
// Use a fixed temp path to avoid hoisting issues with vi.mock
const tmpDir = path.join(os.tmpdir(), 'lobehub-cli-test-creds');
const credentialsDir = path.join(tmpDir, '.lobehub');
const credentialsFile = path.join(credentialsDir, 'credentials.json');
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<Record<string, any>>();
return {
...actual,
default: {
...actual['default'],
homedir: () => path.join(os.tmpdir(), 'lobehub-cli-test-creds'),
},
};
});
describe('credentials', () => {
beforeEach(() => {
fs.mkdirSync(tmpDir, { recursive: true });
});
afterEach(() => {
fs.rmSync(tmpDir, { force: true, recursive: true });
});
const testCredentials: StoredCredentials = {
accessToken: 'test-access-token',
expiresAt: Math.floor(Date.now() / 1000) + 3600,
refreshToken: 'test-refresh-token',
serverUrl: 'https://app.lobehub.com',
};
describe('saveCredentials + loadCredentials', () => {
it('should save and load credentials successfully', () => {
saveCredentials(testCredentials);
const loaded = loadCredentials();
expect(loaded).toEqual(testCredentials);
});
it('should create directory with correct permissions', () => {
saveCredentials(testCredentials);
expect(fs.existsSync(credentialsDir)).toBe(true);
});
it('should encrypt the credentials file', () => {
saveCredentials(testCredentials);
const raw = fs.readFileSync(credentialsFile, 'utf8');
// Should not be plain JSON
expect(() => JSON.parse(raw)).toThrow();
// Should be base64
expect(Buffer.from(raw, 'base64').length).toBeGreaterThan(0);
});
it('should handle credentials without optional fields', () => {
const minimal: StoredCredentials = {
accessToken: 'tok',
serverUrl: 'https://test.com',
};
saveCredentials(minimal);
const loaded = loadCredentials();
expect(loaded).toEqual(minimal);
});
});
describe('loadCredentials', () => {
it('should return null when no credentials file exists', () => {
const result = loadCredentials();
expect(result).toBeNull();
});
it('should handle legacy plaintext JSON and re-encrypt', () => {
fs.mkdirSync(credentialsDir, { recursive: true });
fs.writeFileSync(credentialsFile, JSON.stringify(testCredentials));
const loaded = loadCredentials();
expect(loaded).toEqual(testCredentials);
// Should have been re-encrypted
const raw = fs.readFileSync(credentialsFile, 'utf8');
expect(() => JSON.parse(raw)).toThrow();
});
it('should return null for corrupted file', () => {
fs.mkdirSync(credentialsDir, { recursive: true });
fs.writeFileSync(credentialsFile, 'not-valid-base64-or-json!!!');
const result = loadCredentials();
expect(result).toBeNull();
});
});
describe('clearCredentials', () => {
it('should remove credentials file and return true', () => {
saveCredentials(testCredentials);
const result = clearCredentials();
expect(result).toBe(true);
expect(fs.existsSync(credentialsFile)).toBe(false);
});
it('should return false when no file exists', () => {
const result = clearCredentials();
expect(result).toBe(false);
});
});
});
+77
View File
@@ -0,0 +1,77 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
export interface StoredCredentials {
accessToken: string;
expiresAt?: number; // Unix timestamp (seconds)
refreshToken?: string;
serverUrl: string;
}
const CREDENTIALS_DIR = path.join(os.homedir(), '.lobehub');
const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, 'credentials.json');
// Derive an encryption key from machine-specific info
// Not bulletproof, but prevents casual reading of the credentials file
function deriveKey(): Buffer {
const material = `lobehub-cli:${os.hostname()}:${os.userInfo().username}`;
return crypto.pbkdf2Sync(material, 'lobehub-cli-salt', 100_000, 32, 'sha256');
}
function encrypt(plaintext: string): string {
const key = deriveKey();
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
// Pack: iv(12) + authTag(16) + ciphertext
const packed = Buffer.concat([iv, authTag, encrypted]);
return packed.toString('base64');
}
function decrypt(encoded: string): string {
const key = deriveKey();
const packed = Buffer.from(encoded, 'base64');
const iv = packed.subarray(0, 12);
const authTag = packed.subarray(12, 28);
const ciphertext = packed.subarray(28);
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(authTag);
return decipher.update(ciphertext) + decipher.final('utf8');
}
export function saveCredentials(credentials: StoredCredentials): void {
fs.mkdirSync(CREDENTIALS_DIR, { mode: 0o700, recursive: true });
const encrypted = encrypt(JSON.stringify(credentials));
fs.writeFileSync(CREDENTIALS_FILE, encrypted, { mode: 0o600 });
}
export function loadCredentials(): StoredCredentials | null {
try {
const data = fs.readFileSync(CREDENTIALS_FILE, 'utf8');
// Try decrypting first
try {
const decrypted = decrypt(data);
return JSON.parse(decrypted) as StoredCredentials;
} catch {
// Fallback: handle legacy plaintext JSON, re-save encrypted
const credentials = JSON.parse(data) as StoredCredentials;
saveCredentials(credentials);
return credentials;
}
} catch {
return null;
}
}
export function clearCredentials(): boolean {
try {
fs.unlinkSync(CREDENTIALS_FILE);
return true;
} catch {
return false;
}
}
+229
View File
@@ -0,0 +1,229 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { StoredCredentials } from './credentials';
import { loadCredentials, saveCredentials } from './credentials';
import { getValidToken } from './refresh';
vi.mock('./credentials', () => ({
loadCredentials: vi.fn(),
saveCredentials: vi.fn(),
}));
describe('getValidToken', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn());
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should return null when no credentials stored', async () => {
vi.mocked(loadCredentials).mockReturnValue(null);
const result = await getValidToken();
expect(result).toBeNull();
});
it('should return credentials when token is still valid', async () => {
const creds: StoredCredentials = {
accessToken: 'valid-token',
expiresAt: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
refreshToken: 'refresh-tok',
serverUrl: 'https://app.lobehub.com',
};
vi.mocked(loadCredentials).mockReturnValue(creds);
const result = await getValidToken();
expect(result).toEqual({ credentials: creds });
expect(fetch).not.toHaveBeenCalled();
});
it('should return credentials when no expiresAt is set', async () => {
const creds: StoredCredentials = {
accessToken: 'valid-token',
serverUrl: 'https://app.lobehub.com',
};
vi.mocked(loadCredentials).mockReturnValue(creds);
const result = await getValidToken();
// expiresAt is undefined, so Date.now()/1000 < undefined - 60 is false (NaN comparison)
// This means it will try to refresh, but there's no refreshToken
expect(result).toBeNull();
});
it('should return null when token expired and no refresh token', async () => {
const creds: StoredCredentials = {
accessToken: 'expired-token',
expiresAt: Math.floor(Date.now() / 1000) - 100, // expired
serverUrl: 'https://app.lobehub.com',
};
vi.mocked(loadCredentials).mockReturnValue(creds);
const result = await getValidToken();
expect(result).toBeNull();
});
it('should refresh and save updated credentials when token is expired', async () => {
const creds: StoredCredentials = {
accessToken: 'expired-token',
expiresAt: Math.floor(Date.now() / 1000) - 100,
refreshToken: 'valid-refresh-token',
serverUrl: 'https://app.lobehub.com',
};
vi.mocked(loadCredentials).mockReturnValue(creds);
vi.mocked(fetch).mockResolvedValue({
json: vi.fn().mockResolvedValue({
access_token: 'new-access-token',
expires_in: 3600,
refresh_token: 'new-refresh-token',
token_type: 'Bearer',
}),
ok: true,
} as any);
const result = await getValidToken();
expect(result).not.toBeNull();
expect(result!.credentials.accessToken).toBe('new-access-token');
expect(result!.credentials.refreshToken).toBe('new-refresh-token');
expect(saveCredentials).toHaveBeenCalledWith(
expect.objectContaining({ accessToken: 'new-access-token' }),
);
});
it('should keep old refresh token if new one is not returned', async () => {
const creds: StoredCredentials = {
accessToken: 'expired-token',
expiresAt: Math.floor(Date.now() / 1000) - 100,
refreshToken: 'old-refresh-token',
serverUrl: 'https://app.lobehub.com',
};
vi.mocked(loadCredentials).mockReturnValue(creds);
vi.mocked(fetch).mockResolvedValue({
json: vi.fn().mockResolvedValue({
access_token: 'new-access-token',
token_type: 'Bearer',
}),
ok: true,
} as any);
const result = await getValidToken();
expect(result!.credentials.refreshToken).toBe('old-refresh-token');
expect(result!.credentials.expiresAt).toBeUndefined();
});
it('should return null when refresh request fails (non-ok)', async () => {
const creds: StoredCredentials = {
accessToken: 'expired-token',
expiresAt: Math.floor(Date.now() / 1000) - 100,
refreshToken: 'valid-refresh-token',
serverUrl: 'https://app.lobehub.com',
};
vi.mocked(loadCredentials).mockReturnValue(creds);
vi.mocked(fetch).mockResolvedValue({
json: vi.fn().mockResolvedValue({}),
ok: false,
status: 401,
} as any);
const result = await getValidToken();
expect(result).toBeNull();
});
it('should return null when refresh response has error field', async () => {
const creds: StoredCredentials = {
accessToken: 'expired-token',
expiresAt: Math.floor(Date.now() / 1000) - 100,
refreshToken: 'valid-refresh-token',
serverUrl: 'https://app.lobehub.com',
};
vi.mocked(loadCredentials).mockReturnValue(creds);
vi.mocked(fetch).mockResolvedValue({
json: vi.fn().mockResolvedValue({ error: 'invalid_grant' }),
ok: true,
} as any);
const result = await getValidToken();
expect(result).toBeNull();
});
it('should return null when refresh response has no access_token', async () => {
const creds: StoredCredentials = {
accessToken: 'expired-token',
expiresAt: Math.floor(Date.now() / 1000) - 100,
refreshToken: 'valid-refresh-token',
serverUrl: 'https://app.lobehub.com',
};
vi.mocked(loadCredentials).mockReturnValue(creds);
vi.mocked(fetch).mockResolvedValue({
json: vi.fn().mockResolvedValue({ token_type: 'Bearer' }),
ok: true,
} as any);
const result = await getValidToken();
expect(result).toBeNull();
});
it('should return null when network error occurs during refresh', async () => {
const creds: StoredCredentials = {
accessToken: 'expired-token',
expiresAt: Math.floor(Date.now() / 1000) - 100,
refreshToken: 'valid-refresh-token',
serverUrl: 'https://app.lobehub.com',
};
vi.mocked(loadCredentials).mockReturnValue(creds);
vi.mocked(fetch).mockRejectedValue(new Error('network error'));
const result = await getValidToken();
expect(result).toBeNull();
});
it('should send correct request to refresh endpoint', async () => {
const creds: StoredCredentials = {
accessToken: 'expired-token',
expiresAt: Math.floor(Date.now() / 1000) - 100,
refreshToken: 'my-refresh-token',
serverUrl: 'https://my-server.com',
};
vi.mocked(loadCredentials).mockReturnValue(creds);
vi.mocked(fetch).mockResolvedValue({
json: vi.fn().mockResolvedValue({
access_token: 'new-token',
token_type: 'Bearer',
}),
ok: true,
} as any);
await getValidToken();
expect(fetch).toHaveBeenCalledWith(
'https://my-server.com/oidc/token',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
}),
);
const body = vi.mocked(fetch).mock.calls[0][1]?.body as URLSearchParams;
expect(body.get('grant_type')).toBe('refresh_token');
expect(body.get('refresh_token')).toBe('my-refresh-token');
expect(body.get('client_id')).toBe('lobehub-cli');
});
});
+67
View File
@@ -0,0 +1,67 @@
import { loadCredentials, saveCredentials, type StoredCredentials } from './credentials';
const CLIENT_ID = 'lobehub-cli';
/**
* Get a valid access token, refreshing if expired.
* Returns null if no credentials or refresh fails.
*/
export async function getValidToken(): Promise<{ credentials: StoredCredentials } | null> {
const credentials = loadCredentials();
if (!credentials) return null;
// Check if token is still valid (with 60s buffer)
if (credentials.expiresAt && Date.now() / 1000 < credentials.expiresAt - 60) {
return { credentials };
}
// Token expired — try refresh
if (!credentials.refreshToken) return null;
const refreshed = await refreshAccessToken(credentials.serverUrl, credentials.refreshToken);
if (!refreshed) return null;
const updated: StoredCredentials = {
accessToken: refreshed.access_token,
expiresAt: refreshed.expires_in
? Math.floor(Date.now() / 1000) + refreshed.expires_in
: undefined,
refreshToken: refreshed.refresh_token || credentials.refreshToken,
serverUrl: credentials.serverUrl,
};
saveCredentials(updated);
return { credentials: updated };
}
interface TokenResponse {
access_token: string;
expires_in?: number;
refresh_token?: string;
token_type: string;
}
async function refreshAccessToken(
serverUrl: string,
refreshToken: string,
): Promise<TokenResponse | null> {
try {
const res = await fetch(`${serverUrl}/oidc/token`, {
body: new URLSearchParams({
client_id: CLIENT_ID,
grant_type: 'refresh_token',
refresh_token: refreshToken,
}),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
method: 'POST',
});
const body = (await res.json()) as TokenResponse & { error?: string };
if (!res.ok || body.error || !body.access_token) return null;
return body;
} catch {
return null;
}
}
+117
View File
@@ -0,0 +1,117 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getValidToken } from './refresh';
import { resolveToken } from './resolveToken';
vi.mock('./refresh', () => ({
getValidToken: vi.fn(),
}));
vi.mock('../utils/logger', () => ({
log: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
}));
// Helper to create a valid JWT with sub claim
function makeJwt(sub: string): string {
const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url');
const payload = Buffer.from(JSON.stringify({ sub })).toString('base64url');
return `${header}.${payload}.signature`;
}
describe('resolveToken', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit');
});
});
afterEach(() => {
exitSpy.mockRestore();
});
describe('with explicit --token', () => {
it('should return token and userId from JWT', async () => {
const token = makeJwt('user-123');
const result = await resolveToken({ token });
expect(result).toEqual({ token, userId: 'user-123' });
});
it('should exit if JWT has no sub claim', async () => {
const header = Buffer.from('{}').toString('base64url');
const payload = Buffer.from('{}').toString('base64url');
const token = `${header}.${payload}.sig`;
await expect(resolveToken({ token })).rejects.toThrow('process.exit');
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should exit if JWT is malformed', async () => {
await expect(resolveToken({ token: 'not-a-jwt' })).rejects.toThrow('process.exit');
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe('with --service-token', () => {
it('should return token and userId', async () => {
const result = await resolveToken({
serviceToken: 'svc-token',
userId: 'user-456',
});
expect(result).toEqual({ token: 'svc-token', userId: 'user-456' });
});
it('should exit if --user-id is not provided', async () => {
await expect(resolveToken({ serviceToken: 'svc-token' })).rejects.toThrow('process.exit');
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe('with stored credentials', () => {
it('should return stored credentials token', async () => {
const token = makeJwt('stored-user');
vi.mocked(getValidToken).mockResolvedValue({
credentials: {
accessToken: token,
serverUrl: 'https://app.lobehub.com',
},
});
const result = await resolveToken({});
expect(result).toEqual({ token, userId: 'stored-user' });
});
it('should exit if stored token has no sub', async () => {
const header = Buffer.from('{}').toString('base64url');
const payload = Buffer.from('{}').toString('base64url');
const token = `${header}.${payload}.sig`;
vi.mocked(getValidToken).mockResolvedValue({
credentials: {
accessToken: token,
serverUrl: 'https://app.lobehub.com',
},
});
await expect(resolveToken({})).rejects.toThrow('process.exit');
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should exit when no stored credentials', async () => {
vi.mocked(getValidToken).mockResolvedValue(null);
await expect(resolveToken({})).rejects.toThrow('process.exit');
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
});
+65
View File
@@ -0,0 +1,65 @@
import { log } from '../utils/logger';
import { getValidToken } from './refresh';
interface ResolveTokenOptions {
serviceToken?: string;
token?: string;
userId?: string;
}
interface ResolvedAuth {
token: string;
userId: string;
}
/**
* Parse the `sub` claim from a JWT without verifying the signature.
*/
function parseJwtSub(token: string): string | undefined {
try {
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString());
return payload.sub;
} catch {
return undefined;
}
}
/**
* Resolve an access token from explicit options or stored credentials.
* Exits the process if no token can be resolved.
*/
export async function resolveToken(options: ResolveTokenOptions): Promise<ResolvedAuth> {
// Explicit token takes priority
if (options.token) {
const userId = parseJwtSub(options.token);
if (!userId) {
log.error('Could not extract userId from token. Provide --user-id explicitly.');
process.exit(1);
}
return { token: options.token, userId };
}
if (options.serviceToken) {
if (!options.userId) {
log.error('--user-id is required when using --service-token');
process.exit(1);
}
return { token: options.serviceToken, userId: options.userId };
}
// Try stored credentials
const result = await getValidToken();
if (result) {
log.debug('Using stored credentials');
const token = result.credentials.accessToken;
const userId = parseJwtSub(token);
if (!userId) {
log.error("Stored token is invalid. Run 'lh login' again.");
process.exit(1);
}
return { token, userId };
}
log.error("No authentication found. Run 'lh login' first, or provide --token.");
process.exit(1);
}
+254
View File
@@ -0,0 +1,254 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../auth/resolveToken', () => ({
resolveToken: vi.fn().mockResolvedValue({ token: 'test-token', userId: 'test-user' }),
}));
vi.mock('../utils/logger', () => ({
log: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
toolCall: vi.fn(),
toolResult: vi.fn(),
warn: vi.fn(),
},
setVerbose: vi.fn(),
}));
vi.mock('../tools/shell', () => ({
cleanupAllProcesses: vi.fn(),
}));
vi.mock('../tools', () => ({
executeToolCall: vi.fn().mockResolvedValue({
content: 'tool result',
success: true,
}),
}));
let clientEventHandlers: Record<string, (...args: any[]) => any> = {};
let connectCalled = false;
let lastSentToolResponse: any = null;
let lastSentSystemInfoResponse: any = null;
vi.mock('@lobechat/device-gateway-client', () => ({
GatewayClient: vi.fn().mockImplementation(() => {
clientEventHandlers = {};
connectCalled = false;
lastSentToolResponse = null;
lastSentSystemInfoResponse = null;
return {
connect: vi.fn().mockImplementation(async () => {
connectCalled = true;
}),
currentDeviceId: 'mock-device-id',
disconnect: vi.fn(),
on: vi.fn().mockImplementation((event: string, handler: (...args: any[]) => any) => {
clientEventHandlers[event] = handler;
}),
sendSystemInfoResponse: vi.fn().mockImplementation((data: any) => {
lastSentSystemInfoResponse = data;
}),
sendToolCallResponse: vi.fn().mockImplementation((data: any) => {
lastSentToolResponse = data;
}),
};
}),
}));
// eslint-disable-next-line import-x/first
import { resolveToken } from '../auth/resolveToken';
// eslint-disable-next-line import-x/first
import { executeToolCall } from '../tools';
// eslint-disable-next-line import-x/first
import { cleanupAllProcesses } from '../tools/shell';
// eslint-disable-next-line import-x/first
import { log, setVerbose } from '../utils/logger';
// eslint-disable-next-line import-x/first
import { registerConnectCommand } from './connect';
describe('connect command', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
});
afterEach(() => {
exitSpy.mockRestore();
vi.clearAllMocks();
});
function createProgram() {
const program = new Command();
program.exitOverride();
registerConnectCommand(program);
return program;
}
it('should connect to gateway', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
expect(connectCalled).toBe(true);
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('LobeHub CLI'));
});
it('should handle tool call requests', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
// Trigger tool call
await clientEventHandlers['tool_call_request']?.({
requestId: 'req-1',
toolCall: { apiName: 'readLocalFile', arguments: '{"path":"/test"}', identifier: 'test' },
type: 'tool_call_request',
});
expect(executeToolCall).toHaveBeenCalledWith('readLocalFile', '{"path":"/test"}');
expect(lastSentToolResponse).toEqual({
requestId: 'req-1',
result: { content: 'tool result', error: undefined, success: true },
});
});
it('should handle system info requests', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
clientEventHandlers['system_info_request']?.({
requestId: 'req-2',
type: 'system_info_request',
});
expect(lastSentSystemInfoResponse).toBeDefined();
expect(lastSentSystemInfoResponse.requestId).toBe('req-2');
expect(lastSentSystemInfoResponse.result.success).toBe(true);
expect(lastSentSystemInfoResponse.result.systemInfo).toHaveProperty('homePath');
expect(lastSentSystemInfoResponse.result.systemInfo).toHaveProperty('arch');
});
it('should handle auth_failed', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
clientEventHandlers['auth_failed']?.('invalid token');
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Authentication failed'));
expect(cleanupAllProcesses).toHaveBeenCalled();
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should handle auth_expired', async () => {
vi.mocked(resolveToken).mockResolvedValueOnce({ token: 'new-tok', userId: 'user' });
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
await clientEventHandlers['auth_expired']?.();
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('expired'));
expect(cleanupAllProcesses).toHaveBeenCalled();
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should handle error event', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
clientEventHandlers['error']?.(new Error('connection lost'));
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('connection lost'));
});
it('should set verbose mode when -v flag is passed', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect', '-v']);
expect(setVerbose).toHaveBeenCalledWith(true);
});
it('should show service-token auth type', async () => {
const program = createProgram();
await program.parseAsync([
'node',
'test',
'connect',
'--service-token',
'svc-tok',
'--user-id',
'u1',
]);
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('service-token'));
});
it('should handle SIGINT', async () => {
const sigintHandlers: Array<() => void> = [];
const origOn = process.on;
vi.spyOn(process, 'on').mockImplementation((event: any, handler: any) => {
if (event === 'SIGINT') sigintHandlers.push(handler);
return origOn.call(process, event, handler);
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
// Trigger SIGINT handler
for (const handler of sigintHandlers) {
handler();
}
expect(cleanupAllProcesses).toHaveBeenCalled();
});
it('should handle auth_expired when refresh fails', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
// After initial connect, mock resolveToken to return falsy for the refresh attempt
vi.mocked(resolveToken).mockResolvedValueOnce(undefined as any);
await clientEventHandlers['auth_expired']?.();
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Could not refresh'));
expect(cleanupAllProcesses).toHaveBeenCalled();
});
it('should handle SIGTERM', async () => {
const sigtermHandlers: Array<() => void> = [];
const origOn = process.on;
vi.spyOn(process, 'on').mockImplementation((event: any, handler: any) => {
if (event === 'SIGTERM') sigtermHandlers.push(handler);
return origOn.call(process, event, handler);
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
for (const handler of sigtermHandlers) {
handler();
}
expect(cleanupAllProcesses).toHaveBeenCalled();
});
it('should generate correct system info with Movies for non-linux', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
clientEventHandlers['system_info_request']?.({
requestId: 'req-3',
type: 'system_info_request',
});
const sysInfo = lastSentSystemInfoResponse.result.systemInfo;
// On macOS (darwin), video dir should be Movies
if (process.platform !== 'linux') {
expect(sysInfo.videosPath).toContain('Movies');
} else {
expect(sysInfo.videosPath).toContain('Videos');
}
});
});
+153
View File
@@ -0,0 +1,153 @@
import os from 'node:os';
import path from 'node:path';
import type {
DeviceSystemInfo,
SystemInfoRequestMessage,
ToolCallRequestMessage,
} from '@lobechat/device-gateway-client';
import { GatewayClient } from '@lobechat/device-gateway-client';
import type { Command } from 'commander';
import { resolveToken } from '../auth/resolveToken';
import { executeToolCall } from '../tools';
import { cleanupAllProcesses } from '../tools/shell';
import { log, setVerbose } from '../utils/logger';
interface ConnectOptions {
deviceId?: string;
gateway?: string;
serviceToken?: string;
token?: string;
userId?: string;
verbose?: boolean;
}
export function registerConnectCommand(program: Command) {
program
.command('connect')
.description('Connect to the device gateway and listen for tool calls')
.option('--token <jwt>', 'JWT access token')
.option('--service-token <token>', 'Service token (requires --user-id)')
.option('--user-id <id>', 'User ID (required with --service-token)')
.option('--gateway <url>', 'Gateway URL', 'https://device-gateway.lobehub.com')
.option('--device-id <id>', 'Device ID (auto-generated if not provided)')
.option('-v, --verbose', 'Enable verbose logging')
.action(async (options: ConnectOptions) => {
if (options.verbose) setVerbose(true);
const auth = await resolveToken(options);
const client = new GatewayClient({
deviceId: options.deviceId,
gatewayUrl: options.gateway,
logger: log,
token: auth.token,
userId: auth.userId,
});
// Print device info
log.info('─── LobeHub CLI ───');
log.info(` Device ID : ${client.currentDeviceId}`);
log.info(` Hostname : ${os.hostname()}`);
log.info(` Platform : ${process.platform}`);
log.info(` Gateway : ${options.gateway || 'https://device-gateway.lobehub.com'}`);
log.info(` Auth : ${options.serviceToken ? 'service-token' : 'jwt'}`);
log.info('───────────────────');
// Handle system info requests
client.on('system_info_request', (request: SystemInfoRequestMessage) => {
log.info(`Received system_info_request: requestId=${request.requestId}`);
const systemInfo = collectSystemInfo();
client.sendSystemInfoResponse({
requestId: request.requestId,
result: { success: true, systemInfo },
});
});
// Handle tool call requests
client.on('tool_call_request', async (request: ToolCallRequestMessage) => {
const { requestId, toolCall } = request;
log.toolCall(toolCall.apiName, requestId, toolCall.arguments);
const result = await executeToolCall(toolCall.apiName, toolCall.arguments);
log.toolResult(requestId, result.success, result.content);
client.sendToolCallResponse({
requestId,
result: {
content: result.content,
error: result.error,
success: result.success,
},
});
});
// Handle auth failed
client.on('auth_failed', (reason) => {
log.error(`Authentication failed: ${reason}`);
log.error("Run 'lh login' to re-authenticate.");
cleanup();
process.exit(1);
});
// Handle auth expired — try refresh before giving up
client.on('auth_expired', async () => {
log.warn('Authentication expired. Attempting to refresh...');
const refreshed = await resolveToken({});
if (refreshed) {
log.info('Token refreshed. Please reconnect.');
} else {
log.error("Could not refresh token. Run 'lh login' to re-authenticate.");
}
cleanup();
process.exit(1);
});
// Handle errors
client.on('error', (error) => {
log.error(`Connection error: ${error.message}`);
});
// Graceful shutdown
const cleanup = () => {
log.info('Shutting down...');
cleanupAllProcesses();
client.disconnect();
};
process.on('SIGINT', () => {
cleanup();
process.exit(0);
});
process.on('SIGTERM', () => {
cleanup();
process.exit(0);
});
// Connect
await client.connect();
});
}
function collectSystemInfo(): DeviceSystemInfo {
const home = os.homedir();
const platform = process.platform;
// Platform-specific video path name
const videosDir = platform === 'linux' ? 'Videos' : 'Movies';
return {
arch: os.arch(),
desktopPath: path.join(home, 'Desktop'),
documentsPath: path.join(home, 'Documents'),
downloadsPath: path.join(home, 'Downloads'),
homePath: home,
musicPath: path.join(home, 'Music'),
picturesPath: path.join(home, 'Pictures'),
userDataPath: path.join(home, '.lobehub'),
videosPath: path.join(home, videosDir),
workingDirectory: process.cwd(),
};
}
+250
View File
@@ -0,0 +1,250 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { saveCredentials } from '../auth/credentials';
import { log } from '../utils/logger';
import { registerLoginCommand } from './login';
vi.mock('../auth/credentials', () => ({
saveCredentials: vi.fn(),
}));
vi.mock('../utils/logger', () => ({
log: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
}));
// Mock child_process.exec to prevent browser opening
vi.mock('node:child_process', () => ({
exec: vi.fn((_cmd: string, cb: any) => cb?.(null)),
}));
describe('login command', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal('fetch', vi.fn());
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
});
afterEach(() => {
vi.useRealTimers();
exitSpy.mockRestore();
vi.restoreAllMocks();
});
function createProgram() {
const program = new Command();
program.exitOverride();
registerLoginCommand(program);
return program;
}
function deviceAuthResponse(overrides: Record<string, any> = {}) {
return {
json: vi.fn().mockResolvedValue({
device_code: 'device-123',
expires_in: 600,
interval: 1,
user_code: 'USER-CODE',
verification_uri: 'https://app.lobehub.com/verify',
verification_uri_complete: 'https://app.lobehub.com/verify?code=USER-CODE',
...overrides,
}),
ok: true,
} as any;
}
function tokenSuccessResponse(overrides: Record<string, any> = {}) {
return {
json: vi.fn().mockResolvedValue({
access_token: 'new-token',
expires_in: 3600,
refresh_token: 'refresh-tok',
token_type: 'Bearer',
...overrides,
}),
ok: true,
} as any;
}
function tokenErrorResponse(error: string, description?: string) {
return {
json: vi.fn().mockResolvedValue({
error,
error_description: description,
}),
ok: true,
} as any;
}
async function runLoginAndAdvanceTimers(program: Command, args: string[] = []) {
const parsePromise = program.parseAsync(['node', 'test', 'login', ...args]);
// Advance timers to let sleep resolve in the polling loop
for (let i = 0; i < 10; i++) {
await vi.advanceTimersByTimeAsync(2000);
}
return parsePromise;
}
it('should complete login flow successfully', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(deviceAuthResponse())
.mockResolvedValueOnce(tokenErrorResponse('authorization_pending'))
.mockResolvedValueOnce(tokenSuccessResponse());
const program = createProgram();
await runLoginAndAdvanceTimers(program);
expect(saveCredentials).toHaveBeenCalledWith(
expect.objectContaining({
accessToken: 'new-token',
refreshToken: 'refresh-tok',
serverUrl: 'https://app.lobehub.com',
}),
);
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Login successful'));
});
it('should strip trailing slash from server URL', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(deviceAuthResponse())
.mockResolvedValueOnce(tokenSuccessResponse());
const program = createProgram();
await runLoginAndAdvanceTimers(program, ['--server', 'https://test.com/']);
expect(fetch).toHaveBeenCalledWith('https://test.com/oidc/device/auth', expect.any(Object));
});
it('should handle device auth failure', async () => {
// For early-exit tests, process.exit must throw to stop code execution
// (otherwise code continues past exit and accesses undefined deviceAuth)
exitSpy.mockImplementation(() => {
throw new Error('exit');
});
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 500,
text: vi.fn().mockResolvedValue('Server Error'),
} as any);
const program = createProgram();
await runLoginAndAdvanceTimers(program).catch(() => {});
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Failed to start'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should handle network error on device auth', async () => {
exitSpy.mockImplementation(() => {
throw new Error('exit');
});
vi.mocked(fetch).mockRejectedValueOnce(new Error('ECONNREFUSED'));
const program = createProgram();
await runLoginAndAdvanceTimers(program).catch(() => {});
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Failed to reach'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should handle access_denied error', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(deviceAuthResponse({ expires_in: 2 }))
.mockResolvedValueOnce(tokenErrorResponse('access_denied'));
const program = createProgram();
await runLoginAndAdvanceTimers(program);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('denied'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should handle expired_token error', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(deviceAuthResponse({ expires_in: 2 }))
.mockResolvedValueOnce(tokenErrorResponse('expired_token'));
const program = createProgram();
await runLoginAndAdvanceTimers(program);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('expired'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should handle slow_down by increasing interval', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(deviceAuthResponse())
.mockResolvedValueOnce(tokenErrorResponse('slow_down'))
.mockResolvedValueOnce(tokenSuccessResponse());
const program = createProgram();
await runLoginAndAdvanceTimers(program);
expect(saveCredentials).toHaveBeenCalled();
});
it('should handle unknown error', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(deviceAuthResponse({ expires_in: 2 }))
.mockResolvedValueOnce(tokenErrorResponse('server_error', 'Something went wrong'));
const program = createProgram();
await runLoginAndAdvanceTimers(program);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('server_error'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should handle network error during polling', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(deviceAuthResponse())
.mockRejectedValueOnce(new Error('network'))
.mockResolvedValueOnce(tokenSuccessResponse());
const program = createProgram();
await runLoginAndAdvanceTimers(program);
expect(saveCredentials).toHaveBeenCalled();
});
it('should handle token without expires_in', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(deviceAuthResponse())
.mockResolvedValueOnce(tokenSuccessResponse({ expires_in: undefined }));
const program = createProgram();
await runLoginAndAdvanceTimers(program);
expect(saveCredentials).toHaveBeenCalledWith(expect.objectContaining({ expiresAt: undefined }));
});
it('should use default interval when not provided', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(deviceAuthResponse({ interval: undefined }))
.mockResolvedValueOnce(tokenSuccessResponse());
const program = createProgram();
await runLoginAndAdvanceTimers(program);
expect(saveCredentials).toHaveBeenCalled();
});
it('should handle device code expiration during polling', async () => {
vi.mocked(fetch).mockResolvedValueOnce(deviceAuthResponse({ expires_in: 0 }));
const program = createProgram();
await runLoginAndAdvanceTimers(program);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('expired'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
+178
View File
@@ -0,0 +1,178 @@
import { execFile } from 'node:child_process';
import type { Command } from 'commander';
import { saveCredentials } from '../auth/credentials';
import { log } from '../utils/logger';
const CLIENT_ID = 'lobehub-cli';
const SCOPES = 'openid profile email offline_access';
interface LoginOptions {
server: string;
}
interface DeviceAuthResponse {
device_code: string;
expires_in: number;
interval: number;
user_code: string;
verification_uri: string;
verification_uri_complete?: string;
}
interface TokenResponse {
access_token: string;
expires_in?: number;
refresh_token?: string;
token_type: string;
}
interface TokenErrorResponse {
error: string;
error_description?: string;
}
export function registerLoginCommand(program: Command) {
program
.command('login')
.description('Log in to LobeHub via browser (Device Code Flow)')
.option('--server <url>', 'LobeHub server URL', 'https://app.lobehub.com')
.action(async (options: LoginOptions) => {
const serverUrl = options.server.replace(/\/$/, '');
log.info('Starting login...');
// Step 1: Request device code
let deviceAuth: DeviceAuthResponse;
try {
const res = await fetch(`${serverUrl}/oidc/device/auth`, {
body: new URLSearchParams({
client_id: CLIENT_ID,
resource: 'urn:lobehub:chat',
scope: SCOPES,
}),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
method: 'POST',
});
if (!res.ok) {
const text = await res.text();
log.error(`Failed to start device authorization: ${res.status} ${text}`);
process.exit(1);
}
deviceAuth = (await res.json()) as DeviceAuthResponse;
} catch (error: any) {
log.error(`Failed to reach server: ${error.message}`);
log.error(`Make sure ${serverUrl} is reachable.`);
process.exit(1);
}
// Step 2: Show user code and open browser
const verifyUrl = deviceAuth.verification_uri_complete || deviceAuth.verification_uri;
log.info('');
log.info(' Open this URL in your browser:');
log.info(` ${verifyUrl}`);
log.info('');
log.info(` Enter code: ${deviceAuth.user_code}`);
log.info('');
// Try to open browser automatically
openBrowser(verifyUrl);
log.info('Waiting for authorization...');
// Step 3: Poll for token
const interval = (deviceAuth.interval || 5) * 1000;
const expiresAt = Date.now() + deviceAuth.expires_in * 1000;
let pollInterval = interval;
while (Date.now() < expiresAt) {
await sleep(pollInterval);
try {
const res = await fetch(`${serverUrl}/oidc/token`, {
body: new URLSearchParams({
client_id: CLIENT_ID,
device_code: deviceAuth.device_code,
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
}),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
method: 'POST',
});
const body = (await res.json()) as TokenResponse & TokenErrorResponse;
// Check body for error field — some proxies may return 200 for error responses
if (body.error) {
switch (body.error) {
case 'authorization_pending': {
// Keep polling
break;
}
case 'slow_down': {
pollInterval += 5000;
break;
}
case 'access_denied': {
log.error('Authorization denied by user.');
process.exit(1);
break;
}
case 'expired_token': {
log.error('Device code expired. Please run login again.');
process.exit(1);
break;
}
default: {
log.error(`Authorization error: ${body.error} - ${body.error_description || ''}`);
process.exit(1);
}
}
} else if (body.access_token) {
saveCredentials({
accessToken: body.access_token,
expiresAt: body.expires_in
? Math.floor(Date.now() / 1000) + body.expires_in
: undefined,
refreshToken: body.refresh_token,
serverUrl,
});
log.info('Login successful! Credentials saved.');
return;
}
} catch {
// Network error — keep retrying
}
}
log.error('Device code expired. Please run login again.');
process.exit(1);
});
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function openBrowser(url: string) {
if (process.platform === 'win32') {
// On Windows, use rundll32 to invoke the default URL handler without a shell.
execFile('rundll32', ['url.dll,FileProtocolHandler', url], (err) => {
if (err) {
log.debug(`Could not open browser automatically: ${err.message}`);
}
});
} else {
const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
execFile(cmd, [url], (err) => {
if (err) {
log.debug(`Could not open browser automatically: ${err.message}`);
}
});
}
}
+47
View File
@@ -0,0 +1,47 @@
import { Command } from 'commander';
import { describe, expect, it, vi } from 'vitest';
import { clearCredentials } from '../auth/credentials';
import { log } from '../utils/logger';
import { registerLogoutCommand } from './logout';
vi.mock('../auth/credentials', () => ({
clearCredentials: vi.fn(),
}));
vi.mock('../utils/logger', () => ({
log: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
}));
describe('logout command', () => {
function createProgram() {
const program = new Command();
program.exitOverride();
registerLogoutCommand(program);
return program;
}
it('should log success when credentials are removed', async () => {
vi.mocked(clearCredentials).mockReturnValue(true);
const program = createProgram();
await program.parseAsync(['node', 'test', 'logout']);
expect(clearCredentials).toHaveBeenCalled();
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Logged out'));
});
it('should log already logged out when no credentials', async () => {
vi.mocked(clearCredentials).mockReturnValue(false);
const program = createProgram();
await program.parseAsync(['node', 'test', 'logout']);
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Already logged out'));
});
});
+18
View File
@@ -0,0 +1,18 @@
import type { Command } from 'commander';
import { clearCredentials } from '../auth/credentials';
import { log } from '../utils/logger';
export function registerLogoutCommand(program: Command) {
program
.command('logout')
.description('Log out and remove stored credentials')
.action(() => {
const removed = clearCredentials();
if (removed) {
log.info('Logged out. Credentials removed.');
} else {
log.info('No credentials found. Already logged out.');
}
});
}
+164
View File
@@ -0,0 +1,164 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock resolveToken
vi.mock('../auth/resolveToken', () => ({
resolveToken: vi.fn().mockResolvedValue({ token: 'test-token', userId: 'test-user' }),
}));
vi.mock('../utils/logger', () => ({
log: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
setVerbose: vi.fn(),
}));
// Track event handlers registered on GatewayClient instances
let clientEventHandlers: Record<string, (...args: any[]) => any> = {};
let connectCalled = false;
let clientOptions: any = {};
vi.mock('@lobechat/device-gateway-client', () => ({
GatewayClient: vi.fn().mockImplementation((opts: any) => {
clientOptions = opts;
clientEventHandlers = {};
connectCalled = false;
return {
connect: vi.fn().mockImplementation(async () => {
connectCalled = true;
}),
disconnect: vi.fn(),
on: vi.fn().mockImplementation((event: string, handler: (...args: any[]) => any) => {
clientEventHandlers[event] = handler;
}),
};
}),
}));
// eslint-disable-next-line import-x/first
import { log } from '../utils/logger';
// eslint-disable-next-line import-x/first
import { registerStatusCommand } from './status';
describe('status command', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.useFakeTimers();
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
});
afterEach(() => {
vi.useRealTimers();
exitSpy.mockRestore();
vi.clearAllMocks();
});
function createProgram() {
const program = new Command();
program.exitOverride();
registerStatusCommand(program);
return program;
}
it('should create client with autoReconnect false', async () => {
const program = createProgram();
const parsePromise = program.parseAsync(['node', 'test', 'status']);
await vi.advanceTimersByTimeAsync(0);
// Trigger connected to finish the command
clientEventHandlers['connected']?.();
await parsePromise;
expect(clientOptions.autoReconnect).toBe(false);
});
it('should log CONNECTED on successful connection', async () => {
const program = createProgram();
const parsePromise = program.parseAsync(['node', 'test', 'status']);
await vi.advanceTimersByTimeAsync(0);
clientEventHandlers['connected']?.();
await parsePromise;
expect(log.info).toHaveBeenCalledWith('CONNECTED');
expect(exitSpy).toHaveBeenCalledWith(0);
});
it('should log FAILED on disconnected', async () => {
const program = createProgram();
const parsePromise = program.parseAsync(['node', 'test', 'status']);
await vi.advanceTimersByTimeAsync(0);
clientEventHandlers['disconnected']?.();
await parsePromise;
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('FAILED'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should log FAILED on auth_failed', async () => {
const program = createProgram();
const parsePromise = program.parseAsync(['node', 'test', 'status']);
await vi.advanceTimersByTimeAsync(0);
clientEventHandlers['auth_failed']?.('bad token');
await parsePromise;
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Authentication failed'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should log FAILED on auth_expired', async () => {
const program = createProgram();
const parsePromise = program.parseAsync(['node', 'test', 'status']);
await vi.advanceTimersByTimeAsync(0);
clientEventHandlers['auth_expired']?.();
await parsePromise;
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('expired'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should log connection error', async () => {
const program = createProgram();
const parsePromise = program.parseAsync(['node', 'test', 'status']);
await vi.advanceTimersByTimeAsync(0);
clientEventHandlers['error']?.(new Error('network issue'));
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('network issue'));
// Clean up by triggering connected
clientEventHandlers['connected']?.();
await parsePromise;
});
it('should timeout if no connection within timeout period', async () => {
const program = createProgram();
const parsePromise = program.parseAsync(['node', 'test', 'status', '--timeout', '5000']);
// Advance timer past timeout
await vi.advanceTimersByTimeAsync(5001);
await parsePromise;
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('timed out'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should call connect on the client', async () => {
const program = createProgram();
const parsePromise = program.parseAsync(['node', 'test', 'status']);
await vi.advanceTimersByTimeAsync(0);
expect(connectCalled).toBe(true);
// Clean up
clientEventHandlers['connected']?.();
await parsePromise;
});
});
+78
View File
@@ -0,0 +1,78 @@
import { GatewayClient } from '@lobechat/device-gateway-client';
import type { Command } from 'commander';
import { resolveToken } from '../auth/resolveToken';
import { log, setVerbose } from '../utils/logger';
interface StatusOptions {
gateway?: string;
serviceToken?: string;
timeout?: string;
token?: string;
userId?: string;
verbose?: boolean;
}
export function registerStatusCommand(program: Command) {
program
.command('status')
.description('Check if gateway connection can be established')
.option('--token <jwt>', 'JWT access token')
.option('--service-token <token>', 'Service token (requires --user-id)')
.option('--user-id <id>', 'User ID (required with --service-token)')
.option('--gateway <url>', 'Gateway URL', 'https://device-gateway.lobehub.com')
.option('--timeout <ms>', 'Connection timeout in ms', '10000')
.option('-v, --verbose', 'Enable verbose logging')
.action(async (options: StatusOptions) => {
if (options.verbose) setVerbose(true);
const auth = await resolveToken(options);
const timeout = Number.parseInt(options.timeout || '10000', 10);
const client = new GatewayClient({
autoReconnect: false,
gatewayUrl: options.gateway,
logger: log,
token: auth.token,
userId: auth.userId,
});
const timer = setTimeout(() => {
log.error('FAILED - Connection timed out');
client.disconnect();
process.exit(1);
}, timeout);
client.on('connected', () => {
clearTimeout(timer);
log.info('CONNECTED');
client.disconnect();
process.exit(0);
});
client.on('disconnected', () => {
clearTimeout(timer);
log.error('FAILED - Connection closed by server');
process.exit(1);
});
client.on('auth_failed', (reason) => {
clearTimeout(timer);
log.error(`FAILED - Authentication failed: ${reason}`);
process.exit(1);
});
client.on('auth_expired', () => {
clearTimeout(timer);
log.error('FAILED - Authentication expired');
client.disconnect();
process.exit(1);
});
client.on('error', (error) => {
log.error(`Connection error: ${error.message}`);
});
await client.connect();
});
}
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bun
import { Command } from 'commander';
import { registerConnectCommand } from './commands/connect';
import { registerLoginCommand } from './commands/login';
import { registerLogoutCommand } from './commands/logout';
import { registerStatusCommand } from './commands/status';
const program = new Command();
program
.name('lh')
.description('LobeHub CLI - manage and connect to LobeHub services')
.version('0.1.0');
registerLoginCommand(program);
registerLogoutCommand(program);
registerConnectCommand(program);
registerStatusCommand(program);
program.parse();
+458
View File
@@ -0,0 +1,458 @@
import fs from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
editLocalFile,
globLocalFiles,
grepContent,
listLocalFiles,
readLocalFile,
searchLocalFiles,
writeLocalFile,
} from './file';
vi.mock('../utils/logger', () => ({
log: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
}));
describe('file tools', () => {
const tmpDir = path.join(os.tmpdir(), 'cli-file-test-' + process.pid);
beforeEach(async () => {
await mkdir(tmpDir, { recursive: true });
});
afterEach(() => {
fs.rmSync(tmpDir, { force: true, recursive: true });
});
describe('readLocalFile', () => {
it('should read a file with default line range (0-200)', async () => {
const filePath = path.join(tmpDir, 'test.txt');
const lines = Array.from({ length: 300 }, (_, i) => `line ${i}`);
await writeFile(filePath, lines.join('\n'));
const result = await readLocalFile({ path: filePath });
expect(result.lineCount).toBe(200);
expect(result.totalLineCount).toBe(300);
expect(result.loc).toEqual([0, 200]);
expect(result.filename).toBe('test.txt');
expect(result.fileType).toBe('txt');
});
it('should read full content when fullContent is true', async () => {
const filePath = path.join(tmpDir, 'full.txt');
const lines = Array.from({ length: 300 }, (_, i) => `line ${i}`);
await writeFile(filePath, lines.join('\n'));
const result = await readLocalFile({ fullContent: true, path: filePath });
expect(result.lineCount).toBe(300);
expect(result.loc).toEqual([0, 300]);
});
it('should read specific line range', async () => {
const filePath = path.join(tmpDir, 'range.txt');
const lines = Array.from({ length: 10 }, (_, i) => `line ${i}`);
await writeFile(filePath, lines.join('\n'));
const result = await readLocalFile({ loc: [2, 5], path: filePath });
expect(result.lineCount).toBe(3);
expect(result.content).toBe('line 2\nline 3\nline 4');
expect(result.loc).toEqual([2, 5]);
});
it('should handle non-existent file', async () => {
const result = await readLocalFile({ path: path.join(tmpDir, 'nope.txt') });
expect(result.content).toContain('Error');
expect(result.lineCount).toBe(0);
expect(result.totalLineCount).toBe(0);
});
it('should detect file type from extension', async () => {
const filePath = path.join(tmpDir, 'code.ts');
await writeFile(filePath, 'const x = 1;');
const result = await readLocalFile({ path: filePath });
expect(result.fileType).toBe('ts');
});
it('should handle file without extension', async () => {
const filePath = path.join(tmpDir, 'Makefile');
await writeFile(filePath, 'all: build');
const result = await readLocalFile({ path: filePath });
expect(result.fileType).toBe('unknown');
});
});
describe('writeLocalFile', () => {
it('should write a file successfully', async () => {
const filePath = path.join(tmpDir, 'output.txt');
const result = await writeLocalFile({ content: 'hello world', path: filePath });
expect(result.success).toBe(true);
expect(fs.readFileSync(filePath, 'utf8')).toBe('hello world');
});
it('should create parent directories', async () => {
const filePath = path.join(tmpDir, 'sub', 'dir', 'file.txt');
const result = await writeLocalFile({ content: 'nested', path: filePath });
expect(result.success).toBe(true);
expect(fs.readFileSync(filePath, 'utf8')).toBe('nested');
});
it('should return error for empty path', async () => {
const result = await writeLocalFile({ content: 'data', path: '' });
expect(result.success).toBe(false);
expect(result.error).toContain('Path cannot be empty');
});
it('should return error for undefined content', async () => {
const result = await writeLocalFile({
content: undefined as any,
path: path.join(tmpDir, 'f.txt'),
});
expect(result.success).toBe(false);
expect(result.error).toContain('Content cannot be empty');
});
});
describe('editLocalFile', () => {
it('should replace first occurrence by default', async () => {
const filePath = path.join(tmpDir, 'edit.txt');
await writeFile(filePath, 'hello world\nhello again');
const result = await editLocalFile({
file_path: filePath,
new_string: 'hi',
old_string: 'hello',
});
expect(result.success).toBe(true);
expect(result.replacements).toBe(1);
expect(fs.readFileSync(filePath, 'utf8')).toBe('hi world\nhello again');
expect(result.diffText).toBeDefined();
expect(result.linesAdded).toBeDefined();
expect(result.linesDeleted).toBeDefined();
});
it('should replace all occurrences when replace_all is true', async () => {
const filePath = path.join(tmpDir, 'edit-all.txt');
await writeFile(filePath, 'hello world\nhello again');
const result = await editLocalFile({
file_path: filePath,
new_string: 'hi',
old_string: 'hello',
replace_all: true,
});
expect(result.success).toBe(true);
expect(result.replacements).toBe(2);
expect(fs.readFileSync(filePath, 'utf8')).toBe('hi world\nhi again');
});
it('should return error when old_string not found', async () => {
const filePath = path.join(tmpDir, 'no-match.txt');
await writeFile(filePath, 'hello world');
const result = await editLocalFile({
file_path: filePath,
new_string: 'hi',
old_string: 'xyz',
});
expect(result.success).toBe(false);
expect(result.replacements).toBe(0);
});
it('should handle special regex characters in old_string with replace_all', async () => {
const filePath = path.join(tmpDir, 'regex.txt');
await writeFile(filePath, 'price is $10.00 and $20.00');
const result = await editLocalFile({
file_path: filePath,
new_string: '$XX.XX',
old_string: '$10.00',
replace_all: true,
});
expect(result.success).toBe(true);
expect(fs.readFileSync(filePath, 'utf8')).toBe('price is $XX.XX and $20.00');
});
it('should handle file read error', async () => {
const result = await editLocalFile({
file_path: path.join(tmpDir, 'nonexistent.txt'),
new_string: 'new',
old_string: 'old',
});
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
});
describe('listLocalFiles', () => {
it('should list files in directory', async () => {
await writeFile(path.join(tmpDir, 'a.txt'), 'a');
await writeFile(path.join(tmpDir, 'b.txt'), 'b');
await mkdir(path.join(tmpDir, 'subdir'));
const result = await listLocalFiles({ path: tmpDir });
expect(result.totalCount).toBe(3);
expect(result.files.length).toBe(3);
const names = result.files.map((f: any) => f.name);
expect(names).toContain('a.txt');
expect(names).toContain('b.txt');
expect(names).toContain('subdir');
});
it('should sort by name ascending', async () => {
await writeFile(path.join(tmpDir, 'c.txt'), 'c');
await writeFile(path.join(tmpDir, 'a.txt'), 'a');
await writeFile(path.join(tmpDir, 'b.txt'), 'b');
const result = await listLocalFiles({
path: tmpDir,
sortBy: 'name',
sortOrder: 'asc',
});
expect(result.files[0].name).toBe('a.txt');
expect(result.files[2].name).toBe('c.txt');
});
it('should sort by size', async () => {
await writeFile(path.join(tmpDir, 'small.txt'), 'x');
await writeFile(path.join(tmpDir, 'large.txt'), 'x'.repeat(1000));
const result = await listLocalFiles({
path: tmpDir,
sortBy: 'size',
sortOrder: 'asc',
});
expect(result.files[0].name).toBe('small.txt');
});
it('should sort by createdTime', async () => {
await writeFile(path.join(tmpDir, 'first.txt'), 'first');
// Small delay to ensure different timestamps
await new Promise((r) => setTimeout(r, 10));
await writeFile(path.join(tmpDir, 'second.txt'), 'second');
const result = await listLocalFiles({
path: tmpDir,
sortBy: 'createdTime',
sortOrder: 'asc',
});
expect(result.files.length).toBe(2);
});
it('should respect limit', async () => {
await writeFile(path.join(tmpDir, 'a.txt'), 'a');
await writeFile(path.join(tmpDir, 'b.txt'), 'b');
await writeFile(path.join(tmpDir, 'c.txt'), 'c');
const result = await listLocalFiles({ limit: 2, path: tmpDir });
expect(result.files.length).toBe(2);
expect(result.totalCount).toBe(3);
});
it('should handle non-existent directory', async () => {
const result = await listLocalFiles({ path: path.join(tmpDir, 'nope') });
expect(result.files).toEqual([]);
expect(result.totalCount).toBe(0);
});
it('should use default sortBy for unknown sort key', async () => {
await writeFile(path.join(tmpDir, 'a.txt'), 'a');
const result = await listLocalFiles({
path: tmpDir,
sortBy: 'unknown' as any,
});
expect(result.files.length).toBe(1);
});
it('should mark directories correctly', async () => {
await mkdir(path.join(tmpDir, 'mydir'));
const result = await listLocalFiles({ path: tmpDir });
const dir = result.files.find((f: any) => f.name === 'mydir');
expect(dir.isDirectory).toBe(true);
expect(dir.type).toBe('directory');
});
});
describe('globLocalFiles', () => {
it('should match glob patterns', async () => {
await writeFile(path.join(tmpDir, 'a.ts'), 'a');
await writeFile(path.join(tmpDir, 'b.ts'), 'b');
await writeFile(path.join(tmpDir, 'c.js'), 'c');
const result = await globLocalFiles({ cwd: tmpDir, pattern: '*.ts' });
expect(result.files.length).toBe(2);
expect(result.files).toContain('a.ts');
expect(result.files).toContain('b.ts');
});
it('should ignore node_modules and .git', async () => {
await mkdir(path.join(tmpDir, 'node_modules', 'pkg'), { recursive: true });
await writeFile(path.join(tmpDir, 'node_modules', 'pkg', 'index.ts'), 'x');
await writeFile(path.join(tmpDir, 'src.ts'), 'y');
const result = await globLocalFiles({ cwd: tmpDir, pattern: '**/*.ts' });
expect(result.files).toEqual(['src.ts']);
});
it('should use process.cwd() when cwd not specified', async () => {
const result = await globLocalFiles({ pattern: '*.nonexistent-ext-xyz' });
expect(result.files).toEqual([]);
});
it('should handle invalid pattern gracefully', async () => {
// fast-glob handles most patterns; test with a simple one
const result = await globLocalFiles({ cwd: tmpDir, pattern: '*.txt' });
expect(result.files).toEqual([]);
});
});
describe('editLocalFile edge cases', () => {
it('should count lines added and deleted', async () => {
const filePath = path.join(tmpDir, 'multiline.txt');
await writeFile(filePath, 'line1\nline2\nline3');
const result = await editLocalFile({
file_path: filePath,
new_string: 'newA\nnewB\nnewC\nnewD',
old_string: 'line2',
});
expect(result.success).toBe(true);
expect(result.linesAdded).toBeGreaterThan(0);
expect(result.linesDeleted).toBeGreaterThan(0);
});
});
describe('grepContent', () => {
it('should return matches using ripgrep', async () => {
await writeFile(path.join(tmpDir, 'search.txt'), 'hello world\nfoo bar\nhello again');
const result = await grepContent({ cwd: tmpDir, pattern: 'hello' });
// Result depends on whether rg is installed
expect(result).toHaveProperty('success');
expect(result).toHaveProperty('matches');
});
it('should support file pattern filter', async () => {
await writeFile(path.join(tmpDir, 'test.ts'), 'const x = 1;');
await writeFile(path.join(tmpDir, 'test.js'), 'const y = 2;');
const result = await grepContent({
cwd: tmpDir,
filePattern: '*.ts',
pattern: 'const',
});
expect(result).toHaveProperty('success');
});
it('should handle no matches', async () => {
await writeFile(path.join(tmpDir, 'empty.txt'), 'nothing here');
const result = await grepContent({ cwd: tmpDir, pattern: 'xyz_not_found' });
expect(result.matches).toEqual([]);
});
});
describe('searchLocalFiles', () => {
it('should find files by keyword', async () => {
await writeFile(path.join(tmpDir, 'config.json'), '{}');
await writeFile(path.join(tmpDir, 'config.yaml'), '');
await writeFile(path.join(tmpDir, 'readme.md'), '');
const result = await searchLocalFiles({ directory: tmpDir, keywords: 'config' });
expect(result.length).toBe(2);
expect(result.map((r: any) => r.name)).toContain('config.json');
});
it('should filter by content', async () => {
await writeFile(path.join(tmpDir, 'match.txt'), 'this has the secret');
await writeFile(path.join(tmpDir, 'nomatch.txt'), 'nothing here');
// Search with a broad pattern and content filter
const result = await searchLocalFiles({
contentContains: 'secret',
directory: tmpDir,
keywords: '',
});
// Content filtering should exclude files without 'secret'
expect(result.every((r: any) => r.name !== 'nomatch.txt' || false)).toBe(true);
});
it('should respect limit', async () => {
for (let i = 0; i < 5; i++) {
await writeFile(path.join(tmpDir, `file${i}.log`), `content ${i}`);
}
const result = await searchLocalFiles({
directory: tmpDir,
keywords: 'file',
limit: 2,
});
expect(result.length).toBe(2);
});
it('should use cwd when directory not specified', async () => {
const result = await searchLocalFiles({ keywords: 'nonexistent_xyz_file' });
expect(Array.isArray(result)).toBe(true);
});
it('should handle errors gracefully', async () => {
const result = await searchLocalFiles({
directory: '/nonexistent/path/xyz',
keywords: 'test',
});
expect(result).toEqual([]);
});
});
});
+357
View File
@@ -0,0 +1,357 @@
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { createPatch } from 'diff';
import fg from 'fast-glob';
import { log } from '../utils/logger';
// ─── readLocalFile ───
interface ReadFileParams {
fullContent?: boolean;
loc?: [number, number];
path: string;
}
export async function readLocalFile({ path: filePath, loc, fullContent }: ReadFileParams) {
const effectiveLoc = fullContent ? undefined : (loc ?? [0, 200]);
log.debug(`Reading file: ${filePath}, loc=${JSON.stringify(effectiveLoc)}`);
try {
const content = await readFile(filePath, 'utf8');
const lines = content.split('\n');
const totalLineCount = lines.length;
const totalCharCount = content.length;
let selectedContent: string;
let lineCount: number;
let actualLoc: [number, number];
if (effectiveLoc === undefined) {
selectedContent = content;
lineCount = totalLineCount;
actualLoc = [0, totalLineCount];
} else {
const [startLine, endLine] = effectiveLoc;
const selectedLines = lines.slice(startLine, endLine);
selectedContent = selectedLines.join('\n');
lineCount = selectedLines.length;
actualLoc = effectiveLoc;
}
const fileStat = await stat(filePath);
return {
charCount: selectedContent.length,
content: selectedContent,
createdTime: fileStat.birthtime,
fileType: path.extname(filePath).toLowerCase().replace('.', '') || 'unknown',
filename: path.basename(filePath),
lineCount,
loc: actualLoc,
modifiedTime: fileStat.mtime,
totalCharCount,
totalLineCount,
};
} catch (error) {
const errorMessage = (error as Error).message;
return {
charCount: 0,
content: `Error accessing or processing file: ${errorMessage}`,
createdTime: new Date(),
fileType: path.extname(filePath).toLowerCase().replace('.', '') || 'unknown',
filename: path.basename(filePath),
lineCount: 0,
loc: [0, 0] as [number, number],
modifiedTime: new Date(),
totalCharCount: 0,
totalLineCount: 0,
};
}
}
// ─── writeLocalFile ───
interface WriteFileParams {
content: string;
path: string;
}
export async function writeLocalFile({ path: filePath, content }: WriteFileParams) {
if (!filePath) return { error: 'Path cannot be empty', success: false };
if (content === undefined) return { error: 'Content cannot be empty', success: false };
try {
const dirname = path.dirname(filePath);
await mkdir(dirname, { recursive: true });
await writeFile(filePath, content, 'utf8');
log.debug(`File written: ${filePath} (${content.length} chars)`);
return { success: true };
} catch (error) {
return { error: `Failed to write file: ${(error as Error).message}`, success: false };
}
}
// ─── editLocalFile ───
interface EditFileParams {
file_path: string;
new_string: string;
old_string: string;
replace_all?: boolean;
}
export async function editLocalFile({
file_path: filePath,
old_string,
new_string,
replace_all = false,
}: EditFileParams) {
try {
const content = await readFile(filePath, 'utf8');
if (!content.includes(old_string)) {
return {
error: 'The specified old_string was not found in the file',
replacements: 0,
success: false,
};
}
let newContent: string;
let replacements: number;
if (replace_all) {
const regex = new RegExp(old_string.replaceAll(/[$()*+.?[\\\]^{|}]/g, '\\$&'), 'g');
const matches = content.match(regex);
replacements = matches ? matches.length : 0;
newContent = content.replaceAll(old_string, new_string);
} else {
const index = content.indexOf(old_string);
if (index === -1) {
return { error: 'Old string not found', replacements: 0, success: false };
}
newContent = content.slice(0, index) + new_string + content.slice(index + old_string.length);
replacements = 1;
}
await writeFile(filePath, newContent, 'utf8');
const patch = createPatch(filePath, content, newContent, '', '');
const diffText = `diff --git a${filePath} b${filePath}\n${patch}`;
const patchLines = patch.split('\n');
let linesAdded = 0;
let linesDeleted = 0;
for (const line of patchLines) {
if (line.startsWith('+') && !line.startsWith('+++')) linesAdded++;
else if (line.startsWith('-') && !line.startsWith('---')) linesDeleted++;
}
return { diffText, linesAdded, linesDeleted, replacements, success: true };
} catch (error) {
return { error: (error as Error).message, replacements: 0, success: false };
}
}
// ─── listLocalFiles ───
interface ListFilesParams {
limit?: number;
path: string;
sortBy?: 'createdTime' | 'modifiedTime' | 'name' | 'size';
sortOrder?: 'asc' | 'desc';
}
export async function listLocalFiles({
path: dirPath,
sortBy = 'modifiedTime',
sortOrder = 'desc',
limit = 100,
}: ListFilesParams) {
try {
const entries = await readdir(dirPath);
const results: any[] = [];
for (const entry of entries) {
const fullPath = path.join(dirPath, entry);
try {
const stats = await stat(fullPath);
const isDirectory = stats.isDirectory();
results.push({
createdTime: stats.birthtime,
isDirectory,
lastAccessTime: stats.atime,
modifiedTime: stats.mtime,
name: entry,
path: fullPath,
size: stats.size,
type: isDirectory ? 'directory' : path.extname(entry).toLowerCase().replace('.', ''),
});
} catch {
// Skip files we can't stat
}
}
results.sort((a, b) => {
let comparison: number;
switch (sortBy) {
case 'name': {
comparison = (a.name || '').localeCompare(b.name || '');
break;
}
case 'modifiedTime': {
comparison = a.modifiedTime.getTime() - b.modifiedTime.getTime();
break;
}
case 'createdTime': {
comparison = a.createdTime.getTime() - b.createdTime.getTime();
break;
}
case 'size': {
comparison = a.size - b.size;
break;
}
default: {
comparison = a.modifiedTime.getTime() - b.modifiedTime.getTime();
}
}
return sortOrder === 'desc' ? -comparison : comparison;
});
const totalCount = results.length;
return { files: results.slice(0, limit), totalCount };
} catch (error) {
log.error(`Failed to list directory ${dirPath}:`, error);
return { files: [], totalCount: 0 };
}
}
// ─── globLocalFiles ───
interface GlobFilesParams {
cwd?: string;
pattern: string;
}
export async function globLocalFiles({ pattern, cwd }: GlobFilesParams) {
try {
const files = await fg(pattern, {
cwd: cwd || process.cwd(),
dot: false,
ignore: ['**/node_modules/**', '**/.git/**'],
});
return { files };
} catch (error) {
return { error: (error as Error).message, files: [] };
}
}
// ─── grepContent ───
interface GrepContentParams {
cwd?: string;
filePattern?: string;
pattern: string;
}
export async function grepContent({ pattern, cwd, filePattern }: GrepContentParams) {
const { spawn } = await import('node:child_process');
return new Promise<{ matches: any[]; success: boolean }>((resolve) => {
const args = ['--json', '-n'];
if (filePattern) args.push('--glob', filePattern);
args.push(pattern);
const child = spawn('rg', args, { cwd: cwd || process.cwd() });
let stdout = '';
child.stdout?.on('data', (data) => {
stdout += data.toString();
});
child.stderr?.on('data', () => {
// stderr consumed but not used
});
child.on('close', (code) => {
if (code !== 0 && code !== 1) {
// Fallback: use simple regex search
log.debug('rg not available, falling back to simple search');
resolve({ matches: [], success: false });
return;
}
try {
const matches = stdout
.split('\n')
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line);
} catch {
return null;
}
})
.filter(Boolean);
resolve({ matches, success: true });
} catch {
resolve({ matches: [], success: true });
}
});
child.on('error', () => {
log.debug('rg not available');
resolve({ matches: [], success: false });
});
});
}
// ─── searchLocalFiles ───
interface SearchFilesParams {
contentContains?: string;
directory?: string;
keywords: string;
limit?: number;
}
export async function searchLocalFiles({
keywords,
directory,
contentContains,
limit = 30,
}: SearchFilesParams) {
try {
const cwd = directory || process.cwd();
const files = await fg(`**/*${keywords}*`, {
cwd,
dot: false,
ignore: ['**/node_modules/**', '**/.git/**'],
});
let results = files.map((f) => ({ name: path.basename(f), path: path.join(cwd, f) }));
if (contentContains) {
const filtered: typeof results = [];
for (const file of results) {
try {
const content = await readFile(file.path, 'utf8');
if (content.includes(contentContains)) {
filtered.push(file);
}
} catch {
// Skip unreadable files
}
}
results = filtered;
}
return results.slice(0, limit);
} catch (error) {
log.error('File search failed:', error);
return [];
}
}
+176
View File
@@ -0,0 +1,176 @@
import fs from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { executeToolCall } from './index';
vi.mock('../utils/logger', () => ({
log: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
}));
describe('executeToolCall', () => {
const tmpDir = path.join(os.tmpdir(), 'cli-tool-dispatch-test-' + process.pid);
beforeEach(async () => {
await mkdir(tmpDir, { recursive: true });
});
afterEach(() => {
fs.rmSync(tmpDir, { force: true, recursive: true });
});
it('should dispatch readLocalFile', async () => {
const filePath = path.join(tmpDir, 'test.txt');
await writeFile(filePath, 'hello world');
const result = await executeToolCall('readLocalFile', JSON.stringify({ path: filePath }));
expect(result.success).toBe(true);
const parsed = JSON.parse(result.content);
expect(parsed.content).toContain('hello world');
});
it('should dispatch writeLocalFile', async () => {
const filePath = path.join(tmpDir, 'new.txt');
const result = await executeToolCall(
'writeLocalFile',
JSON.stringify({ content: 'written', path: filePath }),
);
expect(result.success).toBe(true);
expect(fs.readFileSync(filePath, 'utf8')).toBe('written');
});
it('should dispatch runCommand', async () => {
const result = await executeToolCall(
'runCommand',
JSON.stringify({ command: 'echo dispatched' }),
);
expect(result.success).toBe(true);
const parsed = JSON.parse(result.content);
expect(parsed.stdout).toContain('dispatched');
});
it('should dispatch listLocalFiles', async () => {
await writeFile(path.join(tmpDir, 'a.txt'), 'a');
const result = await executeToolCall('listLocalFiles', JSON.stringify({ path: tmpDir }));
expect(result.success).toBe(true);
const parsed = JSON.parse(result.content);
expect(parsed.totalCount).toBeGreaterThan(0);
});
it('should dispatch globLocalFiles', async () => {
await writeFile(path.join(tmpDir, 'test.ts'), 'code');
const result = await executeToolCall(
'globLocalFiles',
JSON.stringify({ cwd: tmpDir, pattern: '*.ts' }),
);
expect(result.success).toBe(true);
const parsed = JSON.parse(result.content);
expect(parsed.files).toContain('test.ts');
});
it('should dispatch editLocalFile', async () => {
const filePath = path.join(tmpDir, 'edit.txt');
await writeFile(filePath, 'old content');
const result = await executeToolCall(
'editLocalFile',
JSON.stringify({
file_path: filePath,
new_string: 'new content',
old_string: 'old content',
}),
);
expect(result.success).toBe(true);
expect(fs.readFileSync(filePath, 'utf8')).toBe('new content');
});
it('should return error for unknown API', async () => {
const result = await executeToolCall('unknownApi', '{}');
expect(result.success).toBe(false);
expect(result.error).toContain('Unknown tool API');
});
it('should handle tool that returns a string result', async () => {
// runCommand returns an object, but we test the string branch by mocking
// Actually, none of the tools return plain strings, so the JSON.stringify branch
// is always taken. The string check is for future-proofing.
// Let's verify the JSON output path
const filePath = path.join(tmpDir, 'str.txt');
await writeFile(filePath, 'content');
const result = await executeToolCall('readLocalFile', JSON.stringify({ path: filePath }));
expect(result.success).toBe(true);
// Result should be valid JSON
expect(() => JSON.parse(result.content)).not.toThrow();
});
it('should return error for invalid JSON arguments', async () => {
const result = await executeToolCall('readLocalFile', 'not-json');
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
it('should dispatch grepContent', async () => {
await writeFile(path.join(tmpDir, 'grep.txt'), 'findme here');
const result = await executeToolCall(
'grepContent',
JSON.stringify({ cwd: tmpDir, pattern: 'findme' }),
);
expect(result.success).toBe(true);
});
it('should dispatch searchLocalFiles', async () => {
await writeFile(path.join(tmpDir, 'search_target.txt'), 'found');
const result = await executeToolCall(
'searchLocalFiles',
JSON.stringify({ directory: tmpDir, keywords: 'search_target' }),
);
expect(result.success).toBe(true);
});
it('should dispatch getCommandOutput', async () => {
const result = await executeToolCall(
'getCommandOutput',
JSON.stringify({ shell_id: 'nonexistent' }),
);
expect(result.success).toBe(true);
const parsed = JSON.parse(result.content);
expect(parsed.success).toBe(false);
});
it('should dispatch killCommand', async () => {
const result = await executeToolCall(
'killCommand',
JSON.stringify({ shell_id: 'nonexistent' }),
);
expect(result.success).toBe(true);
const parsed = JSON.parse(result.content);
expect(parsed.success).toBe(false);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { log } from '../utils/logger';
import {
editLocalFile,
globLocalFiles,
grepContent,
listLocalFiles,
readLocalFile,
searchLocalFiles,
writeLocalFile,
} from './file';
import { getCommandOutput, killCommand, runCommand } from './shell';
const methodMap: Record<string, (args: any) => Promise<unknown>> = {
editLocalFile,
getCommandOutput,
globLocalFiles,
grepContent,
killCommand,
listLocalFiles,
readLocalFile,
runCommand,
searchLocalFiles,
writeLocalFile,
};
export async function executeToolCall(
apiName: string,
argsStr: string,
): Promise<{
content: string;
error?: string;
success: boolean;
}> {
const handler = methodMap[apiName];
if (!handler) {
return { content: '', error: `Unknown tool API: ${apiName}`, success: false };
}
try {
const args = JSON.parse(argsStr);
const result = await handler(args);
const content = typeof result === 'string' ? result : JSON.stringify(result);
return { content, success: true };
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
log.error(`Tool call failed: ${apiName} - ${errorMsg}`);
return { content: '', error: errorMsg, success: false };
}
}
+237
View File
@@ -0,0 +1,237 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanupAllProcesses, getCommandOutput, killCommand, runCommand } from './shell';
vi.mock('../utils/logger', () => ({
log: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
}));
describe('shell tools', () => {
afterEach(() => {
cleanupAllProcesses();
});
describe('runCommand', () => {
it('should execute a simple command', async () => {
const result = await runCommand({ command: 'echo hello' });
expect(result.success).toBe(true);
expect(result.stdout).toContain('hello');
expect(result.exit_code).toBe(0);
});
it('should capture stderr', async () => {
const result = await runCommand({ command: 'echo error >&2' });
expect(result.stderr).toContain('error');
});
it('should handle command failure', async () => {
const result = await runCommand({ command: 'exit 1' });
expect(result.success).toBe(false);
expect(result.exit_code).toBe(1);
});
it('should handle command not found', async () => {
const result = await runCommand({ command: 'nonexistent_command_xyz_123' });
expect(result.success).toBe(false);
});
it('should timeout long-running commands', async () => {
const result = await runCommand({ command: 'sleep 10', timeout: 500 });
expect(result.success).toBe(false);
expect(result.error).toContain('timed out');
}, 10000);
it('should clamp timeout to minimum 1000ms', async () => {
const result = await runCommand({ command: 'echo fast', timeout: 100 });
expect(result.success).toBe(true);
});
it('should run command in background', async () => {
const result = await runCommand({
command: 'echo background',
run_in_background: true,
});
expect(result.success).toBe(true);
expect(result.shell_id).toBeDefined();
});
it('should strip ANSI codes from output', async () => {
const result = await runCommand({
command: 'printf "\\033[31mred\\033[0m"',
});
expect(result.output).not.toContain('\u001B');
});
it('should truncate very long output', async () => {
// Generate output longer than 80KB
const result = await runCommand({
command: `python3 -c "print('x' * 100000)" 2>/dev/null || printf '%0.sx' $(seq 1 100000)`,
});
// Output should be truncated
expect(result.output.length).toBeLessThanOrEqual(85000); // 80000 + truncation message
}, 15000);
it('should use description in log prefix', async () => {
const result = await runCommand({
command: 'echo test',
description: 'test command',
});
expect(result.success).toBe(true);
});
});
describe('getCommandOutput', () => {
it('should get output from background process', async () => {
const bgResult = await runCommand({
command: 'echo hello && sleep 0.1',
run_in_background: true,
});
// Wait for output to be captured
await new Promise((r) => setTimeout(r, 200));
const output = await getCommandOutput({ shell_id: bgResult.shell_id });
expect(output.success).toBe(true);
expect(output.stdout).toContain('hello');
});
it('should return error for unknown shell_id', async () => {
const result = await getCommandOutput({ shell_id: 'unknown-id' });
expect(result.success).toBe(false);
expect(result.error).toContain('not found');
});
it('should track running state', async () => {
const bgResult = await runCommand({
command: 'sleep 5',
run_in_background: true,
});
const output = await getCommandOutput({ shell_id: bgResult.shell_id });
expect(output.running).toBe(true);
});
it('should support filter parameter', async () => {
const bgResult = await runCommand({
command: 'echo "line1\nline2\nline3"',
run_in_background: true,
});
await new Promise((r) => setTimeout(r, 200));
const output = await getCommandOutput({
filter: 'line2',
shell_id: bgResult.shell_id,
});
expect(output.success).toBe(true);
});
it('should handle invalid filter regex', async () => {
const bgResult = await runCommand({
command: 'echo test',
run_in_background: true,
});
await new Promise((r) => setTimeout(r, 200));
const output = await getCommandOutput({
filter: '[invalid',
shell_id: bgResult.shell_id,
});
expect(output.success).toBe(true);
});
it('should return new output only on subsequent calls', async () => {
const bgResult = await runCommand({
command: 'echo first && sleep 0.2 && echo second',
run_in_background: true,
});
await new Promise((r) => setTimeout(r, 100));
const first = await getCommandOutput({ shell_id: bgResult.shell_id });
await new Promise((r) => setTimeout(r, 300));
await getCommandOutput({ shell_id: bgResult.shell_id });
// First read should have "first"
expect(first.stdout).toContain('first');
});
});
describe('killCommand', () => {
it('should kill a background process', async () => {
const bgResult = await runCommand({
command: 'sleep 60',
run_in_background: true,
});
const result = await killCommand({ shell_id: bgResult.shell_id });
expect(result.success).toBe(true);
});
it('should return error for unknown shell_id', async () => {
const result = await killCommand({ shell_id: 'unknown-id' });
expect(result.success).toBe(false);
expect(result.error).toContain('not found');
});
});
describe('killCommand error handling', () => {
it('should handle kill error on already-dead process', async () => {
const bgResult = await runCommand({
command: 'echo done',
run_in_background: true,
});
// Wait for process to finish
await new Promise((r) => setTimeout(r, 200));
// Process is already done, killing should still succeed or return error
const result = await killCommand({ shell_id: bgResult.shell_id });
// It may succeed (process already exited) or fail, but shouldn't throw
expect(result).toHaveProperty('success');
});
});
describe('runCommand error handling', () => {
it('should handle spawn error for non-existent shell', async () => {
// Test with a command that causes spawn error
const result = await runCommand({ command: 'echo test' });
// Normal command should work
expect(result).toHaveProperty('success');
});
});
describe('cleanupAllProcesses', () => {
it('should kill all background processes', async () => {
await runCommand({ command: 'sleep 60', run_in_background: true });
await runCommand({ command: 'sleep 60', run_in_background: true });
cleanupAllProcesses();
// No processes should remain - subsequent getCommandOutput should fail
});
});
});
+233
View File
@@ -0,0 +1,233 @@
import type { ChildProcess } from 'node:child_process';
import { spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { log } from '../utils/logger';
// Maximum output length to prevent context explosion
const MAX_OUTPUT_LENGTH = 80_000;
const ANSI_REGEX =
// eslint-disable-next-line no-control-regex
/\u001B(?:[\u0040-\u005A\u005C-\u005F]|\[[\u0030-\u003F]*[\u0020-\u002F]*[\u0040-\u007E])/g;
const stripAnsi = (str: string): string => str.replaceAll(ANSI_REGEX, '');
const truncateOutput = (str: string, maxLength: number = MAX_OUTPUT_LENGTH): string => {
const cleaned = stripAnsi(str);
if (cleaned.length <= maxLength) return cleaned;
return (
cleaned.slice(0, maxLength) +
'\n... [truncated, ' +
(cleaned.length - maxLength) +
' more characters]'
);
};
interface ShellProcess {
lastReadStderr: number;
lastReadStdout: number;
process: ChildProcess;
stderr: string[];
stdout: string[];
}
const shellProcesses = new Map<string, ShellProcess>();
export function cleanupAllProcesses() {
for (const [id, sp] of shellProcesses) {
try {
sp.process.kill();
} catch {
// Ignore
}
shellProcesses.delete(id);
}
}
// ─── runCommand ───
interface RunCommandParams {
command: string;
description?: string;
run_in_background?: boolean;
timeout?: number;
}
export async function runCommand({
command,
description,
run_in_background,
timeout = 120_000,
}: RunCommandParams) {
const logPrefix = `[runCommand: ${description || command.slice(0, 50)}]`;
log.debug(`${logPrefix} Starting`, { background: run_in_background, timeout });
const effectiveTimeout = Math.min(Math.max(timeout, 1000), 600_000);
const shellConfig =
process.platform === 'win32'
? { args: ['/c', command], cmd: 'cmd.exe' }
: { args: ['-c', command], cmd: '/bin/sh' };
try {
if (run_in_background) {
const shellId = randomUUID();
const childProcess = spawn(shellConfig.cmd, shellConfig.args, {
env: process.env,
shell: false,
});
const shellProcess: ShellProcess = {
lastReadStderr: 0,
lastReadStdout: 0,
process: childProcess,
stderr: [],
stdout: [],
};
childProcess.stdout?.on('data', (data) => {
shellProcess.stdout.push(data.toString());
});
childProcess.stderr?.on('data', (data) => {
shellProcess.stderr.push(data.toString());
});
childProcess.on('exit', (code) => {
log.debug(`${logPrefix} Background process exited`, { code, shellId });
});
shellProcesses.set(shellId, shellProcess);
log.debug(`${logPrefix} Started background`, { shellId });
return { shell_id: shellId, success: true };
} else {
return new Promise<any>((resolve) => {
const childProcess = spawn(shellConfig.cmd, shellConfig.args, {
env: process.env,
shell: false,
});
let stdout = '';
let stderr = '';
let killed = false;
const timeoutHandle = setTimeout(() => {
killed = true;
childProcess.kill();
resolve({
error: `Command timed out after ${effectiveTimeout}ms`,
stderr: truncateOutput(stderr),
stdout: truncateOutput(stdout),
success: false,
});
}, effectiveTimeout);
childProcess.stdout?.on('data', (data) => {
stdout += data.toString();
});
childProcess.stderr?.on('data', (data) => {
stderr += data.toString();
});
childProcess.on('exit', (code) => {
if (!killed) {
clearTimeout(timeoutHandle);
const success = code === 0;
resolve({
exit_code: code || 0,
output: truncateOutput(stdout + stderr),
stderr: truncateOutput(stderr),
stdout: truncateOutput(stdout),
success,
});
}
});
childProcess.on('error', (error) => {
clearTimeout(timeoutHandle);
resolve({
error: error.message,
stderr: truncateOutput(stderr),
stdout: truncateOutput(stdout),
success: false,
});
});
});
}
} catch (error) {
return { error: (error as Error).message, success: false };
}
}
// ─── getCommandOutput ───
interface GetCommandOutputParams {
filter?: string;
shell_id: string;
}
export async function getCommandOutput({ shell_id, filter }: GetCommandOutputParams) {
const shellProcess = shellProcesses.get(shell_id);
if (!shellProcess) {
return {
error: `Shell ID ${shell_id} not found`,
output: '',
running: false,
stderr: '',
stdout: '',
success: false,
};
}
const { lastReadStderr, lastReadStdout, process: childProcess, stderr, stdout } = shellProcess;
const newStdout = stdout.slice(lastReadStdout).join('');
const newStderr = stderr.slice(lastReadStderr).join('');
let output = newStdout + newStderr;
if (filter) {
try {
const regex = new RegExp(filter, 'gm');
const lines = output.split('\n');
output = lines.filter((line) => regex.test(line)).join('\n');
} catch {
// Invalid filter regex, use unfiltered output
}
}
shellProcess.lastReadStdout = stdout.length;
shellProcess.lastReadStderr = stderr.length;
const running = childProcess.exitCode === null;
return {
output: truncateOutput(output),
running,
stderr: truncateOutput(newStderr),
stdout: truncateOutput(newStdout),
success: true,
};
}
// ─── killCommand ───
interface KillCommandParams {
shell_id: string;
}
export async function killCommand({ shell_id }: KillCommandParams) {
const shellProcess = shellProcesses.get(shell_id);
if (!shellProcess) {
return { error: `Shell ID ${shell_id} not found`, success: false };
}
try {
shellProcess.process.kill();
shellProcesses.delete(shell_id);
return { success: true };
} catch (error) {
return { error: (error as Error).message, success: false };
}
}
+155
View File
@@ -0,0 +1,155 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { log, setVerbose } from './logger';
describe('logger', () => {
const consoleSpy = {
error: vi.spyOn(console, 'error').mockImplementation(() => {}),
log: vi.spyOn(console, 'log').mockImplementation(() => {}),
warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
};
const stdoutWriteSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
afterEach(() => {
setVerbose(false);
vi.clearAllMocks();
});
describe('info', () => {
it('should log info messages', () => {
log.info('test message');
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining('[INFO]'),
// No extra args
);
});
it('should pass extra args', () => {
log.info('test %s', 'arg1');
expect(consoleSpy.log).toHaveBeenCalled();
});
});
describe('error', () => {
it('should log error messages', () => {
log.error('error message');
expect(consoleSpy.error).toHaveBeenCalledWith(expect.stringContaining('[ERROR]'));
});
});
describe('warn', () => {
it('should log warning messages', () => {
log.warn('warning message');
expect(consoleSpy.warn).toHaveBeenCalledWith(expect.stringContaining('[WARN]'));
});
});
describe('debug', () => {
it('should not log when verbose is false', () => {
log.debug('debug message');
expect(consoleSpy.log).not.toHaveBeenCalled();
});
it('should log when verbose is true', () => {
setVerbose(true);
log.debug('debug message');
expect(consoleSpy.log).toHaveBeenCalledWith(expect.stringContaining('[DEBUG]'));
});
});
describe('heartbeat', () => {
it('should not write when verbose is false', () => {
log.heartbeat();
expect(stdoutWriteSpy).not.toHaveBeenCalled();
});
it('should write dot when verbose is true', () => {
setVerbose(true);
log.heartbeat();
expect(stdoutWriteSpy).toHaveBeenCalled();
});
});
describe('status', () => {
it('should log connected status', () => {
log.status('connected');
expect(consoleSpy.log).toHaveBeenCalledWith(expect.stringContaining('[STATUS]'));
});
it('should log disconnected status', () => {
log.status('disconnected');
expect(consoleSpy.log).toHaveBeenCalled();
});
it('should log other status', () => {
log.status('connecting');
expect(consoleSpy.log).toHaveBeenCalled();
});
});
describe('toolCall', () => {
it('should log tool call', () => {
log.toolCall('readFile', 'req-1');
expect(consoleSpy.log).toHaveBeenCalledWith(expect.stringContaining('[TOOL]'));
});
it('should log args when verbose', () => {
setVerbose(true);
log.toolCall('readFile', 'req-1', '{"path": "/test"}');
// Should have been called twice (tool call + args)
expect(consoleSpy.log).toHaveBeenCalledTimes(2);
});
it('should not log args when not verbose', () => {
log.toolCall('readFile', 'req-1', '{"path": "/test"}');
expect(consoleSpy.log).toHaveBeenCalledTimes(1);
});
});
describe('toolResult', () => {
it('should log success result', () => {
log.toolResult('req-1', true);
expect(consoleSpy.log).toHaveBeenCalledWith(expect.stringContaining('[RESULT]'));
});
it('should log failure result', () => {
log.toolResult('req-1', false);
expect(consoleSpy.log).toHaveBeenCalled();
});
it('should log content preview when verbose', () => {
setVerbose(true);
log.toolResult('req-1', true, 'some content');
expect(consoleSpy.log).toHaveBeenCalledTimes(2);
});
it('should truncate long content in preview', () => {
setVerbose(true);
log.toolResult('req-1', true, 'x'.repeat(300));
expect(consoleSpy.log).toHaveBeenCalledTimes(2);
// The second call should have truncated content
const lastCall = consoleSpy.log.mock.calls[1][0];
expect(lastCall).toContain('...');
});
it('should not log content when not verbose', () => {
log.toolResult('req-1', true, 'some content');
expect(consoleSpy.log).toHaveBeenCalledTimes(1);
});
});
describe('setVerbose', () => {
it('should enable verbose mode', () => {
setVerbose(true);
log.debug('should appear');
expect(consoleSpy.log).toHaveBeenCalled();
});
it('should disable verbose mode', () => {
setVerbose(true);
setVerbose(false);
log.debug('should not appear');
expect(consoleSpy.log).not.toHaveBeenCalled();
});
});
});
+65
View File
@@ -0,0 +1,65 @@
/* eslint-disable no-console */
import pc from 'picocolors';
let verbose = false;
export const setVerbose = (v: boolean) => {
verbose = v;
};
const timestamp = (): string => {
const now = new Date();
return pc.dim(
`${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`,
);
};
export const log = {
debug: (msg: string, ...args: unknown[]) => {
if (verbose) {
console.log(`${timestamp()} ${pc.dim('[DEBUG]')} ${msg}`, ...args);
}
},
error: (msg: string, ...args: unknown[]) => {
console.error(`${timestamp()} ${pc.red('[ERROR]')} ${pc.red(msg)}`, ...args);
},
heartbeat: () => {
if (verbose) {
process.stdout.write(pc.dim('.'));
}
},
info: (msg: string, ...args: unknown[]) => {
console.log(`${timestamp()} ${pc.blue('[INFO]')} ${msg}`, ...args);
},
status: (status: string) => {
const color =
status === 'connected' ? pc.green : status === 'disconnected' ? pc.red : pc.yellow;
console.log(`${timestamp()} ${pc.bold('[STATUS]')} ${color(status)}`);
},
toolCall: (apiName: string, requestId: string, args?: string) => {
console.log(
`${timestamp()} ${pc.magenta('[TOOL]')} ${pc.bold(apiName)} ${pc.dim(`(${requestId})`)}`,
);
if (args && verbose) {
console.log(` ${pc.dim(args)}`);
}
},
toolResult: (requestId: string, success: boolean, content?: string) => {
const icon = success ? pc.green('OK') : pc.red('FAIL');
console.log(`${timestamp()} ${pc.magenta('[RESULT]')} ${icon} ${pc.dim(`(${requestId})`)}`);
if (content && verbose) {
const preview = content.length > 200 ? content.slice(0, 200) + '...' : content;
console.log(` ${pc.dim(preview)}`);
}
},
warn: (msg: string, ...args: unknown[]) => {
console.warn(`${timestamp()} ${pc.yellow('[WARN]')} ${pc.yellow(msg)}`, ...args);
},
};
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ESNext"],
"types": ["node"],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"paths": {
"@lobechat/device-gateway-client": ["../../packages/device-gateway-client/src"]
}
},
"include": ["src"]
}
+23
View File
@@ -0,0 +1,23 @@
import path from 'node:path';
import { defineConfig } from 'vitest/config';
export default defineConfig({
resolve: {
alias: [
{
find: '@lobechat/device-gateway-client',
replacement: path.resolve(__dirname, '../../packages/device-gateway-client/src/index.ts'),
},
],
},
test: {
coverage: {
all: false,
reporter: ['text', 'json', 'lcov', 'text-summary'],
},
environment: 'node',
// Suppress unhandled rejection warnings from Commander async actions with mocked process.exit
onConsoleLog: () => true,
},
});
+7 -2
View File
@@ -5,14 +5,19 @@
"scripts": {
"deploy": "wrangler deploy",
"dev": "wrangler dev",
"test": "vitest run",
"test:watch": "vitest",
"type-check": "tsc --noEmit"
},
"dependencies": {
"hono": "^4.12.5",
"jose": "^6.1.3"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250214.0",
"@cloudflare/vitest-pool-workers": "^0.12.19",
"@cloudflare/workers-types": "^4.20260301.1",
"typescript": "^5.9.3",
"wrangler": "^4.14.4"
"vitest": "~3.2.4",
"wrangler": "^4.70.0"
}
}
+231 -38
View File
@@ -1,7 +1,13 @@
import { DurableObject } from 'cloudflare:workers';
import { Hono } from 'hono';
import { verifyDesktopToken } from './auth';
import type { DeviceAttachment, Env } from './types';
const AUTH_TIMEOUT = 10_000; // 10s to authenticate after connect
const HEARTBEAT_TIMEOUT = 90_000; // 90s without heartbeat → close
const HEARTBEAT_CHECK_INTERVAL = 90_000; // check every 90s
export class DeviceGatewayDO extends DurableObject<Env> {
private pendingRequests = new Map<
string,
@@ -11,58 +17,91 @@ export class DeviceGatewayDO extends DurableObject<Env> {
}
>();
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
// ─── WebSocket upgrade (from Desktop) ───
if (request.headers.get('Upgrade') === 'websocket') {
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
this.ctx.acceptWebSocket(server);
const deviceId = url.searchParams.get('deviceId') || 'unknown';
const hostname = url.searchParams.get('hostname') || '';
const platform = url.searchParams.get('platform') || '';
server.serializeAttachment({
connectedAt: Date.now(),
deviceId,
hostname,
platform,
} satisfies DeviceAttachment);
return new Response(null, { status: 101, webSocket: client });
}
// ─── HTTP API (from Vercel Agent) ───
if (url.pathname === '/api/device/status') {
const sockets = this.ctx.getWebSockets();
private router = new Hono()
.all('/api/device/status', async () => {
const sockets = this.getAuthenticatedSockets();
return Response.json({
deviceCount: sockets.length,
online: sockets.length > 0,
});
}
if (url.pathname === '/api/device/tool-call') {
return this.handleToolCall(request);
}
if (url.pathname === '/api/device/devices') {
const sockets = this.ctx.getWebSockets();
})
.post('/api/device/tool-call', async (c) => {
return this.handleToolCall(c.req.raw);
})
.post('/api/device/system-info', async (c) => {
return this.handleSystemInfo(c.req.raw);
})
.all('/api/device/devices', async () => {
const sockets = this.getAuthenticatedSockets();
const devices = sockets.map((ws) => ws.deserializeAttachment() as DeviceAttachment);
return Response.json({ devices });
});
async fetch(request: Request): Promise<Response> {
// ─── WebSocket upgrade (from Desktop) ───
if (request.headers.get('Upgrade') === 'websocket') {
return this.handleWebSocketUpgrade(request);
}
return new Response('Not Found', { status: 404 });
// ─── HTTP API routes ───
return this.router.fetch(request);
}
// ─── Hibernation Handlers ───
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
const data = JSON.parse(message as string);
const att = ws.deserializeAttachment() as DeviceAttachment;
if (data.type === 'tool_call_response') {
// ─── Auth message handling ───
if (data.type === 'auth') {
if (att.authenticated) return; // Already authenticated, ignore
try {
const token = data.token as string;
if (!token) throw new Error('Missing token');
let verifiedUserId: string;
if (token === this.env.SERVICE_TOKEN) {
// Service token auth (for CLI debugging)
const storedUserId = await this.ctx.storage.get<string>('_userId');
if (!storedUserId) throw new Error('Missing userId');
verifiedUserId = storedUserId;
} else {
// JWT auth (normal desktop flow)
const result = await verifyDesktopToken(this.env, token);
verifiedUserId = result.userId;
}
// Verify userId matches the DO routing
const storedUserId = await this.ctx.storage.get<string>('_userId');
if (storedUserId && verifiedUserId !== storedUserId) {
throw new Error('userId mismatch');
}
// Mark as authenticated
att.authenticated = true;
att.authDeadline = undefined;
ws.serializeAttachment(att);
ws.send(JSON.stringify({ type: 'auth_success' }));
// Schedule heartbeat check for authenticated connections
await this.scheduleHeartbeatCheck();
} catch (err) {
const reason = err instanceof Error ? err.message : 'Authentication failed';
ws.send(JSON.stringify({ reason, type: 'auth_failed' }));
ws.close(1008, reason);
}
return;
}
// ─── Reject unauthenticated messages ───
if (!att.authenticated) return;
// ─── Business messages (authenticated only) ───
if (data.type === 'tool_call_response' || data.type === 'system_info_response') {
const pending = this.pendingRequests.get(data.requestId);
if (pending) {
clearTimeout(pending.timer);
@@ -72,6 +111,8 @@ export class DeviceGatewayDO extends DurableObject<Env> {
}
if (data.type === 'heartbeat') {
att.lastHeartbeat = Date.now();
ws.serializeAttachment(att);
ws.send(JSON.stringify({ type: 'heartbeat_ack' }));
}
}
@@ -84,10 +125,162 @@ export class DeviceGatewayDO extends DurableObject<Env> {
ws.close(1011, 'Internal error');
}
// ─── Heartbeat Timeout ───
async alarm() {
const now = Date.now();
const closedSockets = new Set<WebSocket>();
for (const ws of this.ctx.getWebSockets()) {
const att = ws.deserializeAttachment() as DeviceAttachment;
// Auth timeout: close unauthenticated connections past deadline
if (!att.authenticated && att.authDeadline && now > att.authDeadline) {
ws.send(JSON.stringify({ reason: 'Authentication timeout', type: 'auth_failed' }));
ws.close(1008, 'Authentication timeout');
closedSockets.add(ws);
continue;
}
// Heartbeat timeout: only for authenticated connections
if (att.authenticated && now - att.lastHeartbeat > HEARTBEAT_TIMEOUT) {
ws.close(1000, 'Heartbeat timeout');
closedSockets.add(ws);
}
}
// Keep alarm running while there are active connections
const remaining = this.ctx.getWebSockets().filter((ws) => !closedSockets.has(ws));
if (remaining.length > 0) {
await this.scheduleHeartbeatCheck();
}
}
// ─── WebSocket Upgrade ───
private async handleWebSocketUpgrade(request: Request): Promise<Response> {
const url = new URL(request.url);
const userId = request.headers.get('X-User-Id');
const deviceId = url.searchParams.get('deviceId') || 'unknown';
const hostname = url.searchParams.get('hostname') || '';
const platform = url.searchParams.get('platform') || '';
// Close stale connection from the same device
for (const ws of this.ctx.getWebSockets()) {
const att = ws.deserializeAttachment() as DeviceAttachment;
if (att.deviceId === deviceId) {
ws.close(1000, 'Replaced by new connection');
}
}
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
this.ctx.acceptWebSocket(server);
const now = Date.now();
server.serializeAttachment({
authDeadline: now + AUTH_TIMEOUT,
authenticated: false,
connectedAt: now,
deviceId,
hostname,
lastHeartbeat: now,
platform,
} satisfies DeviceAttachment);
if (userId) {
await this.ctx.storage.put('_userId', userId);
}
// Schedule auth timeout check (10s)
await this.scheduleAuthTimeout();
return new Response(null, { status: 101, webSocket: client });
}
private async scheduleAuthTimeout() {
const currentAlarm = await this.ctx.storage.getAlarm();
if (!currentAlarm) {
await this.ctx.storage.setAlarm(Date.now() + AUTH_TIMEOUT);
}
}
private async scheduleHeartbeatCheck() {
const currentAlarm = await this.ctx.storage.getAlarm();
if (!currentAlarm) {
await this.ctx.storage.setAlarm(Date.now() + HEARTBEAT_CHECK_INTERVAL);
}
}
// ─── Helpers ───
private getAuthenticatedSockets(): WebSocket[] {
return this.ctx.getWebSockets().filter((ws) => {
const att = ws.deserializeAttachment() as DeviceAttachment;
return att.authenticated;
});
}
// ─── System Info RPC ───
private async handleSystemInfo(request: Request): Promise<Response> {
const sockets = this.getAuthenticatedSockets();
if (sockets.length === 0) {
return Response.json({ error: 'DEVICE_OFFLINE', success: false }, { status: 503 });
}
const { deviceId, timeout = 10_000 } = (await request.json()) as {
deviceId?: string;
timeout?: number;
};
const requestId = crypto.randomUUID();
const targetWs = deviceId
? sockets.find((ws) => {
const att = ws.deserializeAttachment() as DeviceAttachment;
return att.deviceId === deviceId;
})
: sockets[0];
if (!targetWs) {
return Response.json({ error: 'DEVICE_NOT_FOUND', success: false }, { status: 503 });
}
try {
const result = await new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingRequests.delete(requestId);
reject(new Error('TIMEOUT'));
}, timeout);
this.pendingRequests.set(requestId, { resolve, timer });
targetWs.send(
JSON.stringify({
requestId,
type: 'system_info_request',
}),
);
});
return Response.json({ success: true, ...(result as object) });
} catch (err) {
return Response.json(
{
error: (err as Error).message,
success: false,
},
{ status: 504 },
);
}
}
// ─── Tool Call RPC ───
private async handleToolCall(request: Request): Promise<Response> {
const sockets = this.ctx.getWebSockets();
const sockets = this.getAuthenticatedSockets();
if (sockets.length === 0) {
return Response.json(
{ content: '桌面设备不在线', error: 'DEVICE_OFFLINE', success: false },
+39 -44
View File
@@ -1,52 +1,47 @@
import { verifyDesktopToken } from './auth';
import { Hono } from 'hono';
import { DeviceGatewayDO } from './DeviceGatewayDO';
import type { Env } from './types';
export { DeviceGatewayDO };
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const app = new Hono<{ Bindings: Env }>();
// ─── Health check ───
if (url.pathname === '/health') {
return new Response('OK', { status: 200 });
// ─── Health check ───
app.get('/health', (c) => c.text('OK'));
// ─── Auth middleware for service APIs ───
const serviceAuth = (): ((c: any, next: () => Promise<void>) => Promise<Response | void>) => {
return async (c, next) => {
const authHeader = c.req.header('Authorization');
if (authHeader !== `Bearer ${c.env.SERVICE_TOKEN}`) {
return c.text('Unauthorized', 401);
}
// ─── Desktop WebSocket connection ───
if (url.pathname === '/ws') {
const token = url.searchParams.get('token');
if (!token) return new Response('Missing token', { status: 401 });
try {
const { userId } = await verifyDesktopToken(env, token);
const id = env.DEVICE_GATEWAY.idFromName(`user:${userId}`);
const stub = env.DEVICE_GATEWAY.get(id);
// Forward WebSocket upgrade to DO
const headers = new Headers(request.headers);
headers.set('X-User-Id', userId);
return stub.fetch(new Request(request, { headers }));
} catch {
return new Response('Invalid token', { status: 401 });
}
}
// ─── Vercel Agent HTTP API ───
if (url.pathname.startsWith('/api/device/')) {
const authHeader = request.headers.get('Authorization');
if (authHeader !== `Bearer ${env.SERVICE_TOKEN}`) {
return new Response('Unauthorized', { status: 401 });
}
const body = (await request.clone().json()) as { userId: string };
if (!body.userId) return new Response('Missing userId', { status: 400 });
const id = env.DEVICE_GATEWAY.idFromName(`user:${body.userId}`);
const stub = env.DEVICE_GATEWAY.get(id);
return stub.fetch(request);
}
return new Response('Not Found', { status: 404 });
},
await next();
};
};
// ─── Desktop WebSocket connection ───
app.get('/ws', async (c) => {
const userId = c.req.query('userId');
if (!userId) return c.text('Missing userId', 400);
const id = c.env.DEVICE_GATEWAY.idFromName(`user:${userId}`);
const stub = c.env.DEVICE_GATEWAY.get(id);
const headers = new Headers(c.req.raw.headers);
headers.set('X-User-Id', userId);
return stub.fetch(new Request(c.req.raw, { headers }));
});
// ─── Vercel Agent HTTP API ───
app.all('/api/device/*', serviceAuth(), async (c) => {
const body = (await c.req.raw.clone().json()) as { userId: string };
if (!body.userId) return c.text('Missing userId', 400);
const id = c.env.DEVICE_GATEWAY.idFromName(`user:${body.userId}`);
const stub = c.env.DEVICE_GATEWAY.get(id);
return stub.fetch(c.req.raw);
});
export default app;
+53 -2
View File
@@ -7,15 +7,23 @@ export interface Env {
// ─── Device Info ───
export interface DeviceAttachment {
authDeadline?: number;
authenticated: boolean;
connectedAt: number;
deviceId: string;
hostname: string;
lastHeartbeat: number;
platform: string;
}
// ─── WebSocket Protocol Messages ───
// Desktop → CF
export interface AuthMessage {
token: string;
type: 'auth';
}
export interface HeartbeatMessage {
type: 'heartbeat';
}
@@ -30,7 +38,35 @@ export interface ToolCallResponseMessage {
type: 'tool_call_response';
}
export interface SystemInfoResponseMessage {
requestId: string;
result: DeviceSystemInfo;
type: 'system_info_response';
}
export interface DeviceSystemInfo {
arch: string;
desktopPath: string;
documentsPath: string;
downloadsPath: string;
homePath: string;
musicPath: string;
picturesPath: string;
userDataPath: string;
videosPath: string;
workingDirectory: string;
}
// CF → Desktop
export interface AuthSuccessMessage {
type: 'auth_success';
}
export interface AuthFailedMessage {
reason: string;
type: 'auth_failed';
}
export interface HeartbeatAckMessage {
type: 'heartbeat_ack';
}
@@ -49,5 +85,20 @@ export interface ToolCallRequestMessage {
type: 'tool_call_request';
}
export type ClientMessage = HeartbeatMessage | ToolCallResponseMessage;
export type ServerMessage = AuthExpiredMessage | HeartbeatAckMessage | ToolCallRequestMessage;
export interface SystemInfoRequestMessage {
requestId: string;
type: 'system_info_request';
}
export type ClientMessage =
| AuthMessage
| HeartbeatMessage
| SystemInfoResponseMessage
| ToolCallResponseMessage;
export type ServerMessage =
| AuthExpiredMessage
| AuthFailedMessage
| AuthSuccessMessage
| HeartbeatAckMessage
| SystemInfoRequestMessage
| ToolCallRequestMessage;
+125
View File
@@ -0,0 +1,125 @@
---
title: Connect LobeHub to Discord
description: >-
Learn how to create a Discord bot and connect it to your LobeHub agent as a
message channel, allowing your AI assistant to interact with users directly in
Discord servers and direct messages.
tags:
- Discord
- Message Channels
- Bot Setup
- Integration
---
# Connect LobeHub to Discord
<Callout type={'info'}>
This feature is currently in development and may not be fully stable. You can enable it by turning on **Developer Mode** in **Settings** → **Advanced Settings** → **Developer Mode**.
</Callout>
By connecting a Discord channel to your LobeHub agent, users can interact with the AI assistant directly through Discord server channels and direct messages.
## Prerequisites
- A LobeHub account with an active subscription
- A Discord account with **Manage Server** permission on the target server
## Step 1: Create a Discord Application and Bot
<Steps>
### Go to the Discord Developer Portal
Visit the [Discord Developer Portal](https://discord.com/developers/applications) and click **New Application**. Give your application a name (e.g., "LobeHub Assistant") and click **Create**.
### Create a Bot
In the left sidebar, click **Bot**. Customize the bot's username and avatar as needed.
### Enable Privileged Gateway Intents
On the Bot settings page, scroll down to **Privileged Gateway Intents** and enable:
- **Message Content Intent** — Required for the bot to read message content
- **Server Members Intent** — Recommended for user identification
- **Presence Intent** — Optional; enable if you want the bot to access user online/offline status
Click **Save Changes**.
### Copy the Bot Token
On the **Bot** page, click **Reset Token** to generate your bot token. Copy and save it securely.
> **Important:** Treat your bot token like a password. Never share it publicly or commit it to version control.
### Copy the Application ID and Public Key
Go to **General Information** in the left sidebar. Copy and save:
- **Application ID**
- **Public Key**
You will need all three values (Bot Token, Application ID, Public Key) in the next step.
</Steps>
## Step 2: Configure Discord in LobeHub
<Steps>
### Open Channel Settings
In LobeHub, navigate to your agent's settings, then select the **Channels** tab. Click **Discord** from the platform list.
### Fill in the Credentials
Enter the following fields:
- **Application ID** — The Application ID from your Discord app's General Information page
- **Bot Token** — The bot token you generated earlier
- **Public Key** — The Public Key from your Discord app, used for interaction verification
Your token will be encrypted and stored securely.
### Save Configuration
Click **Save Configuration**. Your credentials will be saved and LobeHub will start listening for Discord events.
</Steps>
## Step 3: Invite the Bot to Your Server
<Steps>
### Generate an Invite URL
In the Discord Developer Portal, go to **OAuth2** → **URL Generator**. Select the following scopes:
- `bot`
- `applications.commands`
Under **Bot Permissions**, select:
- View Channels
- Send Messages
- Read Message History
- Embed Links
- Attach Files
- Add Reactions (optional)
### Authorize the Bot
Copy the generated URL, open it in your browser, select the server you want to add the bot to, and click **Authorize**.
</Steps>
## Step 4: Test the Connection
Back in LobeHub's channel settings for Discord, click **Test Connection** to verify everything is configured correctly. Then send a message to your bot in Discord to confirm it responds.
## Configuration Reference
| Field | Required | Description |
| ------------------ | -------- | ------------------------------------------------ |
| **Application ID** | Yes | Your Discord application's ID |
| **Bot Token** | Yes | Authentication token for your Discord bot |
| **Public Key** | Yes | Used to verify interaction requests from Discord |
## Troubleshooting
- **Bot not responding in server:** Confirm the bot has been invited to the server with the correct permissions, and Message Content Intent is enabled.
- **Test Connection failed:** Double-check the Application ID, Bot Token, and Public Key are correct.
+124
View File
@@ -0,0 +1,124 @@
---
title: 将 LobeHub 连接到 Discord
description: >-
了解如何创建一个 Discord 机器人并将其连接到您的 LobeHub 代理作为消息渠道,使您的 AI 助手能够直接在 Discord
服务器和私信中与用户互动。
tags:
- Discord
- 消息渠道
- 机器人设置
- 集成
---
# 将 LobeHub 连接到 Discord
<Callout type={'info'}>
此功能目前正在开发中,可能尚未完全稳定。您可以通过在 **设置** → **高级设置** → **开发者模式** 中启用 **开发者模式** 来使用此功能。
</Callout>
通过将 Discord 渠道连接到您的 LobeHub 代理,用户可以直接通过 Discord 服务器频道和私信与 AI 助手互动。
## 前置条件
- 一个拥有有效订阅的 LobeHub 账户
- 一个拥有目标服务器 **管理服务器** 权限的 Discord 账户
## 第一步:创建 Discord 应用程序和机器人
<Steps>
### 访问 Discord 开发者门户
访问 [Discord 开发者门户](https://discord.com/developers/applications),点击 **新建应用程序**。为您的应用程序命名(例如,“LobeHub 助手”),然后点击 **创建**。
### 创建机器人
在左侧菜单中,点击 **机器人**。根据需要自定义机器人的用户名和头像。
### 启用特权网关意图
在机器人设置页面,向下滚动到 **特权网关意图** 并启用以下选项:
- **消息内容意图** — 允许机器人读取消息内容(必需)
- **服务器成员意图** — 推荐启用,用于用户识别
- **在线状态意图** — 可选;如果希望机器人访问用户的在线 / 离线状态,请启用
点击 **保存更改**。
### 复制机器人令牌
在 **机器人** 页面,点击 **重置令牌** 以生成您的机器人令牌。复制并安全保存该令牌。
> **重要提示:** 请将您的机器人令牌视为密码。切勿公开分享或提交到版本控制系统。
### 复制应用程序 ID 和公钥
在左侧菜单中,转到 **常规信息**。复制并保存以下内容:
- **应用程序 ID**
- **公钥**
您将在下一步中需要这三个值(机器人令牌、应用程序 ID、公钥)。
</Steps>
## 第二步:在 LobeHub 中配置 Discord
<Steps>
### 打开渠道设置
在 LobeHub 中,导航到您的代理设置,然后选择 **渠道** 标签。点击平台列表中的 **Discord**。
### 填写凭据
输入以下字段:
- **应用程序 ID** — 来自 Discord 应用程序常规信息页面的应用程序 ID
- **机器人令牌** — 您之前生成的机器人令牌
- **公钥** — 来自 Discord 应用程序的公钥,用于交互验证
您的令牌将被加密并安全存储。
### 保存配置
点击 **保存配置**。您的凭据将被保存,LobeHub 将开始监听 Discord 事件。
</Steps>
## 第三步:邀请机器人加入您的服务器
<Steps>
### 生成邀请链接
在 Discord 开发者门户中,转到 **OAuth2** → **URL 生成器**。选择以下范围:
- `bot`
- `applications.commands`
在 **机器人权限** 下选择:
- 查看频道
- 发送消息
- 读取消息历史
- 嵌入链接
- 附加文件
- 添加反应(可选)
### 授权机器人
复制生成的链接,在浏览器中打开,选择您希望添加机器人的服务器,然后点击 **授权**。
</Steps>
## 第四步:测试连接
返回 LobeHub 的 Discord 渠道设置,点击 **测试连接** 以验证配置是否正确。然后在 Discord 中向您的机器人发送消息,确认其是否响应。
## 配置参考
| 字段 | 是否必需 | 描述 |
| ----------- | ---- | -------------------- |
| **应用程序 ID** | 是 | 您的 Discord 应用程序的 ID |
| **机器人令牌** | 是 | 您的 Discord 机器人的认证令牌 |
| **公钥** | 是 | 用于验证来自 Discord 的交互请求 |
## 故障排除
- **机器人未在服务器中响应:** 确认机器人已被邀请到服务器并拥有正确的权限,同时启用了消息内容意图。
- **测试连接失败:** 仔细检查应用程序 ID、机器人令牌和公钥是否正确。
+185
View File
@@ -0,0 +1,185 @@
---
title: Connect LobeHub to Feishu / Lark
description: >-
Learn how to create a Feishu (Lark) custom app and connect it to your LobeHub
agent as a message channel, enabling your AI assistant to interact with team
members in Feishu or Lark chats.
tags:
- Feishu
- Lark
- Message Channels
- Bot Setup
- Integration
---
# Connect LobeHub to Feishu / Lark
<Callout type={'info'}>
This feature is currently in development and may not be fully stable. You can enable it by turning on **Developer Mode** in **Settings** → **Advanced Settings** → **Developer Mode**.
</Callout>
By connecting a Feishu (or Lark) channel to your LobeHub agent, team members can interact with the AI assistant directly in Feishu private chats and group conversations.
> Feishu is the Chinese version, and Lark is the international version. The setup process is identical — just use the corresponding platform portal.
## Prerequisites
- A LobeHub account with an active subscription
- A Feishu or Lark account with permissions to create enterprise apps
## Step 1: Create a Feishu / Lark App
<Steps>
### Open the Developer Portal
- **Feishu:** Visit [open.feishu.cn/app](https://open.feishu.cn/app)
- **Lark:** Visit [open.larksuite.com/app](https://open.larksuite.com/app)
Sign in with your account.
### Create an Enterprise App
Click **Create Enterprise App**. Fill in the app name (e.g., "LobeHub Assistant"), description, and icon, then submit the form.
### Copy App Credentials
Go to **Credentials & Basic Info** and copy:
- **App ID** (format: `cli_xxx`)
- **App Secret**
> **Important:** Keep your App Secret confidential. Never share it publicly.
</Steps>
## Step 2: Configure App Permissions and Bot
<Steps>
### Import Required Permissions
In your app settings, go to **Permissions & Scopes**, click **Batch Import**, and paste the JSON below to grant the bot all necessary permissions.
```json
{
"scopes": {
"tenant": [
"aily:file:read",
"aily:file:write",
"application:application.app_message_stats.overview:readonly",
"application:application:self_manage",
"application:bot.menu:write",
"cardkit:card:read",
"cardkit:card:write",
"contact:user.employee_id:readonly",
"corehr:file:download",
"event:ip_list",
"im:chat.access_event.bot_p2p_chat:read",
"im:chat.members:bot_access",
"im:message",
"im:message.group_at_msg:readonly",
"im:message.p2p_msg:readonly",
"im:message:readonly",
"im:message:send_as_bot",
"im:resource"
],
"user": [
"aily:file:read",
"aily:file:write",
"im:chat.access_event.bot_p2p_chat:read"
]
}
}
```
<Callout type={'warning'}>
The JSON above is for **Feishu (飞书)**. If you are using **Lark (international)**, some scopes may not be available (e.g. `aily:*`, `corehr:*`, `im:chat.access_event.bot_p2p_chat:read`). Remove any scopes that the batch import rejects.
</Callout>
### Enable Bot Capability
Go to **App Capability** → **Bot**. Toggle the bot capability on and set your preferred bot name.
</Steps>
## Step 3: Configure Feishu / Lark in LobeHub
<Steps>
### Open Channel Settings
In LobeHub, navigate to your agent's settings, then select the **Channels** tab. Click **飞书** (Feishu) or **Lark** from the platform list.
### Fill in App Credentials
Enter the following fields:
- **App ID** — The App ID from your Feishu/Lark app
- **App Secret** — The App Secret from your Feishu/Lark app
> You don't need to fill in **Verification Token** or **Encrypt Key** at this point — you can set them up after configuring the Event Subscription in Step 4.
### Save and Copy the Webhook URL
Click **Save Configuration**. After saving, an **Event Subscription URL** will be displayed. Copy this URL — you will need it in the next step.
</Steps>
## Step 4: Set Up Event Subscription in Feishu / Lark
<Steps>
### Open Event Subscription Settings
Go back to your app in the Feishu/Lark Developer Portal. Navigate to **Event Subscription**.
### Configure the Request URL
Paste the **Event Subscription URL** you copied from LobeHub into the **Request URL** field. The platform will verify the endpoint automatically.
### Add the Message Event
Add the following event:
- `im.message.receive_v1` — Triggered when a message is received
This allows your app to receive messages and forward them to LobeHub.
### (Recommended) Fill in Verification Token and Encrypt Key
After configuring Event Subscription, you can find the **Verification Token** and **Encrypt Key** at the top of the Event Subscription page under **Encryption Strategy**.
Go back to LobeHub's channel settings and fill in:
- **Verification Token** — Used to verify that webhook events originate from Feishu/Lark
- **Encrypt Key** (optional) — Used to decrypt encrypted event payloads
Click **Save Configuration** again to apply.
</Steps>
## Step 5: Publish the App
<Steps>
### Create a Version
In your app settings, go to **Version Management & Release**. Create a new version with release notes.
### Submit for Review
Submit the version for review and publish. For enterprise self-managed apps, approval is typically automatic.
</Steps>
## Step 6: Test the Connection
Back in LobeHub's channel settings, click **Test Connection** to verify the credentials. Then find your bot in Feishu/Lark by searching its name and send it a message to confirm it responds.
## Configuration Reference
| Field | Required | Description |
| -------------------------- | -------- | -------------------------------------------------------------------- |
| **App ID** | Yes | Your Feishu/Lark app's App ID (`cli_xxx`) |
| **App Secret** | Yes | Your Feishu/Lark app's App Secret |
| **Verification Token** | No | Verifies webhook event source (recommended) |
| **Encrypt Key** | No | Decrypts encrypted event payloads |
| **Event Subscription URL** | — | Auto-generated after saving; paste into Feishu/Lark Developer Portal |
## Troubleshooting
- **Event Subscription URL verification failed:** Ensure you saved the configuration in LobeHub first, and the URL was copied correctly.
- **Bot not responding:** Verify the app is published and approved, the bot capability is enabled, and the `im.message.receive_v1` event is subscribed.
- **Permission errors:** Confirm all required permissions are added and approved in the Developer Portal.
- **Test Connection failed:** Double-check the App ID and App Secret. For Lark, ensure you selected "Lark" (not "飞书") in LobeHub's channel settings.
+177
View File
@@ -0,0 +1,177 @@
---
title: 将 LobeHub 连接到飞书 / Lark
description: 了解如何创建飞书(Lark)自定义应用并将其连接到您的 LobeHub 代理作为消息渠道,使您的 AI 助手能够在飞书或 Lark 聊天中与团队成员互动。
tags:
- 飞书
- Lark
- 消息渠道
- 机器人设置
- 集成
---
# 将 LobeHub 连接到飞书 / Lark
<Callout type={'info'}>
此功能目前正在开发中,可能尚未完全稳定。您可以通过在 **设置** → **高级设置** → **开发者模式**
中启用 **开发者模式** 来使用此功能。
</Callout>
通过将飞书(或 Lark)渠道连接到您的 LobeHub 代理,团队成员可以直接在飞书的私聊和群组对话中与 AI 助手互动。
> 飞书是中国版本,Lark 是国际版本。设置过程完全相同 —— 只需使用对应的平台门户即可。
## 前置条件
- 一个拥有有效订阅的 LobeHub 账户
- 一个拥有创建企业应用权限的飞书或 Lark 账户
## 第一步:创建飞书 / Lark 应用
<Steps>
### 打开开发者门户
- **飞书:** 访问 [open.feishu.cn/app](https://open.feishu.cn/app)
- **Lark** 访问 [open.larksuite.com/app](https://open.larksuite.com/app)
使用您的账户登录。
### 创建企业应用
点击 **创建企业应用**。填写应用名称(例如 "LobeHub 助手")、描述和图标,然后提交表单。
### 复制应用凭证
进入 **凭证与基本信息**,复制以下内容:
- **应用 ID**(格式:`cli_xxx`
- **应用密钥**
> **重要提示:** 请妥善保管您的应用密钥。切勿公开分享。
</Steps>
## 第二步:配置应用权限和机器人功能
<Steps>
### 导入所需权限
在您的应用设置中,进入 **权限与范围**,点击 **批量导入**,然后粘贴以下 JSON 以授予机器人所需的所有权限。
```json
{
"scopes": {
"tenant": [
"aily:file:read",
"aily:file:write",
"application:application.app_message_stats.overview:readonly",
"application:application:self_manage",
"application:bot.menu:write",
"cardkit:card:read",
"cardkit:card:write",
"contact:user.employee_id:readonly",
"corehr:file:download",
"event:ip_list",
"im:chat.access_event.bot_p2p_chat:read",
"im:chat.members:bot_access",
"im:message",
"im:message.group_at_msg:readonly",
"im:message.p2p_msg:readonly",
"im:message:readonly",
"im:message:send_as_bot",
"im:resource"
],
"user": [
"aily:file:read",
"aily:file:write",
"im:chat.access_event.bot_p2p_chat:read"
]
}
}
```
<Callout type={'warning'}>
以上 JSON 适用于**飞书**。如果您使用的是 **Lark(国际版)**,部分权限码可能不可用(如 `aily:*`、`corehr:*`、`im:chat.access_event.bot_p2p_chat:read`)。请移除批量导入时提示无效的权限码。
</Callout>
### 启用机器人功能
进入 **应用能力** → **机器人**。开启机器人功能并设置您喜欢的机器人名称。
</Steps>
## 第三步:在 LobeHub 中配置飞书 / Lark
<Steps>
### 打开渠道设置
在 LobeHub 中,导航到您的代理设置,然后选择 **渠道** 标签。点击平台列表中的 **飞书** 或 **Lark**。
### 填写应用凭证
输入以下字段:
- **应用 ID** — 来自飞书 / Lark 应用的应用 ID
- **应用密钥** — 来自飞书 / Lark 应用的应用密钥
- **Verification Token** — 用于验证 webhook 事件是否来自飞书 / Lark
您还可以选择配置以下内容:
- **Encrypt Key** — 用于解密飞书 / Lark 的加密事件负载
> Verification Token 和 Encrypt Key 可以在飞书 / Lark 开发者门户的 **事件订阅** → **加密策略** 中找到(位于页面顶部)。如果您还没有打开过事件订阅页面,可以在完成第四步后再回来填写 Verification Token。
### 保存并复制 Webhook URL
点击 **保存配置**。保存后,将显示一个 **事件订阅 URL**。复制此 URL—— 您将在下一步中需要它。
</Steps>
## 第四步:在飞书 / Lark 中设置事件订阅
<Steps>
### 打开事件订阅设置
返回飞书 / Lark 开发者门户中的应用。导航到 **事件订阅**。
### 配置请求 URL
将您从 LobeHub 复制的 **事件订阅 URL** 粘贴到 **请求 URL** 字段中。平台会自动验证端点。
### 添加消息事件
添加以下事件:
- `im.message.receive_v1` — 当收到消息时触发
这将使您的应用能够接收消息并将其转发到 LobeHub。
</Steps>
## 第五步:发布应用
<Steps>
### 创建版本
在您的应用设置中,进入 **版本管理与发布**。创建一个新版本并填写发布说明。
### 提交审核
提交版本进行审核并发布。对于企业自管理应用,通常会自动批准。
</Steps>
## 第六步:测试连接
回到 LobeHub 的渠道设置,点击 **测试连接** 以验证凭证。然后在飞书 / Lark 中搜索您的机器人名称并发送消息,确认其是否响应。
## 配置参考
| 字段 | 是否必需 | 描述 |
| ---------------------- | ---- | ------------------------------- |
| **应用 ID** | 是 | 您的飞书 / Lark 应用的应用 ID`cli_xxx` |
| **应用密钥** | 是 | 您的飞书 / Lark 应用的应用密钥 |
| **Verification Token** | 是 | 验证 webhook 事件来源 |
| **Encrypt Key** | 否 | 解密加密事件负载 |
| **事件订阅 URL** | — | 保存后自动生成;粘贴到飞书 / Lark 开发者门户 |
## 故障排除
- **事件订阅 URL 验证失败:** 确保您已在 LobeHub 中保存配置,并正确复制了 URL。
- **机器人未响应:** 验证应用已发布并获得批准,机器人功能已启用,并订阅了 `im.message.receive_v1` 事件。
- **权限错误:** 确保所有所需权限已在开发者门户中添加并获得批准。
- **测试连接失败:** 仔细检查应用 ID 和应用密钥。对于 Lark,请确保您在 LobeHub 的渠道设置中选择了 "Lark"(而不是 "飞书")。
+60
View File
@@ -0,0 +1,60 @@
---
title: Channels Overview
description: >-
Connect your LobeHub agents to external messaging platforms like Discord,
Telegram, and Feishu/Lark, allowing users to interact with AI assistants
directly in their favorite chat apps.
tags:
- Channels
- Message Channels
- Integration
- Discord
- Telegram
- Feishu
- Lark
---
# Channels
<Callout type={'info'}>
This feature is currently in development and may not be fully stable. You can enable it by turning on **Developer Mode** in **Settings** → **Advanced Settings** → **Developer Mode**.
</Callout>
Channels allow you to connect your LobeHub agents to external messaging platforms. Once connected, users can interact with your AI assistant directly in the chat apps they already use — no need to visit LobeHub.
## Supported Platforms
| Platform | Description |
| -------------------------------------------- | --------------------------------------------------------------- |
| [Discord](/docs/usage/channels/discord) | Connect to Discord servers for channel chat and direct messages |
| [Telegram](/docs/usage/channels/telegram) | Connect to Telegram for private and group conversations |
| [Feishu / Lark](/docs/usage/channels/feishu) | Connect to Feishu (飞书) or Lark for team collaboration |
## How It Works
Each channel integration works by linking a bot account on the target platform to a LobeHub agent. When a user sends a message to the bot, LobeHub processes it through the agent and sends the response back to the same conversation.
- **Per-agent configuration** — Each agent can have its own set of channel connections, so different agents can serve different platforms or communities.
- **Multiple channels simultaneously** — A single agent can be connected to Discord, Telegram, and Feishu/Lark at the same time. LobeHub routes messages to the correct agent automatically.
- **Secure credential storage** — All bot tokens and app secrets are encrypted before being stored.
## Getting Started
1. Enable **Developer Mode** in LobeHub: **Settings** → **Advanced Settings** → **Developer Mode**
2. Navigate to your agent's settings and select the **Channels** tab
3. Choose a platform and follow the setup guide:
- [Discord](/docs/usage/channels/discord)
- [Telegram](/docs/usage/channels/telegram)
- [Feishu / Lark](/docs/usage/channels/feishu)
## Feature Support
Text messages are supported across all platforms. Some features vary by platform:
| Feature | Discord | Telegram | Feishu / Lark |
| ---------------------- | ------- | -------- | ------------- |
| Text messages | Yes | Yes | Yes |
| Direct messages | Yes | Yes | Yes |
| Group chats | Yes | Yes | Yes |
| Reactions | Yes | Yes | Partial |
| Image/file attachments | Yes | Yes | Yes |
+57
View File
@@ -0,0 +1,57 @@
---
title: 渠道概览
description: 将 LobeHub 代理连接到外部消息平台,如 Discord、Telegram 和飞书/Lark,让用户可以直接在他们喜欢的聊天应用中与 AI 助手互动。
tags:
- 渠道
- 消息渠道
- 集成
- Discord
- Telegram
- 飞书
- Lark
---
# 渠道
<Callout type={'info'}>
此功能目前正在开发中,可能尚未完全稳定。您可以通过在 **设置** → **高级设置** → **开发者模式** 中启用 **开发者模式** 来开启此功能。
</Callout>
渠道功能允许您将 LobeHub 代理连接到外部消息平台。一旦连接,用户可以直接在他们已经使用的聊天应用中与您的 AI 助手互动,无需访问 LobeHub。
## 支持的平台
| 平台 | 描述 |
| ----------------------------------------- | -------------------------- |
| [Discord](/docs/usage/channels/discord) | 连接到 Discord 服务器,用于频道聊天和私信 |
| [Telegram](/docs/usage/channels/telegram) | 连接到 Telegram,用于私人和群组对话 |
| [飞书 / Lark](/docs/usage/channels/feishu) | 连接到飞书(Feishu)或 Lark,用于团队协作 |
## 工作原理
每个渠道集成都通过将目标平台上的机器人账户与 LobeHub 代理连接来实现。当用户向机器人发送消息时,LobeHub 会通过代理处理消息并将响应发送回同一对话。
- **按代理配置** — 每个代理可以拥有自己的一组渠道连接,因此不同的代理可以服务于不同的平台或社区。
- **同时支持多个渠道** — 单个代理可以同时连接到 Discord、Telegram 和飞书 / Lark。LobeHub 会自动将消息路由到正确的代理。
- **安全的凭据存储** — 所有机器人令牌和应用密钥在存储前都会被加密。
## 快速开始
1. 在 LobeHub 中启用 **开发者模式****设置** → **高级设置** → **开发者模式**
2. 前往您的代理设置页面,选择 **渠道** 标签
3. 选择一个平台并按照设置指南操作:
- [Discord](/docs/usage/channels/discord)
- [Telegram](/docs/usage/channels/telegram)
- [飞书 / Lark](/docs/usage/channels/feishu)
## 功能支持
所有平台均支持文本消息。某些功能因平台而异:
| 功能 | Discord | Telegram | 飞书 / Lark |
| --------- | ------- | -------- | --------- |
| 文本消息 | 是 | 是 | 是 |
| 私人消息 | 是 | 是 | 是 |
| 群组聊天 | 是 | 是 | 是 |
| 表情反应 | 是 | 是 | 部分支持 |
| 图片 / 文件附件 | 是 | 是 | 是 |
+97
View File
@@ -0,0 +1,97 @@
---
title: Connect LobeHub to Telegram
description: >-
Learn how to create a Telegram bot and connect it to your LobeHub agent as a
message channel, enabling your AI assistant to chat with users in Telegram
private and group conversations.
tags:
- Telegram
- Message Channels
- Bot Setup
- Integration
---
# Connect LobeHub to Telegram
<Callout type={'info'}>
This feature is currently in development and may not be fully stable. You can enable it by turning
on **Developer Mode** in **Settings** → **Advanced Settings** → **Developer Mode**.
</Callout>
By connecting a Telegram channel to your LobeHub agent, users can interact with the AI assistant through Telegram private chats and group conversations.
## Prerequisites
- A LobeHub account with an active subscription
- A Telegram account
## Step 1: Create a Telegram Bot
<Steps>
### Open BotFather
Open Telegram and search for **@BotFather** — the official Telegram bot for managing bots. Start a conversation and send the `/newbot` command.
### Set Bot Name and Username
BotFather will ask you to:
1. Choose a **display name** for your bot (e.g., "LobeHub Assistant")
2. Choose a **username** — it must end with `bot` (e.g., `lobehub_assistant_bot`)
### Copy the Bot Token
After creating the bot, BotFather will send you an **API token** (format: `123456789:ABCdefGhIjKlmNoPQRsTuVwXyZ`). Copy and save this token.
> **Important:** Your bot token is a secret credential. Never share it publicly.
</Steps>
## Step 2: Configure Telegram in LobeHub
<Steps>
### Open Channel Settings
In LobeHub, navigate to your agent's settings, then select the **Channels** tab. Click **Telegram** from the platform list.
### Enter the Bot Token
Paste the bot token you received from BotFather into the **Bot Token** field.
The **Bot User ID** will be automatically derived from your token — no need to enter it manually.
### Optional: Set a Webhook Secret
You can optionally enter a **Webhook Secret Token** for additional security. This is used to verify that incoming webhook requests originate from Telegram.
### Save Configuration
Click **Save Configuration**. LobeHub will automatically register the webhook URL with Telegram — no manual URL copying is required.
Your token will be encrypted and stored securely.
</Steps>
## Step 3: Test the Connection
Click **Test Connection** in LobeHub's channel settings to verify the integration. Then open Telegram, find your bot by searching its username, and send a message. The bot should respond through your LobeHub agent.
## Adding the Bot to Group Chats
To use the bot in Telegram groups:
1. Add the bot as a member of the group
2. By default, the bot responds when mentioned with `@your_bot_username`
3. Send a message mentioning the bot to start interacting
## Configuration Reference
| Field | Required | Description |
| ------------------------ | -------- | ---------------------------------------------- |
| **Bot Token** | Yes | API token from BotFather |
| **Bot User ID** | Auto | Automatically derived from the bot token |
| **Webhook Secret Token** | No | Optional secret for verifying webhook requests |
## Troubleshooting
- **Bot not responding:** Verify the bot token is correct and the configuration is saved. Click **Test Connection** to diagnose.
- **Webhook registration failed:** Ensure your LobeHub subscription is active. Telegram requires HTTPS endpoints for webhooks, which LobeHub provides automatically.
- **Group chat issues:** Make sure the bot has been added to the group and has permission to read messages. Mention the bot with `@username` to trigger a response.
+95
View File
@@ -0,0 +1,95 @@
---
title: 将 LobeHub 连接到 Telegram
description: >-
学习如何创建一个 Telegram 机器人并将其连接到 LobeHub 代理作为消息渠道,使您的 AI 助手能够在 Telegram
私聊和群组对话中与用户互动。
tags:
- Telegram
- 消息渠道
- 机器人设置
- 集成
---
# 将 LobeHub 连接到 Telegram
<Callout type={'info'}>
此功能目前正在开发中,可能尚未完全稳定。您可以通过在 **设置** → **高级设置** → **开发者模式** 中启用 **开发者模式** 来使用此功能。
</Callout>
通过将 Telegram 渠道连接到您的 LobeHub 代理,用户可以通过 Telegram 私聊和群组对话与 AI 助手互动。
## 前置条件
- 一个拥有有效订阅的 LobeHub 账户
- 一个 Telegram 账户
## 第一步:创建 Telegram 机器人
<Steps>
### 打开 BotFather
打开 Telegram 并搜索 **@BotFather** —— 这是用于管理机器人的官方 Telegram 机器人。开始对话并发送 `/newbot` 命令。
### 设置机器人名称和用户名
BotFather 会要求您:
1. 为您的机器人选择一个 **显示名称**(例如,“LobeHub 助手”)
2. 选择一个 **用户名** —— 必须以 `bot` 结尾(例如,`lobehub_assistant_bot`
### 复制机器人令牌
创建机器人后,BotFather 会发送给您一个 **API 令牌**(格式:`123456789:ABCdefGhIjKlmNoPQRsTuVwXyZ`)。复制并保存此令牌。
> **重要提示:** 您的机器人令牌是一个机密凭证,请勿公开分享。
</Steps>
## 第二步:在 LobeHub 中配置 Telegram
<Steps>
### 打开渠道设置
在 LobeHub 中,导航到您的代理设置,然后选择 **渠道** 标签页。从平台列表中点击 **Telegram**。
### 输入机器人令牌
将您从 BotFather 收到的机器人令牌粘贴到 **机器人令牌** 字段中。
**机器人用户 ID** 将根据您的令牌自动生成,无需手动输入。
### 可选:设置 Webhook 密钥
您可以选择输入一个 **Webhook 密钥令牌** 以增加安全性。此密钥用于验证来自 Telegram 的入站 Webhook 请求。
### 保存配置
点击 **保存配置**。LobeHub 将自动向 Telegram 注册 Webhook URL,无需手动复制 URL。
您的令牌将被加密并安全存储。
</Steps>
## 第三步:测试连接
在 LobeHub 的渠道设置中点击 **测试连接** 以验证集成。然后打开 Telegram,搜索您的机器人用户名并发送消息。机器人应通过您的 LobeHub 代理进行响应。
## 将机器人添加到群组聊天
要在 Telegram 群组中使用机器人:
1. 将机器人添加为群组成员
2. 默认情况下,机器人在被 `@your_bot_username` 提及时会响应
3. 发送一条提及机器人的消息以开始互动
## 配置参考
| 字段 | 是否必需 | 描述 |
| ---------------- | ---- | --------------------- |
| **机器人令牌** | 是 | 来自 BotFather 的 API 令牌 |
| **机器人用户 ID** | 自动 | 根据机器人令牌自动生成 |
| **Webhook 密钥令牌** | 否 | 用于验证 Webhook 请求的可选密钥 |
## 故障排除
- **机器人未响应:** 验证机器人令牌是否正确并确保配置已保存。点击 **测试连接** 进行诊断。
- **Webhook 注册失败:** 确保您的 LobeHub 订阅处于活动状态。Telegram 要求 Webhook 使用 HTTPS 端点,LobeHub 会自动提供。
- **群组聊天问题:** 确保机器人已被添加到群组并具有读取消息的权限。使用 `@username` 提及机器人以触发响应。
@@ -1,7 +1,6 @@
---
title: 社区创作者
description: >-
加入 LobeHub 社区成为创作者——发布助理、分享工作流,打造帮助千万用户提效的工具。
description: 加入 LobeHub 社区成为创作者——发布助理、分享工作流,打造帮助千万用户提效的工具。
tags:
- LobeHub
- 社区创作者
+3 -4
View File
@@ -1,10 +1,9 @@
---
title: Introduction
description: >-
LobeHub is the next-generation agent harness designed to democratize AI
power. Move beyond one-off, task-driven tools and build long-term agent
teammates that grow with you in the worlds largest humanagent co-evolving
network.
LobeHub is the next-generation agent harness designed to democratize AI power.
Move beyond one-off, task-driven tools and build long-term agent teammates
that grow with you in the worlds largest humanagent co-evolving network.
tags:
- LobeHub
- Getting Started
+2 -2
View File
@@ -1,8 +1,8 @@
---
title: 简介
description: >-
LobeHub 是下一代 Agent harness,旨在让 AI 能力大众化。超越一次性、以任务为驱动的工具,构建能随着您一起成长的长期
Agent 队友,加入全球最大的人与 Agent 共生网络。
LobeHub 是下一代 Agent harness,旨在让 AI 能力大众化。超越一次性、以任务为驱动的工具,构建能随着您一起成长的长期 Agent
队友,加入全球最大的人与 Agent 共生网络。
tags:
- LobeHub
- 入门指南
+2 -1
View File
@@ -1,7 +1,8 @@
---
title: Interface Appearance
description: >-
Customize LobeHub's look — theme, colors, language, code highlighting, and Mermaid diagrams. Make it yours.
Customize LobeHub's look — theme, colors, language, code highlighting, and
Mermaid diagrams. Make it yours.
tags:
- LobeHub
- Appearance
@@ -1,7 +1,6 @@
---
title: 界面外观
description: >-
自定义 LobeHub 的外观——主题、色彩、语言、代码高亮与 Mermaid 图表。打造属于你的界面。
description: 自定义 LobeHub 的外观——主题、色彩、语言、代码高亮与 Mermaid 图表。打造属于你的界面。
tags:
- LobeHub
- 外观
+2 -1
View File
@@ -1,7 +1,8 @@
---
title: Command Menu
description: >-
The quick action center of LobeHub — search Agents, Topics, settings, and jump anywhere with a few keystrokes.
The quick action center of LobeHub — search Agents, Topics, settings, and jump
anywhere with a few keystrokes.
tags:
- LobeHub
- Command Menu
@@ -1,7 +1,6 @@
---
title: 命令菜单
description: >-
LobeHub 的快捷操作中心——搜索助理、话题、设置,用几个按键跳转到任意位置。
description: LobeHub 的快捷操作中心——搜索助理、话题、设置,用几个按键跳转到任意位置。
tags:
- LobeHub
- 命令菜单
+2 -1
View File
@@ -1,7 +1,8 @@
---
title: Keyboard Shortcuts
description: >-
Master LobeHub with keyboard shortcuts — command palette, Agent switching, focus mode, and more. Customize to fit your workflow.
Master LobeHub with keyboard shortcuts — command palette, Agent switching,
focus mode, and more. Customize to fit your workflow.
tags:
- LobeHub
- Keyboard Shortcuts
@@ -1,7 +1,6 @@
---
title: 快捷键
description: >-
用快捷键掌控 LobeHub——命令面板、助理切换、专注模式等。按你的习惯自定义。
description: 用快捷键掌控 LobeHub——命令面板、助理切换、专注模式等。按你的习惯自定义。
tags:
- LobeHub
- 快捷键
+2 -1
View File
@@ -1,7 +1,8 @@
---
title: Data Analytics
description: >-
Track your LobeHub usage — days active, Agents, conversations, model usage. Visualize patterns and share your stats.
Track your LobeHub usage — days active, Agents, conversations, model usage.
Visualize patterns and share your stats.
tags:
- LobeHub
- Data Analytics
+1 -2
View File
@@ -1,7 +1,6 @@
---
title: 数据统计
description: >-
追踪你的 LobeHub 使用情况——活跃天数、助理、对话、模型使用。可视化你的使用模式,并分享统计结果。
description: 追踪你的 LobeHub 使用情况——活跃天数、助理、对话、模型使用。可视化你的使用模式,并分享统计结果。
tags:
- LobeHub
- 数据统计
+12 -31
View File
@@ -11,7 +11,7 @@
import { Given, Then, When } from '@cucumber/cucumber';
import { expect } from '@playwright/test';
import { CustomWorld } from '../../support/world';
import type { CustomWorld } from '../../support/world';
// ============================================
// Given Steps
@@ -158,25 +158,9 @@ When('用户点击另一个对话', async function (this: CustomWorld) {
}
// Fallback: try to find topic items in the sidebar
// Topics are displayed with star icons (lucide-star) in the left sidebar
const sidebarTopics = this.page.locator('svg.lucide-star').locator('..').locator('..');
let topicCount = await sidebarTopics.count();
console.log(` 📍 Found ${topicCount} topics with star icons`);
// If not found by star, try finding by topic list structure
if (topicCount < 2) {
// Topics might be in a list container - look for items in sidebar with specific text
const topicItems = this.page.locator('[class*="nav-item"], [class*="NavItem"]');
topicCount = await topicItems.count();
console.log(` 📍 Found ${topicCount} nav items`);
if (topicCount >= 2) {
await topicItems.nth(1).click();
console.log(' ✅ 已点击另一个对话');
await this.page.waitForTimeout(500);
return;
}
}
const sidebarTopics = this.page.locator('[data-testid="topic-item"]');
const topicCount = await sidebarTopics.count();
console.log(` 📍 Found ${topicCount} topic items`);
// Click the second topic (first one is current/active)
if (topicCount >= 2) {
@@ -192,13 +176,11 @@ When('用户点击另一个对话', async function (this: CustomWorld) {
When('用户右键点击对话', async function (this: CustomWorld) {
console.log(' 📍 Step: 右键点击对话...');
// Find topic items by their star icon - each saved topic has a star
const sidebarTopics = this.page.locator('svg.lucide-star').locator('..').locator('..');
let topicCount = await sidebarTopics.count();
console.log(` 📍 Found ${topicCount} topics with star icons`);
const sidebarTopics = this.page.locator('[data-testid="topic-item"]');
const topicCount = await sidebarTopics.count();
console.log(` 📍 Found ${topicCount} topic items`);
if (topicCount > 0) {
// Right-click the first saved topic
await sidebarTopics.first().click({ button: 'right' });
console.log(' ✅ 已右键点击对话');
} else {
@@ -211,10 +193,9 @@ When('用户右键点击对话', async function (this: CustomWorld) {
When('用户右键点击一个对话', async function (this: CustomWorld) {
console.log(' 📍 Step: 右键点击一个对话...');
// Find topic items by their star icon
const sidebarTopics = this.page.locator('svg.lucide-star').locator('..').locator('..');
let topicCount = await sidebarTopics.count();
console.log(` 📍 Found ${topicCount} topics with star icons`);
const sidebarTopics = this.page.locator('[data-testid="topic-item"]');
const topicCount = await sidebarTopics.count();
console.log(` 📍 Found ${topicCount} topic items`);
// Store the topic text for later verification
if (topicCount > 0) {
@@ -238,7 +219,7 @@ When('用户选择重命名选项', async function (this: CustomWorld) {
// Instead of using right-click context menu, use the "..." dropdown menu
// which appears when hovering over a topic item
const topicItems = this.page.locator('svg.lucide-star').locator('..').locator('..');
const topicItems = this.page.locator('[data-testid="topic-item"]');
const topicCount = await topicItems.count();
console.log(` 📍 Found ${topicCount} topic items`);
@@ -253,7 +234,7 @@ When('用户选择重命名选项', async function (this: CustomWorld) {
// Important: we must find the icon WITHIN the hovered topic, not the global one
// The topic item has a specific structure with nav-item-actions
const moreButtonInTopic = firstTopic.locator('svg.lucide-ellipsis, svg.lucide-more-horizontal');
let moreButtonCount = await moreButtonInTopic.count();
const moreButtonCount = await moreButtonInTopic.count();
console.log(` 📍 Found ${moreButtonCount} more buttons inside topic`);
if (moreButtonCount > 0) {
+5
View File
@@ -10,6 +10,8 @@
"integration.copied": "تم النسخ إلى الحافظة",
"integration.copy": "نسخ",
"integration.deleteConfirm": "هل أنت متأكد أنك تريد إزالة هذا التكامل؟",
"integration.devWebhookProxyUrl": "عنوان URL لنفق HTTPS",
"integration.devWebhookProxyUrlHint": "يتطلب Telegram HTTPS للويب هوك. قم بلصق عنوان URL للنفق الخاص بك (مثلًا من cloudflared أو ngrok) لتوجيه طلبات الويب هوك إلى خادم التطوير المحلي الخاص بك.",
"integration.disabled": "معطل",
"integration.discord.description": "قم بتوصيل هذا المساعد بخادم Discord للدردشة في القنوات والرسائل المباشرة.",
"integration.documentation": "التوثيق",
@@ -26,6 +28,9 @@
"integration.saveFailed": "فشل في حفظ الإعدادات",
"integration.saveFirstWarning": "يرجى حفظ الإعدادات أولاً",
"integration.saved": "تم حفظ الإعدادات بنجاح",
"integration.secretToken": "رمز سري للويب هوك",
"integration.secretTokenHint": "اختياري. يُستخدم للتحقق من طلبات الويب هوك من Telegram.",
"integration.secretTokenPlaceholder": "رمز سري اختياري للتحقق من الويب هوك",
"integration.testConnection": "اختبار الاتصال",
"integration.testFailed": "فشل اختبار الاتصال",
"integration.testSuccess": "نجح اختبار الاتصال",
+3
View File
@@ -5,6 +5,7 @@
"alert.cloud.desc": "جميع المستخدمين المسجلين يحصلون على {{credit}} من أرصدة الحوسبة المجانية شهريًا — دون الحاجة إلى إعداد. يشمل المزامنة السحابية العالمية والبحث المتقدم على الويب.",
"alert.cloud.descOnMobile": "جميع المستخدمين المسجلين يحصلون على {{credit}} من أرصدة الحوسبة المجانية شهريًا — دون الحاجة إلى إعداد.",
"alert.cloud.title": "النسخة التجريبية من {{name}} متاحة الآن",
"alreadyUpToDate": "محدث بالفعل",
"appLoading.appIdle": "جاهز للبدء",
"appLoading.appInitializing": "يتم تشغيل التطبيق...",
"appLoading.failed": "حدث خطأ أثناء بدء التشغيل. اعرض التفاصيل لاستكشاف الأخطاء، أو حاول مرة أخرى لاحقًا.",
@@ -211,6 +212,7 @@
"delete": "حذف",
"document": "دليل المستخدم",
"download": "تنزيل",
"downloadingUpdate": "جارٍ تنزيل {{percent}}%",
"duplicate": "تكرار",
"edit": "تعديل",
"errors.invalidFileFormat": "تنسيق الملف غير صالح",
@@ -360,6 +362,7 @@
"releaseNotes": "تفاصيل الإصدار",
"rename": "إعادة التسمية",
"reset": "إعادة تعيين",
"restartToUpdate": "أعد التشغيل للتحديث",
"retry": "إعادة المحاولة",
"run": "تشغيل",
"save": "حفظ",
+4
View File
@@ -93,6 +93,10 @@
"sync.mode.useSelfHosted": "هل تريد استخدام نسخة مستضافة ذاتياً؟",
"sync.selfHosted.description": "نسخة المجتمع التي يمكنك نشرها بنفسك",
"sync.selfHosted.title": "نسخة مستضافة ذاتياً",
"tab.closeCurrentTab": "إغلاق علامة التبويب",
"tab.closeLeftTabs": "إغلاق العلامات على اليسار",
"tab.closeOtherTabs": "إغلاق العلامات الأخرى",
"tab.closeRightTabs": "إغلاق العلامات على اليمين",
"updater.checkingUpdate": "جارٍ التحقق من التحديثات",
"updater.checkingUpdateDesc": "جارٍ جلب معلومات الإصدار...",
"updater.downloadNewVersion": "تحميل الإصدار الجديد",
+1
View File
@@ -100,6 +100,7 @@
"pageEditor.saving": "جارٍ الحفظ...",
"pageEditor.titlePlaceholder": "بدون عنوان",
"pageEditor.wordCount": "{{wordCount}} كلمة",
"pageList.actions.openInNewTab": "افتح في علامة تبويب جديدة",
"pageList.copyContent": "نسخ النص الكامل",
"pageList.duplicate": "تكرار",
"pageList.empty": "لا توجد صفحات بعد. انقر على الزر أعلاه لإنشاء أول صفحة.",
+1
View File
@@ -681,6 +681,7 @@
"gpt-5.2-chat-latest.description": "GPT-5.2 Chat هو إصدار ChatGPT المخصص لأحدث تحسينات المحادثة.",
"gpt-5.2-pro.description": "GPT-5.2 Pro: إصدار أكثر ذكاءً ودقة من GPT-5.2 (لواجهة Responses API فقط)، مناسب للمشكلات الصعبة والاستدلال متعدد الأدوار الطويل.",
"gpt-5.2.description": "GPT-5.2 هو نموذج رائد لتدفقات العمل البرمجية والتلقائية مع استدلال أقوى وأداء سياقي طويل.",
"gpt-5.4.description": "GPT-5.4 هو أحدث نموذج من OpenAI للعمل المهني المعقد ويعتبر بديلاً مباشراً لـ GPT-5.2 و GPT-5.3 Codex.",
"gpt-5.description": "أفضل نموذج لمهام البرمجة والتلقائية عبر المجالات. يحقق GPT-5 قفزات في الدقة والسرعة والاستدلال والوعي بالسياق والتفكير المنظم وحل المشكلات.",
"gpt-audio.description": "GPT Audio هو نموذج دردشة عام يدعم الإدخال والإخراج الصوتي، مدعوم في واجهة Chat Completions API.",
"gpt-image-1-mini.description": "إصدار منخفض التكلفة من GPT Image 1 يدعم إدخال نصوص وصور وإخراج صور.",
+10
View File
@@ -207,6 +207,7 @@
"header.sessionDesc": "ملف تعريف الوكيل وتفضيلات الجلسة",
"header.sessionWithName": "إعدادات الجلسة · {{name}}",
"header.title": "الإعدادات",
"hotkey.clearBinding": "مسح الربط",
"hotkey.conflicts": "تعارض مع اختصارات موجودة",
"hotkey.errors.CONFLICT": "تعارض في الاختصار: هذا الاختصار مستخدم بالفعل لوظيفة أخرى",
"hotkey.errors.INVALID_FORMAT": "تنسيق اختصار غير صالح: يرجى استخدام التنسيق الصحيح (مثل CommandOrControl+E)",
@@ -694,6 +695,15 @@
"tab.agent": "خدمة الوكيل",
"tab.all": "الكل",
"tab.apikey": "إدارة مفاتيح API",
"tab.beta": "بيتا",
"tab.beta.updateChannel.canary": "كناري",
"tab.beta.updateChannel.canaryDesc": "يتم تشغيله عند كل دمج PR، مع عدة إصدارات يومياً. الأكثر عدم استقراراً.",
"tab.beta.updateChannel.desc": "افتراضياً، يتم تلقي الإشعارات للتحديثات المستقرة. قنوات Nightly وCanary تتلقى إصدارات ما قبل الإصدار التي قد تكون غير مستقرة للعمل الإنتاجي.",
"tab.beta.updateChannel.nightly": "ليلي",
"tab.beta.updateChannel.nightlyDesc": "إصدارات يومية تلقائية مع أحدث التغييرات.",
"tab.beta.updateChannel.stable": "مستقر",
"tab.beta.updateChannel.stableDesc": "إصدارات جاهزة للإنتاج.",
"tab.beta.updateChannel.title": "قناة التحديث",
"tab.chatAppearance": "مظهر المحادثة",
"tab.common": "المظهر",
"tab.experiment": "تجريبي",
+1
View File
@@ -7,6 +7,7 @@
"actions.duplicate": "نسخ",
"actions.export": "تصدير المواضيع",
"actions.import": "استيراد المحادثة",
"actions.openInNewTab": "افتح في علامة تبويب جديدة",
"actions.openInNewWindow": "فتح في نافذة جديدة",
"actions.removeAll": "حذف جميع المواضيع",
"actions.removeUnstarred": "حذف المواضيع غير المميزة",
+5
View File
@@ -10,6 +10,8 @@
"integration.copied": "Копирано в клипборда",
"integration.copy": "Копирай",
"integration.deleteConfirm": "Сигурни ли сте, че искате да премахнете тази интеграция?",
"integration.devWebhookProxyUrl": "HTTPS тунел URL",
"integration.devWebhookProxyUrlHint": "Telegram изисква HTTPS за уебхукове. Поставете вашия тунел URL (например от cloudflared или ngrok), за да пренасочите заявките за уебхукове към вашия локален dev сървър.",
"integration.disabled": "Деактивирано",
"integration.discord.description": "Свържете този асистент с Discord сървър за чат в канали и директни съобщения.",
"integration.documentation": "Документация",
@@ -26,6 +28,9 @@
"integration.saveFailed": "Неуспешно запазване на конфигурацията",
"integration.saveFirstWarning": "Моля, първо запазете конфигурацията",
"integration.saved": "Конфигурацията е успешно запазена",
"integration.secretToken": "Тайна за уебхукове",
"integration.secretTokenHint": "По избор. Използва се за проверка на заявките за уебхукове от Telegram.",
"integration.secretTokenPlaceholder": "По избор тайна за проверка на уебхукове",
"integration.testConnection": "Тестване на връзката",
"integration.testFailed": "Тестът на връзката неуспешен",
"integration.testSuccess": "Тестът на връзката успешен",
+3
View File
@@ -5,6 +5,7 @@
"alert.cloud.desc": "Всички регистрирани потребители получават {{credit}} безплатни изчислителни кредити на месец — без нужда от настройка. Включва глобална синхронизация в облака и разширено търсене в мрежата.",
"alert.cloud.descOnMobile": "Всички регистрирани потребители получават {{credit}} безплатни изчислителни кредити на месец — без нужда от настройка.",
"alert.cloud.title": "{{name}} бета версия е активна",
"alreadyUpToDate": "Вече е актуализирано",
"appLoading.appIdle": "Готово за стартиране",
"appLoading.appInitializing": "Приложението се стартира...",
"appLoading.failed": "Възникна грешка при стартиране. Вижте подробности за отстраняване на проблема или опитайте отново по-късно.",
@@ -211,6 +212,7 @@
"delete": "Изтрий",
"document": "Ръководство за потребителя",
"download": "Изтегли",
"downloadingUpdate": "Изтегляне {{percent}}%",
"duplicate": "Дублирай",
"edit": "Редактирай",
"errors.invalidFileFormat": "Невалиден файлов формат",
@@ -360,6 +362,7 @@
"releaseNotes": "Детайли за версията",
"rename": "Преименувай",
"reset": "Нулирай",
"restartToUpdate": "Рестартирайте, за да актуализирате",
"retry": "Опитай отново",
"run": "Стартирай",
"save": "Запази",
+4
View File
@@ -93,6 +93,10 @@
"sync.mode.useSelfHosted": "Използвате ли собствена инсталация?",
"sync.selfHosted.description": "Общностна версия, която можете да разположите самостоятелно",
"sync.selfHosted.title": "Самостоятелно хоствана инстанция",
"tab.closeCurrentTab": "Затвори раздела",
"tab.closeLeftTabs": "Затвори разделите отляво",
"tab.closeOtherTabs": "Затвори другите раздели",
"tab.closeRightTabs": "Затвори разделите отдясно",
"updater.checkingUpdate": "Проверка за актуализации",
"updater.checkingUpdateDesc": "Извличане на информация за версията...",
"updater.downloadNewVersion": "Изтегли нова версия",
+1
View File
@@ -100,6 +100,7 @@
"pageEditor.saving": "Запазване...",
"pageEditor.titlePlaceholder": "Без заглавие",
"pageEditor.wordCount": "{{wordCount}} думи",
"pageList.actions.openInNewTab": "Отвори в нов раздел",
"pageList.copyContent": "Копирай целия текст",
"pageList.duplicate": "Дублирай",
"pageList.empty": "Все още няма страници. Натиснете бутона по-горе, за да създадете първата.",
+1
View File
@@ -681,6 +681,7 @@
"gpt-5.2-chat-latest.description": "GPT-5.2 Chat е вариантът на ChatGPT (chat-latest) с най-новите подобрения в разговорите.",
"gpt-5.2-pro.description": "GPT-5.2 pro: по-интелигентен и прецизен вариант на GPT-5.2 (само чрез Responses API), подходящ за трудни задачи и дълги многоетапни разсъждения.",
"gpt-5.2.description": "GPT-5.2 е водещ модел за програмиране и агентски работни потоци с по-силно разсъждение и производителност при дълъг контекст.",
"gpt-5.4.description": "GPT-5.4 е най-новият модел на OpenAI за сложна професионална работа и директен заместител на GPT-5.2 и GPT-5.3 Codex.",
"gpt-5.description": "Най-добрият модел за междудисциплинарно програмиране и агентски задачи. GPT-5 прави скок в точността, скоростта, разсъждението, осъзнаването на контекста, структурираното мислене и решаването на проблеми.",
"gpt-audio.description": "GPT Audio е универсален чат модел за вход/изход на аудио, поддържан в Chat Completions API.",
"gpt-image-1-mini.description": "По-евтин вариант на GPT Image 1 с роден вход от текст и изображения и изход на изображения.",
+10
View File
@@ -207,6 +207,7 @@
"header.sessionDesc": "Профил на агента и предпочитания за сесията",
"header.sessionWithName": "Настройки на сесията · {{name}}",
"header.title": "Настройки",
"hotkey.clearBinding": "Изчистване на свързването",
"hotkey.conflicts": "Конфликт с вече съществуващи клавишни комбинации",
"hotkey.errors.CONFLICT": "Конфликт на клавишна комбинация: Тази комбинация вече е зададена за друга функция",
"hotkey.errors.INVALID_FORMAT": "Невалиден формат: Моля, използвайте правилния формат (напр. CommandOrControl+E)",
@@ -694,6 +695,15 @@
"tab.agent": "Услуга за агент",
"tab.all": "Всички",
"tab.apikey": "Управление на API ключове",
"tab.beta": "Бета",
"tab.beta.updateChannel.canary": "Канарче",
"tab.beta.updateChannel.canaryDesc": "Задейства се при всяко сливане на PR, множество компилации на ден. Най-нестабилната версия.",
"tab.beta.updateChannel.desc": "По подразбиране получавайте известия за стабилни актуализации. Каналите Nightly и Canary получават предварителни версии, които може да са нестабилни за производствена работа.",
"tab.beta.updateChannel.nightly": "Нощна",
"tab.beta.updateChannel.nightlyDesc": "Автоматизирани ежедневни компилации с най-новите промени.",
"tab.beta.updateChannel.stable": "Стабилна",
"tab.beta.updateChannel.stableDesc": "Готови за производство издания.",
"tab.beta.updateChannel.title": "Канал за актуализации",
"tab.chatAppearance": "Външен вид на чата",
"tab.common": "Външен вид",
"tab.experiment": "Експеримент",
+1
View File
@@ -7,6 +7,7 @@
"actions.duplicate": "Дублирай",
"actions.export": "Експортирай теми",
"actions.import": "Импортирай разговор",
"actions.openInNewTab": "Отвори в нов раздел",
"actions.openInNewWindow": "Отвори в нов прозорец",
"actions.removeAll": "Изтрий всички теми",
"actions.removeUnstarred": "Изтрий немаркираните теми",
+5
View File
@@ -10,6 +10,8 @@
"integration.copied": "In die Zwischenablage kopiert",
"integration.copy": "Kopieren",
"integration.deleteConfirm": "Sind Sie sicher, dass Sie diese Integration entfernen möchten?",
"integration.devWebhookProxyUrl": "HTTPS-Tunnel-URL",
"integration.devWebhookProxyUrlHint": "Telegram benötigt HTTPS für Webhooks. Fügen Sie Ihre Tunnel-URL ein (z. B. von cloudflared oder ngrok), um Webhook-Anfragen an Ihren lokalen Entwicklungsserver weiterzuleiten.",
"integration.disabled": "Deaktiviert",
"integration.discord.description": "Verbinden Sie diesen Assistenten mit einem Discord-Server für Kanal-Chat und Direktnachrichten.",
"integration.documentation": "Dokumentation",
@@ -26,6 +28,9 @@
"integration.saveFailed": "Konfiguration konnte nicht gespeichert werden",
"integration.saveFirstWarning": "Bitte speichern Sie zuerst die Konfiguration",
"integration.saved": "Konfiguration erfolgreich gespeichert",
"integration.secretToken": "Webhook-Geheimtoken",
"integration.secretTokenHint": "Optional. Wird verwendet, um Webhook-Anfragen von Telegram zu verifizieren.",
"integration.secretTokenPlaceholder": "Optionales Geheimnis zur Webhook-Verifizierung",
"integration.testConnection": "Verbindung testen",
"integration.testFailed": "Verbindungstest fehlgeschlagen",
"integration.testSuccess": "Verbindungstest erfolgreich",
+3
View File
@@ -5,6 +5,7 @@
"alert.cloud.desc": "Alle registrierten Nutzer erhalten monatlich {{credit}} kostenlose Rechen-Credits keine Einrichtung erforderlich. Inklusive globaler Cloud-Synchronisierung und erweiterter Websuche.",
"alert.cloud.descOnMobile": "Alle registrierten Nutzer erhalten monatlich {{credit}} kostenlose Rechen-Credits keine Einrichtung erforderlich.",
"alert.cloud.title": "{{name}} Beta ist live",
"alreadyUpToDate": "Bereits auf dem neuesten Stand",
"appLoading.appIdle": "Bereit zum Start",
"appLoading.appInitializing": "Anwendung wird gestartet...",
"appLoading.failed": "Beim Start ist ein Fehler aufgetreten. Details anzeigen, um das Problem zu beheben, oder später erneut versuchen.",
@@ -211,6 +212,7 @@
"delete": "Löschen",
"document": "Benutzerhandbuch",
"download": "Herunterladen",
"downloadingUpdate": "Lade {{percent}}% herunter",
"duplicate": "Duplizieren",
"edit": "Bearbeiten",
"errors.invalidFileFormat": "Ungültiges Dateiformat",
@@ -360,6 +362,7 @@
"releaseNotes": "Versionshinweise",
"rename": "Umbenennen",
"reset": "Zurücksetzen",
"restartToUpdate": "Neustart zum Aktualisieren",
"retry": "Erneut versuchen",
"run": "Ausführen",
"save": "Speichern",
+4
View File
@@ -93,6 +93,10 @@
"sync.mode.useSelfHosted": "Eigene Instanz verwenden?",
"sync.selfHosted.description": "Community-Version, die Sie selbst bereitstellen können",
"sync.selfHosted.title": "Selbst gehostete Instanz",
"tab.closeCurrentTab": "Tab schließen",
"tab.closeLeftTabs": "Tabs links schließen",
"tab.closeOtherTabs": "Andere Tabs schließen",
"tab.closeRightTabs": "Tabs rechts schließen",
"updater.checkingUpdate": "Suche nach Updates",
"updater.checkingUpdateDesc": "Versionsinformationen werden abgerufen...",
"updater.downloadNewVersion": "Neue Version herunterladen",
+1
View File
@@ -100,6 +100,7 @@
"pageEditor.saving": "Speichern...",
"pageEditor.titlePlaceholder": "Unbenannt",
"pageEditor.wordCount": "{{wordCount}} Wörter",
"pageList.actions.openInNewTab": "In neuem Tab öffnen",
"pageList.copyContent": "Gesamten Text kopieren",
"pageList.duplicate": "Duplizieren",
"pageList.empty": "Noch keine Seiten vorhanden. Klicken Sie oben auf die Schaltfläche, um Ihre erste Seite zu erstellen.",
+1
View File
@@ -681,6 +681,7 @@
"gpt-5.2-chat-latest.description": "GPT-5.2 Chat ist die ChatGPT-Variante (chat-latest) mit den neuesten Verbesserungen für Konversationen.",
"gpt-5.2-pro.description": "GPT-5.2 Pro: eine intelligentere, präzisere GPT-5.2-Variante (nur über die Responses API), geeignet für schwierige Probleme und längere, mehrstufige Denkprozesse.",
"gpt-5.2.description": "GPT-5.2 ist ein Spitzenmodell für Programmier- und agentenbasierte Workflows mit verbessertem Denkvermögen und Langkontextleistung.",
"gpt-5.4.description": "GPT-5.4 ist das neueste Modell von OpenAI für komplexe professionelle Arbeit und ein direkter Ersatz für GPT-5.2 und GPT-5.3 Codex.",
"gpt-5.description": "Das beste Modell für domänenübergreifendes Programmieren und Agentenaufgaben. GPT-5 bietet große Fortschritte in Genauigkeit, Geschwindigkeit, logischem Denken, Kontextverständnis, strukturiertem Denken und Problemlösung.",
"gpt-audio.description": "GPT Audio ist ein allgemeines Chat-Modell mit Audioein- und -ausgabe, unterstützt durch die Chat Completions API.",
"gpt-image-1-mini.description": "Eine kostengünstigere GPT Image 1-Variante mit nativer Text- und Bildeingabe sowie Bildausgabe.",
+10
View File
@@ -207,6 +207,7 @@
"header.sessionDesc": "Agentenprofil und Sitzungseinstellungen",
"header.sessionWithName": "Sitzungseinstellungen · {{name}}",
"header.title": "Einstellungen",
"hotkey.clearBinding": "Bindung löschen",
"hotkey.conflicts": "Konflikt mit bestehenden Tastenkombinationen",
"hotkey.errors.CONFLICT": "Tastenkombination-Konflikt: Diese Kombination ist bereits einer anderen Funktion zugewiesen",
"hotkey.errors.INVALID_FORMAT": "Ungültiges Format: Bitte verwende das korrekte Format (z.B. CommandOrControl+E)",
@@ -694,6 +695,15 @@
"tab.agent": "Agentendienst",
"tab.all": "Alle",
"tab.apikey": "API-Schlüsselverwaltung",
"tab.beta": "Beta",
"tab.beta.updateChannel.canary": "Canary",
"tab.beta.updateChannel.canaryDesc": "Ausgelöst bei jedem PR-Merge, mehrere Builds pro Tag. Am instabilsten.",
"tab.beta.updateChannel.desc": "Standardmäßig Benachrichtigungen für stabile Updates erhalten. Nightly- und Canary-Kanäle erhalten Vorabversionen, die möglicherweise instabil für Produktionsarbeiten sind.",
"tab.beta.updateChannel.nightly": "Nightly",
"tab.beta.updateChannel.nightlyDesc": "Automatisierte tägliche Builds mit den neuesten Änderungen.",
"tab.beta.updateChannel.stable": "Stabil",
"tab.beta.updateChannel.stableDesc": "Produktionsreife Veröffentlichungen.",
"tab.beta.updateChannel.title": "Update-Kanal",
"tab.chatAppearance": "Chat-Darstellung",
"tab.common": "Darstellung",
"tab.experiment": "Experiment",
+1
View File
@@ -7,6 +7,7 @@
"actions.duplicate": "Duplizieren",
"actions.export": "Themen exportieren",
"actions.import": "Konversation importieren",
"actions.openInNewTab": "In neuem Tab öffnen",
"actions.openInNewWindow": "In neuem Fenster öffnen",
"actions.removeAll": "Alle Themen löschen",
"actions.removeUnstarred": "Nicht markierte Themen löschen",
+48 -37
View File
@@ -1,39 +1,50 @@
{
"integration.applicationId": "Application ID / Bot Username",
"integration.applicationIdPlaceholder": "e.g. 1234567890",
"integration.botToken": "Bot Token / API Key",
"integration.botTokenEncryptedHint": "Token will be encrypted and stored securely.",
"integration.botTokenHowToGet": "How to get?",
"integration.botTokenPlaceholderExisting": "Token is hidden for security",
"integration.botTokenPlaceholderNew": "Paste your bot token here",
"integration.connectionConfig": "Connection Configuration",
"integration.copied": "Copied to clipboard",
"integration.copy": "Copy",
"integration.deleteConfirm": "Are you sure you want to remove this integration?",
"integration.devWebhookProxyUrl": "HTTPS Tunnel URL",
"integration.devWebhookProxyUrlHint": "Telegram requires HTTPS for webhooks. Paste your tunnel URL (e.g. from cloudflared or ngrok) to forward webhook requests to your local dev server.",
"integration.disabled": "Disabled",
"integration.discord.description": "Connect this assistant to Discord server for channel chat and direct messages.",
"integration.documentation": "Documentation",
"integration.enabled": "Enabled",
"integration.endpointUrl": "Interaction Endpoint URL",
"integration.endpointUrlHint": "Please copy this URL and paste it into the <bold>\"Interactions Endpoint URL\"</bold> field in the {{name}} Developer Portal.",
"integration.platforms": "Platforms",
"integration.publicKey": "Public Key",
"integration.publicKeyPlaceholder": "Required for interaction verification",
"integration.removeFailed": "Failed to remove integration",
"integration.removeIntegration": "Remove Integration",
"integration.removed": "Integration removed",
"integration.save": "Save Configuration",
"integration.saveFailed": "Failed to save configuration",
"integration.saveFirstWarning": "Please save configuration first",
"integration.saved": "Configuration saved successfully",
"integration.secretToken": "Webhook Secret Token",
"integration.secretTokenHint": "Optional. Used to verify webhook requests from Telegram.",
"integration.secretTokenPlaceholder": "Optional secret for webhook verification",
"integration.testConnection": "Test Connection",
"integration.testFailed": "Connection test failed",
"integration.testSuccess": "Connection test passed",
"integration.updateFailed": "Failed to update status",
"integration.validationError": "Please fill in Application ID and Token"
"channel.appSecret": "App Secret",
"channel.appSecretPlaceholder": "Paste your app secret here",
"channel.applicationId": "Application ID / Bot Username",
"channel.applicationIdPlaceholder": "e.g. 1234567890",
"channel.botToken": "Bot Token / API Key",
"channel.botTokenEncryptedHint": "Token will be encrypted and stored securely.",
"channel.botTokenHowToGet": "How to get?",
"channel.botTokenPlaceholderExisting": "Token is hidden for security",
"channel.botTokenPlaceholderNew": "Paste your bot token here",
"channel.connectionConfig": "Connection Configuration",
"channel.copied": "Copied to clipboard",
"channel.copy": "Copy",
"channel.deleteConfirm": "Are you sure you want to remove this channel?",
"channel.devWebhookProxyUrl": "HTTPS Tunnel URL",
"channel.devWebhookProxyUrlHint": "Telegram requires HTTPS for webhooks. Paste your tunnel URL (e.g. from cloudflared or ngrok) to forward webhook requests to your local dev server.",
"channel.disabled": "Disabled",
"channel.discord.description": "Connect this assistant to Discord server for channel chat and direct messages.",
"channel.documentation": "Documentation",
"channel.enabled": "Enabled",
"channel.encryptKey": "Encrypt Key",
"channel.encryptKeyHint": "Optional. Used to decrypt encrypted event payloads.",
"channel.encryptKeyPlaceholder": "Optional encryption key",
"channel.endpointUrl": "Webhook URL",
"channel.endpointUrlHint": "Please copy this URL and paste it into the <bold>{{fieldName}}</bold> field in the {{name}} Developer Portal.",
"channel.feishu.description": "Connect this assistant to Feishu for private and group chats.",
"channel.lark.description": "Connect this assistant to Lark for private and group chats.",
"channel.platforms": "Platforms",
"channel.publicKey": "Public Key",
"channel.publicKeyPlaceholder": "Required for interaction verification",
"channel.removeChannel": "Remove Channel",
"channel.removeFailed": "Failed to remove channel",
"channel.removed": "Channel removed",
"channel.save": "Save Configuration",
"channel.saveFailed": "Failed to save configuration",
"channel.saveFirstWarning": "Please save configuration first",
"channel.saved": "Configuration saved successfully",
"channel.secretToken": "Webhook Secret Token",
"channel.secretTokenHint": "Optional. Used to verify webhook requests from Telegram.",
"channel.secretTokenPlaceholder": "Optional secret for webhook verification",
"channel.telegram.description": "Connect this assistant to Telegram for private and group chats.",
"channel.testConnection": "Test Connection",
"channel.testFailed": "Connection test failed",
"channel.testSuccess": "Connection test passed",
"channel.updateFailed": "Failed to update status",
"channel.validationError": "Please fill in Application ID and Token",
"channel.verificationToken": "Verification Token",
"channel.verificationTokenHint": "Optional. Used to verify webhook event source.",
"channel.verificationTokenPlaceholder": "Paste your verification token here"
}
+1 -1
View File
@@ -349,7 +349,7 @@
"supervisor.todoList.allComplete": "All tasks completed",
"supervisor.todoList.title": "Tasks Completed",
"tab.groupProfile": "Group Profile",
"tab.integration": "Integration",
"tab.integration": "Channels",
"tab.profile": "Agent Profile",
"tab.search": "Search",
"task.activity.calling": "Calling Skill...",
+1
View File
@@ -681,6 +681,7 @@
"gpt-5.2-chat-latest.description": "GPT-5.2 Chat is the ChatGPT variant (chat-latest) for the latest conversation improvements.",
"gpt-5.2-pro.description": "GPT-5.2 pro: a smarter, more precise GPT-5.2 variant (Responses API only), suited for hard problems and longer multi-turn reasoning.",
"gpt-5.2.description": "GPT-5.2 is a flagship model for coding and agentic workflows with stronger reasoning and long-context performance.",
"gpt-5.4.description": "GPT-5.4 is OpenAI's latest model for complex professional work and a drop-in replacement for GPT-5.2 and GPT-5.3 Codex.",
"gpt-5.description": "The best model for cross-domain coding and agent tasks. GPT-5 leaps in accuracy, speed, reasoning, context awareness, structured thinking, and problem solving.",
"gpt-audio.description": "GPT Audio is a general chat model for audio input/output, supported in the Chat Completions API.",
"gpt-image-1-mini.description": "A lower-cost GPT Image 1 variant with native text and image input and image output.",
+2
View File
@@ -207,6 +207,7 @@
"header.sessionDesc": "Agent Profile and session preferences",
"header.sessionWithName": "Session Settings · {{name}}",
"header.title": "Settings",
"hotkey.clearBinding": "Clear binding",
"hotkey.conflicts": "Conflicts with existing hotkeys",
"hotkey.errors.CONFLICT": "Hotkey conflict: This hotkey is already assigned to another function",
"hotkey.errors.INVALID_FORMAT": "Invalid hotkey format: Please use the correct format (e.g., CommandOrControl+E)",
@@ -691,6 +692,7 @@
"tab.addCustomMcp": "Add Custom MCP Skill",
"tab.addCustomMcp.desc": "Manually configure a custom MCP server",
"tab.addCustomSkill": "Add",
"tab.advanced": "Advanced",
"tab.agent": "Agent Service",
"tab.all": "All",
"tab.apikey": "API Key Management",
+2
View File
@@ -6,11 +6,13 @@
"actions.confirmRemoveUnstarred": "You are about to delete unstarred topics. This action cannot be undone.",
"actions.duplicate": "Duplicate",
"actions.export": "Export Topics",
"actions.favorite": "Favorite",
"actions.import": "Import Conversation",
"actions.openInNewTab": "Open in New Tab",
"actions.openInNewWindow": "Open in a new window",
"actions.removeAll": "Delete All Topics",
"actions.removeUnstarred": "Delete Unstarred Topics",
"actions.unfavorite": "Unfavorite",
"defaultTitle": "Default Topic",
"displayItems": "Display Items",
"duplicateLoading": "Copying Topic...",
+5
View File
@@ -10,6 +10,8 @@
"integration.copied": "Copiado al portapapeles",
"integration.copy": "Copiar",
"integration.deleteConfirm": "¿Estás seguro de que deseas eliminar esta integración?",
"integration.devWebhookProxyUrl": "URL del túnel HTTPS",
"integration.devWebhookProxyUrlHint": "Telegram requiere HTTPS para los webhooks. Ingresa la URL de tu túnel (por ejemplo, de cloudflared o ngrok) para redirigir las solicitudes de webhook a tu servidor de desarrollo local.",
"integration.disabled": "Deshabilitado",
"integration.discord.description": "Conecta este asistente al servidor de Discord para chat en canales y mensajes directos.",
"integration.documentation": "Documentación",
@@ -26,6 +28,9 @@
"integration.saveFailed": "No se pudo guardar la configuración",
"integration.saveFirstWarning": "Por favor, guarda la configuración primero",
"integration.saved": "Configuración guardada exitosamente",
"integration.secretToken": "Token secreto del webhook",
"integration.secretTokenHint": "Opcional. Se utiliza para verificar las solicitudes de webhook de Telegram.",
"integration.secretTokenPlaceholder": "Secreto opcional para la verificación del webhook",
"integration.testConnection": "Probar Conexión",
"integration.testFailed": "La prueba de conexión falló",
"integration.testSuccess": "La prueba de conexión fue exitosa",
+3
View File
@@ -5,6 +5,7 @@
"alert.cloud.desc": "Todos los usuarios registrados reciben {{credit}} créditos de computación gratuitos al mes, sin necesidad de configuración. Incluye sincronización en la nube global y búsqueda web avanzada.",
"alert.cloud.descOnMobile": "Todos los usuarios registrados reciben {{credit}} créditos de computación gratuitos al mes, sin necesidad de configuración.",
"alert.cloud.title": "La beta de {{name}} está activa",
"alreadyUpToDate": "Ya está actualizado",
"appLoading.appIdle": "Listo para comenzar",
"appLoading.appInitializing": "Iniciando la aplicación...",
"appLoading.failed": "Algo salió mal durante el inicio. Consulta los detalles para solucionar el problema o inténtalo más tarde.",
@@ -211,6 +212,7 @@
"delete": "Eliminar",
"document": "Manual de usuario",
"download": "Descargar",
"downloadingUpdate": "Descargando {{percent}}%",
"duplicate": "Duplicar",
"edit": "Editar",
"errors.invalidFileFormat": "Formato de archivo no válido",
@@ -360,6 +362,7 @@
"releaseNotes": "Detalles de la versión",
"rename": "Renombrar",
"reset": "Restablecer",
"restartToUpdate": "Reiniciar para actualizar",
"retry": "Reintentar",
"run": "Ejecutar",
"save": "Guardar",
+4
View File
@@ -93,6 +93,10 @@
"sync.mode.useSelfHosted": "¿Usar una instancia autohospedada?",
"sync.selfHosted.description": "Versión comunitaria que puedes desplegar tú mismo",
"sync.selfHosted.title": "Instancia Autohospedada",
"tab.closeCurrentTab": "Cerrar pestaña",
"tab.closeLeftTabs": "Cerrar pestañas a la izquierda",
"tab.closeOtherTabs": "Cerrar otras pestañas",
"tab.closeRightTabs": "Cerrar pestañas a la derecha",
"updater.checkingUpdate": "Buscando actualizaciones",
"updater.checkingUpdateDesc": "Obteniendo información de la versión...",
"updater.downloadNewVersion": "Descargar nueva versión",
+1
View File
@@ -100,6 +100,7 @@
"pageEditor.saving": "Guardando...",
"pageEditor.titlePlaceholder": "Sin título",
"pageEditor.wordCount": "{{wordCount}} palabras",
"pageList.actions.openInNewTab": "Abrir en una nueva pestaña",
"pageList.copyContent": "Copiar texto completo",
"pageList.duplicate": "Duplicar",
"pageList.empty": "Aún no hay páginas. Haz clic en el botón de arriba para crear la primera.",
+1
View File
@@ -681,6 +681,7 @@
"gpt-5.2-chat-latest.description": "GPT-5.2 Chat es la variante de ChatGPT (chat-latest) con las últimas mejoras en conversación.",
"gpt-5.2-pro.description": "GPT-5.2 Pro: una variante más inteligente y precisa de GPT-5.2 (solo API de respuestas), adecuada para problemas difíciles y razonamiento prolongado en múltiples turnos.",
"gpt-5.2.description": "GPT-5.2 es un modelo insignia para flujos de trabajo de codificación y agentes con razonamiento más sólido y rendimiento de contexto largo.",
"gpt-5.4.description": "GPT-5.4 es el modelo más reciente de OpenAI para trabajos profesionales complejos y un reemplazo directo de GPT-5.2 y GPT-5.3 Codex.",
"gpt-5.description": "El mejor modelo para tareas de codificación y agentes entre dominios. GPT-5 representa un salto en precisión, velocidad, razonamiento, conciencia de contexto, pensamiento estructurado y resolución de problemas.",
"gpt-audio.description": "GPT Audio es un modelo general de chat con entrada/salida de audio, compatible con la API de Chat Completions.",
"gpt-image-1-mini.description": "Una variante de menor costo de GPT Image 1 con entrada nativa de texto e imagen y salida de imagen.",
+10
View File
@@ -207,6 +207,7 @@
"header.sessionDesc": "Perfil del Agente y preferencias de sesión",
"header.sessionWithName": "Configuración de Sesión · {{name}}",
"header.title": "Configuración",
"hotkey.clearBinding": "Borrar asignación",
"hotkey.conflicts": "Conflictos con atajos existentes",
"hotkey.errors.CONFLICT": "Conflicto de atajo: Este atajo ya está asignado a otra función",
"hotkey.errors.INVALID_FORMAT": "Formato de atajo inválido: Usa el formato correcto (ej. CommandOrControl+E)",
@@ -694,6 +695,15 @@
"tab.agent": "Servicio de Agente",
"tab.all": "Todos",
"tab.apikey": "Gestión de Claves API",
"tab.beta": "Beta",
"tab.beta.updateChannel.canary": "Canary",
"tab.beta.updateChannel.canaryDesc": "Activado con cada fusión de PR, múltiples compilaciones por día. El más inestable.",
"tab.beta.updateChannel.desc": "Por defecto, recibe notificaciones para actualizaciones estables. Los canales Nightly y Canary reciben compilaciones preliminares que pueden ser inestables para el trabajo de producción.",
"tab.beta.updateChannel.nightly": "Nightly",
"tab.beta.updateChannel.nightlyDesc": "Compilaciones automáticas diarias con los últimos cambios.",
"tab.beta.updateChannel.stable": "Estable",
"tab.beta.updateChannel.stableDesc": "Lanzamientos listos para producción.",
"tab.beta.updateChannel.title": "Canal de Actualización",
"tab.chatAppearance": "Apariencia del Chat",
"tab.common": "Apariencia",
"tab.experiment": "Experimentos",
+1
View File
@@ -7,6 +7,7 @@
"actions.duplicate": "Duplicar",
"actions.export": "Exportar temas",
"actions.import": "Importar conversación",
"actions.openInNewTab": "Abrir en una nueva pestaña",
"actions.openInNewWindow": "Abrir en una nueva ventana",
"actions.removeAll": "Eliminar todos los temas",
"actions.removeUnstarred": "Eliminar temas no favoritos",
+5
View File
@@ -10,6 +10,8 @@
"integration.copied": "به کلیپ‌بورد کپی شد",
"integration.copy": "کپی",
"integration.deleteConfirm": "آیا مطمئن هستید که می‌خواهید این یکپارچه‌سازی را حذف کنید؟",
"integration.devWebhookProxyUrl": "آدرس تونل HTTPS",
"integration.devWebhookProxyUrlHint": "تلگرام برای وب‌هوک‌ها نیاز به HTTPS دارد. آدرس تونل خود را (مثلاً از cloudflared یا ngrok) وارد کنید تا درخواست‌های وب‌هوک به سرور توسعه محلی شما ارسال شود.",
"integration.disabled": "غیرفعال",
"integration.discord.description": "این دستیار را به سرور Discord برای چت کانال و پیام‌های مستقیم متصل کنید.",
"integration.documentation": "مستندات",
@@ -26,6 +28,9 @@
"integration.saveFailed": "ذخیره پیکربندی ناموفق بود",
"integration.saveFirstWarning": "لطفاً ابتدا پیکربندی را ذخیره کنید",
"integration.saved": "پیکربندی با موفقیت ذخیره شد",
"integration.secretToken": "توکن مخفی وب‌هوک",
"integration.secretTokenHint": "اختیاری. برای تأیید درخواست‌های وب‌هوک از تلگرام استفاده می‌شود.",
"integration.secretTokenPlaceholder": "توکن اختیاری برای تأیید وب‌هوک",
"integration.testConnection": "تست اتصال",
"integration.testFailed": "تست اتصال ناموفق بود",
"integration.testSuccess": "تست اتصال موفقیت‌آمیز بود",
+3
View File
@@ -5,6 +5,7 @@
"alert.cloud.desc": "تمام کاربران ثبت‌نام‌شده هر ماه {{credit}} اعتبار رایگان محاسباتی دریافت می‌کنند — بدون نیاز به تنظیمات. شامل همگام‌سازی ابری جهانی و جستجوی پیشرفته وب.",
"alert.cloud.descOnMobile": "تمام کاربران ثبت‌نام‌شده هر ماه {{credit}} اعتبار رایگان محاسباتی دریافت می‌کنند — بدون نیاز به تنظیمات.",
"alert.cloud.title": "نسخه آزمایشی {{name}} فعال شد",
"alreadyUpToDate": "قبلاً به‌روز شده است",
"appLoading.appIdle": "آماده برای شروع",
"appLoading.appInitializing": "در حال راه‌اندازی برنامه...",
"appLoading.failed": "در هنگام راه‌اندازی مشکلی پیش آمد. برای رفع مشکل جزئیات را بررسی کنید یا بعداً دوباره تلاش کنید.",
@@ -211,6 +212,7 @@
"delete": "حذف",
"document": "راهنمای کاربر",
"download": "دانلود",
"downloadingUpdate": "در حال دانلود {{percent}}%",
"duplicate": "تکراری",
"edit": "ویرایش",
"errors.invalidFileFormat": "فرمت فایل نامعتبر است",
@@ -360,6 +362,7 @@
"releaseNotes": "جزئیات نسخه",
"rename": "تغییر نام",
"reset": "بازنشانی",
"restartToUpdate": "برای به‌روزرسانی مجدد راه‌اندازی کنید",
"retry": "تلاش مجدد",
"run": "اجرا",
"save": "ذخیره",
+4
View File
@@ -93,6 +93,10 @@
"sync.mode.useSelfHosted": "از نسخه خودمیزبان استفاده می‌کنید؟",
"sync.selfHosted.description": "نسخه جامعه که می‌توانید خودتان آن را راه‌اندازی کنید",
"sync.selfHosted.title": "نسخه خودمیزبان",
"tab.closeCurrentTab": "بستن زبانه",
"tab.closeLeftTabs": "بستن زبانه‌های سمت چپ",
"tab.closeOtherTabs": "بستن سایر زبانه‌ها",
"tab.closeRightTabs": "بستن زبانه‌های سمت راست",
"updater.checkingUpdate": "در حال بررسی به‌روزرسانی",
"updater.checkingUpdateDesc": "در حال دریافت اطلاعات نسخه...",
"updater.downloadNewVersion": "دانلود نسخه جدید",
+1
View File
@@ -100,6 +100,7 @@
"pageEditor.saving": "در حال ذخیره...",
"pageEditor.titlePlaceholder": "بدون عنوان",
"pageEditor.wordCount": "{{wordCount}} کلمه",
"pageList.actions.openInNewTab": "باز کردن در زبانه جدید",
"pageList.copyContent": "کپی متن کامل",
"pageList.duplicate": "تکثیر",
"pageList.empty": "هنوز هیچ صفحه‌ای وجود ندارد. برای ایجاد اولین صفحه روی دکمه بالا کلیک کنید.",
+1
View File
@@ -681,6 +681,7 @@
"gpt-5.2-chat-latest.description": "GPT-5.2 Chat نسخه ChatGPT برای تجربه آخرین بهبودهای مکالمه‌ای است.",
"gpt-5.2-pro.description": "GPT-5.2 Pro: نسخه‌ای هوشمندتر و دقیق‌تر از GPT-5.2 (فقط از طریق API پاسخ‌ها)، مناسب برای مسائل دشوار و استدلال چندمرحله‌ای طولانی.",
"gpt-5.2.description": "GPT-5.2 یک مدل پرچم‌دار برای گردش‌کارهای برنامه‌نویسی و عامل‌محور با استدلال قوی‌تر و عملکرد بهتر در زمینه‌های طولانی است.",
"gpt-5.4.description": "GPT-5.4 جدیدترین مدل OpenAI برای کارهای حرفه‌ای پیچیده و جایگزینی مستقیم برای GPT-5.2 و GPT-5.3 Codex است.",
"gpt-5.description": "بهترین مدل برای برنامه‌نویسی میان‌رشته‌ای و وظایف عامل. GPT-5 جهشی در دقت، سرعت، استدلال، آگاهی زمینه‌ای، تفکر ساختاریافته و حل مسئله دارد.",
"gpt-audio.description": "GPT Audio یک مدل چت عمومی برای ورودی/خروجی صوتی است که در API تکمیل چت پشتیبانی می‌شود.",
"gpt-image-1-mini.description": "نسخه کم‌هزینه‌تر GPT Image 1 با ورودی بومی متن و تصویر و خروجی تصویری.",
+10
View File
@@ -207,6 +207,7 @@
"header.sessionDesc": "پروفایل عامل و ترجیحات جلسه",
"header.sessionWithName": "تنظیمات جلسه · {{name}}",
"header.title": "تنظیمات",
"hotkey.clearBinding": "پاک کردن اتصال",
"hotkey.conflicts": "تداخل با کلیدهای میانبر موجود",
"hotkey.errors.CONFLICT": "تداخل کلید میانبر: این کلید قبلاً به عملکرد دیگری اختصاص داده شده است",
"hotkey.errors.INVALID_FORMAT": "فرمت نامعتبر کلید میانبر: لطفاً از فرمت صحیح استفاده کنید (مثلاً CommandOrControl+E)",
@@ -694,6 +695,15 @@
"tab.agent": "سرویس عامل",
"tab.all": "همه",
"tab.apikey": "مدیریت کلید API",
"tab.beta": "بتا",
"tab.beta.updateChannel.canary": "کناری",
"tab.beta.updateChannel.canaryDesc": "فعال‌شده با هر ادغام PR، چندین ساخت در روز. ناپایدارترین.",
"tab.beta.updateChannel.desc": "به طور پیش‌فرض، اعلان‌ها برای به‌روزرسانی‌های پایدار دریافت کنید. کانال‌های نایتلی و کناری نسخه‌های پیش‌انتشار را دریافت می‌کنند که ممکن است برای کار تولیدی ناپایدار باشند.",
"tab.beta.updateChannel.nightly": "نایتلی",
"tab.beta.updateChannel.nightlyDesc": "ساخت‌های خودکار روزانه با آخرین تغییرات.",
"tab.beta.updateChannel.stable": "پایدار",
"tab.beta.updateChannel.stableDesc": "نسخه‌های آماده تولید.",
"tab.beta.updateChannel.title": "کانال به‌روزرسانی",
"tab.chatAppearance": "ظاهر گفتگو",
"tab.common": "ظاهر",
"tab.experiment": "آزمایش",
+1
View File
@@ -7,6 +7,7 @@
"actions.duplicate": "ایجاد نسخه مشابه",
"actions.export": "خروجی گرفتن از گفت‌وگوها",
"actions.import": "وارد کردن گفت‌وگو",
"actions.openInNewTab": "باز کردن در تب جدید",
"actions.openInNewWindow": "باز کردن در پنجره جدید",
"actions.removeAll": "حذف تمام گفت‌وگوها",
"actions.removeUnstarred": "حذف گفت‌وگوهای بدون ستاره",
+5
View File
@@ -10,6 +10,8 @@
"integration.copied": "Copié dans le presse-papiers",
"integration.copy": "Copier",
"integration.deleteConfirm": "Êtes-vous sûr de vouloir supprimer cette intégration ?",
"integration.devWebhookProxyUrl": "URL du tunnel HTTPS",
"integration.devWebhookProxyUrlHint": "Telegram nécessite HTTPS pour les webhooks. Collez votre URL de tunnel (par exemple, de cloudflared ou ngrok) pour rediriger les requêtes webhook vers votre serveur de développement local.",
"integration.disabled": "Désactivé",
"integration.discord.description": "Connectez cet assistant au serveur Discord pour les discussions de canal et les messages directs.",
"integration.documentation": "Documentation",
@@ -26,6 +28,9 @@
"integration.saveFailed": "Échec de l'enregistrement de la configuration",
"integration.saveFirstWarning": "Veuillez d'abord enregistrer la configuration",
"integration.saved": "Configuration enregistrée avec succès",
"integration.secretToken": "Jeton secret du webhook",
"integration.secretTokenHint": "Optionnel. Utilisé pour vérifier les requêtes webhook provenant de Telegram.",
"integration.secretTokenPlaceholder": "Secret optionnel pour la vérification du webhook",
"integration.testConnection": "Tester la connexion",
"integration.testFailed": "Échec du test de connexion",
"integration.testSuccess": "Test de connexion réussi",
+3
View File
@@ -5,6 +5,7 @@
"alert.cloud.desc": "Tous les utilisateurs enregistrés reçoivent {{credit}} crédits de calcul gratuits par mois — aucune configuration requise. Inclut la synchronisation cloud mondiale et la recherche web avancée.",
"alert.cloud.descOnMobile": "Tous les utilisateurs enregistrés reçoivent {{credit}} crédits de calcul gratuits par mois — aucune configuration requise.",
"alert.cloud.title": "La bêta de {{name}} est en ligne",
"alreadyUpToDate": "Déjà à jour",
"appLoading.appIdle": "Prêt à démarrer",
"appLoading.appInitializing": "L'application démarre...",
"appLoading.failed": "Une erreur s'est produite au démarrage. Consultez les détails pour résoudre le problème ou réessayez plus tard.",
@@ -211,6 +212,7 @@
"delete": "Supprimer",
"document": "Manuel utilisateur",
"download": "Télécharger",
"downloadingUpdate": "Téléchargement {{percent}}%",
"duplicate": "Dupliquer",
"edit": "Modifier",
"errors.invalidFileFormat": "Format de fichier invalide",
@@ -360,6 +362,7 @@
"releaseNotes": "Détails de la version",
"rename": "Renommer",
"reset": "Réinitialiser",
"restartToUpdate": "Redémarrer pour mettre à jour",
"retry": "Réessayer",
"run": "Exécuter",
"save": "Enregistrer",
+4
View File
@@ -93,6 +93,10 @@
"sync.mode.useSelfHosted": "Utiliser une instance auto-hébergée ?",
"sync.selfHosted.description": "Version communautaire que vous pouvez déployer vous-même",
"sync.selfHosted.title": "Instance auto-hébergée",
"tab.closeCurrentTab": "Fermer l'onglet",
"tab.closeLeftTabs": "Fermer les onglets à gauche",
"tab.closeOtherTabs": "Fermer les autres onglets",
"tab.closeRightTabs": "Fermer les onglets à droite",
"updater.checkingUpdate": "Vérification des mises à jour",
"updater.checkingUpdateDesc": "Récupération des informations de version...",
"updater.downloadNewVersion": "Télécharger la nouvelle version",
+1
View File
@@ -100,6 +100,7 @@
"pageEditor.saving": "Enregistrement...",
"pageEditor.titlePlaceholder": "Sans titre",
"pageEditor.wordCount": "{{wordCount}} mots",
"pageList.actions.openInNewTab": "Ouvrir dans un nouvel onglet",
"pageList.copyContent": "Copier le texte complet",
"pageList.duplicate": "Dupliquer",
"pageList.empty": "Aucune page pour le moment. Cliquez sur le bouton ci-dessus pour en créer une.",
+1
View File
@@ -681,6 +681,7 @@
"gpt-5.2-chat-latest.description": "GPT-5.2 Chat est la variante ChatGPT (chat-latest) intégrant les dernières améliorations conversationnelles.",
"gpt-5.2-pro.description": "GPT-5.2 Pro : une variante GPT-5.2 plus intelligente et plus précise (uniquement via lAPI Responses), adaptée aux problèmes complexes et au raisonnement multi-tours prolongé.",
"gpt-5.2.description": "GPT-5.2 est un modèle phare pour les flux de travail de codage et dagents, avec un raisonnement renforcé et des performances sur de longs contextes.",
"gpt-5.4.description": "GPT-5.4 est le dernier modèle d'OpenAI pour les travaux professionnels complexes et un remplacement direct pour GPT-5.2 et GPT-5.3 Codex.",
"gpt-5.description": "Le meilleur modèle pour le codage inter-domaines et les tâches dagents. GPT-5 marque un bond en précision, vitesse, raisonnement, compréhension du contexte, pensée structurée et résolution de problèmes.",
"gpt-audio.description": "GPT Audio est un modèle de chat général prenant en charge les entrées/sorties audio, disponible via lAPI Chat Completions.",
"gpt-image-1-mini.description": "Une variante économique de GPT Image 1 avec entrées texte et image natives et sortie image.",
+10
View File
@@ -207,6 +207,7 @@
"header.sessionDesc": "Profil de l'agent et préférences de session",
"header.sessionWithName": "Paramètres de session · {{name}}",
"header.title": "Paramètres",
"hotkey.clearBinding": "Effacer le raccourci",
"hotkey.conflicts": "Conflits avec des raccourcis existants",
"hotkey.errors.CONFLICT": "Conflit de raccourci : ce raccourci est déjà attribué à une autre fonction",
"hotkey.errors.INVALID_FORMAT": "Format de raccourci invalide : veuillez utiliser le format correct (ex. : CommandOrControl+E)",
@@ -694,6 +695,15 @@
"tab.agent": "Service dagent",
"tab.all": "Tous",
"tab.apikey": "Gestion des clés API",
"tab.beta": "Bêta",
"tab.beta.updateChannel.canary": "Canary",
"tab.beta.updateChannel.canaryDesc": "Déclenché à chaque fusion de PR, plusieurs builds par jour. Le moins stable.",
"tab.beta.updateChannel.desc": "Par défaut, recevez des notifications pour les mises à jour stables. Les canaux Nightly et Canary reçoivent des builds préliminaires qui peuvent être instables pour un usage en production.",
"tab.beta.updateChannel.nightly": "Nightly",
"tab.beta.updateChannel.nightlyDesc": "Builds quotidiens automatisés avec les derniers changements.",
"tab.beta.updateChannel.stable": "Stable",
"tab.beta.updateChannel.stableDesc": "Versions prêtes pour la production.",
"tab.beta.updateChannel.title": "Canal de mise à jour",
"tab.chatAppearance": "Apparence du chat",
"tab.common": "Apparence",
"tab.experiment": "Expérimental",

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