mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e2d55e3433 | |||
| 9f0ae47eab | |||
| c59552757c | |||
| 647065402c | |||
| c67ef5f8d6 | |||
| 9760d0d458 | |||
| 1db7487afd | |||
| 7cc55dd1b7 | |||
| b80fa29bcd | |||
| 94f71160b8 | |||
| ca699bc58b | |||
| 5c6c3c9d10 | |||
| 20c2b2d75a | |||
| 5ad0c77264 | |||
| f887110b8c | |||
| 0f458a4504 | |||
| d3b9e9ddc2 | |||
| 79e25b458b | |||
| c35d606e7c | |||
| fdbfa71635 | |||
| 807cfd49a2 | |||
| dd653f936e | |||
| 2f5be753c2 | |||
| c0097ca036 | |||
| a9f1c8abce | |||
| 66ffbfda8b | |||
| c55a40c2ed | |||
| bd667b0677 | |||
| cb8b4d9f65 | |||
| 71e6fdacbc | |||
| 1935edae10 | |||
| d07587e61f | |||
| b1f565e62a | |||
| 28256b45bc | |||
| 42bc9edd63 | |||
| 4dc9863c31 | |||
| 5d315a1346 | |||
| fe525975c8 | |||
| 07f3928502 | |||
| 517a92b866 | |||
| 3287130eac | |||
| 456e7b8ed0 | |||
| b6587ec5d7 | |||
| e18437b2f9 | |||
| e4c90fc6f3 | |||
| 42ed155944 | |||
| 2dc7b15c31 | |||
| 5391ceda7d | |||
| a2bf627531 | |||
| 0b7c917745 | |||
| 716c27df12 | |||
| 0dd0d11731 | |||
| 400a0205a3 | |||
| 86889b81bd | |||
| d3550afe05 | |||
| 4d240cf7fa | |||
| db45907ab8 | |||
| 76a07d811b | |||
| 616d53e2ec | |||
| 6c1c60ee27 |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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.');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
@@ -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、机器人令牌和公钥是否正确。
|
||||
@@ -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.
|
||||
@@ -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"(而不是 "飞书")。
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
| --------- | ------- | -------- | --------- |
|
||||
| 文本消息 | 是 | 是 | 是 |
|
||||
| 私人消息 | 是 | 是 | 是 |
|
||||
| 群组聊天 | 是 | 是 | 是 |
|
||||
| 表情反应 | 是 | 是 | 部分支持 |
|
||||
| 图片 / 文件附件 | 是 | 是 | 是 |
|
||||
@@ -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.
|
||||
@@ -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
|
||||
- 社区创作者
|
||||
|
||||
@@ -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 world’s largest human–agent 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 world’s largest human–agent co-evolving network.
|
||||
tags:
|
||||
- LobeHub
|
||||
- Getting Started
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
title: 简介
|
||||
description: >-
|
||||
LobeHub 是下一代 Agent harness,旨在让 AI 能力大众化。超越一次性、以任务为驱动的工具,构建能随着您一起成长的长期
|
||||
Agent 队友,加入全球最大的人与 Agent 共生网络。
|
||||
LobeHub 是下一代 Agent harness,旨在让 AI 能力大众化。超越一次性、以任务为驱动的工具,构建能随着您一起成长的长期 Agent
|
||||
队友,加入全球最大的人与 Agent 共生网络。
|
||||
tags:
|
||||
- LobeHub
|
||||
- 入门指南
|
||||
|
||||
@@ -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
|
||||
- 外观
|
||||
|
||||
@@ -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
|
||||
- 命令菜单
|
||||
|
||||
@@ -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
|
||||
- 快捷键
|
||||
|
||||
@@ -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,7 +1,6 @@
|
||||
---
|
||||
title: 数据统计
|
||||
description: >-
|
||||
追踪你的 LobeHub 使用情况——活跃天数、助理、对话、模型使用。可视化你的使用模式,并分享统计结果。
|
||||
description: 追踪你的 LobeHub 使用情况——活跃天数、助理、对话、模型使用。可视化你的使用模式,并分享统计结果。
|
||||
tags:
|
||||
- LobeHub
|
||||
- 数据统计
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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": "نجح اختبار الاتصال",
|
||||
|
||||
@@ -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": "حفظ",
|
||||
|
||||
@@ -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": "تحميل الإصدار الجديد",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"pageEditor.saving": "جارٍ الحفظ...",
|
||||
"pageEditor.titlePlaceholder": "بدون عنوان",
|
||||
"pageEditor.wordCount": "{{wordCount}} كلمة",
|
||||
"pageList.actions.openInNewTab": "افتح في علامة تبويب جديدة",
|
||||
"pageList.copyContent": "نسخ النص الكامل",
|
||||
"pageList.duplicate": "تكرار",
|
||||
"pageList.empty": "لا توجد صفحات بعد. انقر على الزر أعلاه لإنشاء أول صفحة.",
|
||||
|
||||
@@ -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 يدعم إدخال نصوص وصور وإخراج صور.",
|
||||
|
||||
@@ -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": "تجريبي",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"actions.duplicate": "نسخ",
|
||||
"actions.export": "تصدير المواضيع",
|
||||
"actions.import": "استيراد المحادثة",
|
||||
"actions.openInNewTab": "افتح في علامة تبويب جديدة",
|
||||
"actions.openInNewWindow": "فتح في نافذة جديدة",
|
||||
"actions.removeAll": "حذف جميع المواضيع",
|
||||
"actions.removeUnstarred": "حذف المواضيع غير المميزة",
|
||||
|
||||
@@ -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": "Тестът на връзката успешен",
|
||||
|
||||
@@ -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": "Запази",
|
||||
|
||||
@@ -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": "Изтегли нова версия",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"pageEditor.saving": "Запазване...",
|
||||
"pageEditor.titlePlaceholder": "Без заглавие",
|
||||
"pageEditor.wordCount": "{{wordCount}} думи",
|
||||
"pageList.actions.openInNewTab": "Отвори в нов раздел",
|
||||
"pageList.copyContent": "Копирай целия текст",
|
||||
"pageList.duplicate": "Дублирай",
|
||||
"pageList.empty": "Все още няма страници. Натиснете бутона по-горе, за да създадете първата.",
|
||||
|
||||
@@ -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 с роден вход от текст и изображения и изход на изображения.",
|
||||
|
||||
@@ -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": "Експеримент",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"actions.duplicate": "Дублирай",
|
||||
"actions.export": "Експортирай теми",
|
||||
"actions.import": "Импортирай разговор",
|
||||
"actions.openInNewTab": "Отвори в нов раздел",
|
||||
"actions.openInNewWindow": "Отвори в нов прозорец",
|
||||
"actions.removeAll": "Изтрий всички теми",
|
||||
"actions.removeUnstarred": "Изтрий немаркираните теми",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "تست اتصال موفقیتآمیز بود",
|
||||
|
||||
@@ -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": "ذخیره",
|
||||
|
||||
@@ -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": "دانلود نسخه جدید",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"pageEditor.saving": "در حال ذخیره...",
|
||||
"pageEditor.titlePlaceholder": "بدون عنوان",
|
||||
"pageEditor.wordCount": "{{wordCount}} کلمه",
|
||||
"pageList.actions.openInNewTab": "باز کردن در زبانه جدید",
|
||||
"pageList.copyContent": "کپی متن کامل",
|
||||
"pageList.duplicate": "تکثیر",
|
||||
"pageList.empty": "هنوز هیچ صفحهای وجود ندارد. برای ایجاد اولین صفحه روی دکمه بالا کلیک کنید.",
|
||||
|
||||
@@ -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 با ورودی بومی متن و تصویر و خروجی تصویری.",
|
||||
|
||||
@@ -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": "آزمایش",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"actions.duplicate": "ایجاد نسخه مشابه",
|
||||
"actions.export": "خروجی گرفتن از گفتوگوها",
|
||||
"actions.import": "وارد کردن گفتوگو",
|
||||
"actions.openInNewTab": "باز کردن در تب جدید",
|
||||
"actions.openInNewWindow": "باز کردن در پنجره جدید",
|
||||
"actions.removeAll": "حذف تمام گفتوگوها",
|
||||
"actions.removeUnstarred": "حذف گفتوگوهای بدون ستاره",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 l’API 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 d’agents, 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 d’agents. 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 l’API Chat Completions.",
|
||||
"gpt-image-1-mini.description": "Une variante économique de GPT Image 1 avec entrées texte et image natives et sortie image.",
|
||||
|
||||
@@ -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 d’agent",
|
||||
"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
Reference in New Issue
Block a user