mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fa30f8f21 | |||
| 63b3ecac9d | |||
| 5111da7cd9 | |||
| f162caa3b9 | |||
| ea90853f6b | |||
| cd94c5f75b | |||
| c2f33e7986 | |||
| b868eab506 | |||
| dbfc7e7b76 | |||
| 387eca4586 | |||
| 13aed82c1e | |||
| 651730865f | |||
| 394abf3a68 | |||
| dc7a19d8e7 | |||
| bc798d2eaa | |||
| 63a5624d33 | |||
| 69956e4aa2 | |||
| 720391c36b | |||
| c1980728be | |||
| a6c4621b08 | |||
| 27a8f0895a | |||
| 751ab0ea28 | |||
| 2a39954361 | |||
| 2de924386b | |||
| 8f3c392928 | |||
| 03b48072f5 | |||
| a38a02a22f | |||
| eeae5f13d0 | |||
| c65ea09139 | |||
| 5b6b63ba5f | |||
| 6868b1877a | |||
| e39966a14c | |||
| e4708c1ed5 | |||
| da3504ffc6 |
@@ -1,12 +1,22 @@
|
||||
---
|
||||
name: linear
|
||||
description: Linear issue management guide. Use when working with Linear issues, creating issues, updating status, or adding comments. Triggers on Linear issue references (LOBE-xxx), issue tracking, or project management tasks. Requires Linear MCP tools to be available.
|
||||
description: "Linear issue management. MUST USE when: (1) user mentions LOBE-xxx issue IDs (e.g. LOBE-4540), (2) user says 'linear', 'linear issue', 'link linear', (3) creating PRs that reference Linear issues. Provides workflows for retrieving issues, updating status, and adding comments."
|
||||
---
|
||||
|
||||
# Linear Issue Management
|
||||
|
||||
Before using Linear workflows, search for `linear` MCP tools. If not found, treat as not installed.
|
||||
|
||||
## ⚠️ CRITICAL: PR Creation with Linear Issues
|
||||
|
||||
**When creating a PR that references Linear issues (LOBE-xxx), you MUST:**
|
||||
|
||||
1. Create the PR with magic keywords (`Fixes LOBE-xxx`)
|
||||
2. **IMMEDIATELY after PR creation**, add completion comments to ALL referenced Linear issues
|
||||
3. Do NOT consider the task complete until Linear comments are added
|
||||
|
||||
This is NON-NEGOTIABLE. Skipping Linear comments is a workflow violation.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Retrieve issue details** before starting: `mcp__linear-server__get_issue`
|
||||
@@ -18,9 +28,26 @@ Before using Linear workflows, search for `linear` MCP tools. If not found, trea
|
||||
|
||||
When creating issues with `mcp__linear-server__create_issue`, **MUST add the `claude code` label**.
|
||||
|
||||
## Completion Comment (REQUIRED)
|
||||
## Completion Comment Format
|
||||
|
||||
Every completed issue MUST have a comment summarizing work done:
|
||||
|
||||
```markdown
|
||||
## Changes Summary
|
||||
|
||||
- **Feature**: Brief description of what was implemented
|
||||
- **Files Changed**: List key files modified
|
||||
- **PR**: #xxx or PR URL
|
||||
|
||||
### Key Changes
|
||||
|
||||
- Change 1
|
||||
- Change 2
|
||||
- ...
|
||||
```
|
||||
|
||||
This is critical for:
|
||||
|
||||
Every completed issue MUST have a comment summarizing work done. This is critical for:
|
||||
- Team visibility
|
||||
- Code review context
|
||||
- Future reference
|
||||
@@ -28,6 +55,7 @@ Every completed issue MUST have a comment summarizing work done. This is critica
|
||||
## PR Association (REQUIRED)
|
||||
|
||||
When creating PRs for Linear issues, include magic keywords in PR body:
|
||||
|
||||
- `Fixes LOBE-123`
|
||||
- `Closes LOBE-123`
|
||||
- `Resolves LOBE-123`
|
||||
@@ -41,11 +69,11 @@ When working on multiple issues, update EACH issue IMMEDIATELY after completing
|
||||
3. Run related tests
|
||||
4. Create PR if needed
|
||||
5. Update status to **"In Review"** (NOT "Done")
|
||||
6. Add completion comment
|
||||
6. **Add completion comment immediately**
|
||||
7. Move to next issue
|
||||
|
||||
**Note:** Status → "In Review" when PR created. "Done" only after PR merged.
|
||||
|
||||
**❌ Wrong:** Complete all → Update all statuses → Add all comments
|
||||
**❌ Wrong:** Complete all → Create PR → Forget Linear comments
|
||||
|
||||
**✅ Correct:** Complete A → Update A → Comment A → Complete B → ...
|
||||
**✅ Correct:** Complete → Create PR → Add Linear comments → Task done
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../.agents/skills
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
run: npm run workflow:set-desktop-version ${{ needs.version.outputs.version }} ${{ inputs.channel }}
|
||||
|
||||
- name: Build artifact on macOS
|
||||
run: npm run desktop:build
|
||||
run: npm run desktop:package:app
|
||||
env:
|
||||
UPDATE_CHANNEL: ${{ inputs.channel }}
|
||||
APP_URL: http://localhost:3015
|
||||
@@ -193,7 +193,7 @@ jobs:
|
||||
run: npm run workflow:set-desktop-version ${{ needs.version.outputs.version }} ${{ inputs.channel }}
|
||||
|
||||
- name: Build artifact on Windows
|
||||
run: npm run desktop:build
|
||||
run: npm run desktop:package:app
|
||||
env:
|
||||
UPDATE_CHANNEL: ${{ inputs.channel }}
|
||||
APP_URL: http://localhost:3015
|
||||
@@ -246,7 +246,7 @@ jobs:
|
||||
run: npm run workflow:set-desktop-version ${{ needs.version.outputs.version }} ${{ inputs.channel }}
|
||||
|
||||
- name: Build artifact on Linux
|
||||
run: npm run desktop:build
|
||||
run: npm run desktop:package:app
|
||||
env:
|
||||
UPDATE_CHANNEL: ${{ inputs.channel }}
|
||||
APP_URL: http://localhost:3015
|
||||
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
# 注意:fork 的 PR 无法访问 secrets,会构建未签名版本
|
||||
- name: Build artifact on macOS
|
||||
if: runner.os == 'macOS'
|
||||
run: npm run desktop:build
|
||||
run: npm run desktop:package:app
|
||||
env:
|
||||
# 设置更新通道,PR构建为nightly,否则为stable
|
||||
UPDATE_CHANNEL: 'nightly'
|
||||
@@ -144,7 +144,7 @@ jobs:
|
||||
# 注意:fork 的 PR 无法访问 secrets,会构建未签名版本
|
||||
- name: Build artifact on Windows
|
||||
if: runner.os == 'Windows'
|
||||
run: npm run desktop:build
|
||||
run: npm run desktop:package:app
|
||||
env:
|
||||
# 设置更新通道,PR构建为nightly,否则为stable
|
||||
UPDATE_CHANNEL: 'nightly'
|
||||
@@ -160,7 +160,7 @@ jobs:
|
||||
# Linux 平台构建处理
|
||||
- name: Build artifact on Linux
|
||||
if: runner.os == 'Linux'
|
||||
run: npm run desktop:build
|
||||
run: npm run desktop:package:app
|
||||
env:
|
||||
# 设置更新通道,PR构建为nightly,否则为stable
|
||||
UPDATE_CHANNEL: 'nightly'
|
||||
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
# macOS 构建
|
||||
- name: Build artifact on macOS
|
||||
if: runner.os == 'macOS'
|
||||
run: npm run desktop:build
|
||||
run: npm run desktop:package:app
|
||||
env:
|
||||
UPDATE_CHANNEL: beta
|
||||
APP_URL: http://localhost:3015
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
# Windows 构建
|
||||
- name: Build artifact on Windows
|
||||
if: runner.os == 'Windows'
|
||||
run: npm run desktop:build
|
||||
run: npm run desktop:package:app
|
||||
env:
|
||||
UPDATE_CHANNEL: beta
|
||||
APP_URL: http://localhost:3015
|
||||
@@ -133,7 +133,7 @@ jobs:
|
||||
# Linux 构建
|
||||
- name: Build artifact on Linux
|
||||
if: runner.os == 'Linux'
|
||||
run: npm run desktop:build
|
||||
run: npm run desktop:package:app
|
||||
env:
|
||||
UPDATE_CHANNEL: beta
|
||||
APP_URL: http://localhost:3015
|
||||
|
||||
@@ -145,7 +145,7 @@ jobs:
|
||||
fi
|
||||
|
||||
# macOS (ARM64)
|
||||
# 使用 GitHub Hosted Runner
|
||||
# 使用 GitHub Hosted Runner (macos-15 修复 hdiutil 问题)
|
||||
if [[ "${{ github.event_name }}" != "workflow_dispatch" ]] || [[ "${{ inputs.build_mac }}" == "true" ]]; then
|
||||
echo "Using GitHub-Hosted Runner for macOS ARM64"
|
||||
arm_entry='{"os": "macos-15", "name": "macos-arm64"}'
|
||||
@@ -185,10 +185,18 @@ jobs:
|
||||
- name: Set package version
|
||||
run: npm run workflow:set-desktop-version ${{ needs.check-stable.outputs.version }} stable
|
||||
|
||||
# macOS 构建前清理 (修复 hdiutil 问题 https://github.com/electron-userland/electron-builder/issues/8415)
|
||||
- name: Clean previous build artifacts (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
sudo rm -rf apps/desktop/release || true
|
||||
sudo rm -rf apps/desktop/dist || true
|
||||
sudo rm -rf /tmp/electron-builder* || true
|
||||
|
||||
# macOS 构建
|
||||
- name: Build artifact on macOS
|
||||
if: runner.os == 'macOS'
|
||||
run: npm run desktop:build
|
||||
run: npm run desktop:package:app
|
||||
env:
|
||||
UPDATE_CHANNEL: stable
|
||||
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
|
||||
@@ -204,11 +212,13 @@ jobs:
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_STABLE_DESKTOP_PROJECT_ID }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_STABLE_DESKTOP_BASE_URL }}
|
||||
# Debug hdiutil issues (https://github.com/electron-userland/electron-builder/issues/8415)
|
||||
DEBUG_DMG: true
|
||||
|
||||
# Windows 构建
|
||||
- name: Build artifact on Windows
|
||||
if: runner.os == 'Windows'
|
||||
run: npm run desktop:build
|
||||
run: npm run desktop:package:app
|
||||
env:
|
||||
UPDATE_CHANNEL: stable
|
||||
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
|
||||
@@ -225,7 +235,7 @@ jobs:
|
||||
- name: Build artifact on Linux
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
npm run desktop:build
|
||||
npm run desktop:package:app
|
||||
tar -czf apps/desktop/release/lobehub-renderer.tar.gz -C out .
|
||||
env:
|
||||
UPDATE_CHANNEL: stable
|
||||
|
||||
+10
-31
@@ -1,23 +1,14 @@
|
||||
name: Desktop Next Build
|
||||
name: Verify Electron i18n Codemod
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- next
|
||||
pull_request:
|
||||
paths:
|
||||
- 'apps/desktop/**'
|
||||
- 'scripts/electronWorkflow/**'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'bun.lockb'
|
||||
- 'src/**'
|
||||
- 'packages/**'
|
||||
- '.github/workflows/desktop-build-electron.yml'
|
||||
- main
|
||||
- dev
|
||||
|
||||
concurrency:
|
||||
group: desktop-electron-${{ github.ref }}
|
||||
group: verify-electron-codemod-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
@@ -28,19 +19,12 @@ env:
|
||||
BUN_VERSION: 1.2.23
|
||||
|
||||
jobs:
|
||||
build-next:
|
||||
name: Build desktop Next bundle
|
||||
verify-codemod:
|
||||
name: Verify i18n codemod on temp workspace
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
UPDATE_CHANNEL: nightly
|
||||
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_NIGHTLY_DESKTOP_PROJECT_ID || 'dummy-desktop-project' }}
|
||||
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL || 'https://analytics.example.com' }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -57,7 +41,7 @@ jobs:
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-store
|
||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v5
|
||||
@@ -76,10 +60,5 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --node-linker=hoisted
|
||||
|
||||
- name: Install desktop dependencies
|
||||
run: |
|
||||
cd apps/desktop
|
||||
bun run install-isolated
|
||||
|
||||
- name: Build desktop Next.js bundle
|
||||
run: bun run desktop:build-electron
|
||||
- name: Run electron workflow modifiers
|
||||
run: bun scripts/electronWorkflow/modifiers/index.mts
|
||||
+12
-11
@@ -29,7 +29,7 @@ LobeHub Desktop is a cross-platform desktop application for [LobeChat](https://g
|
||||
pnpm install-isolated
|
||||
|
||||
# Start development server
|
||||
pnpm electron:dev
|
||||
pnpm dev
|
||||
|
||||
# Type checking
|
||||
pnpm type-check
|
||||
@@ -51,19 +51,20 @@ cp .env.desktop .env
|
||||
|
||||
### Build Commands
|
||||
|
||||
| Command | Description |
|
||||
| ------------------ | --------------------------------------- |
|
||||
| `pnpm build` | Build for all platforms |
|
||||
| `pnpm build:mac` | Build for macOS (Intel + Apple Silicon) |
|
||||
| `pnpm build:win` | Build for Windows |
|
||||
| `pnpm build:linux` | Build for Linux |
|
||||
| `pnpm build-local` | Local development build |
|
||||
| Command | Description |
|
||||
| -------------------------- | ------------------------------------------- |
|
||||
| `pnpm build:main` | Build main/preload (dist output only) |
|
||||
| `pnpm package:mac` | Package for macOS (Intel + Apple Silicon) |
|
||||
| `pnpm package:win` | Package for Windows |
|
||||
| `pnpm package:linux` | Package for Linux |
|
||||
| `pnpm package:local` | Local packaging build (no ASAR) |
|
||||
| `pnpm package:local:reuse` | Local packaging build reusing existing dist |
|
||||
|
||||
### Development Workflow
|
||||
|
||||
```bash
|
||||
# 1. Development
|
||||
pnpm electron:dev # Start with hot reload
|
||||
pnpm dev # Start with hot reload
|
||||
|
||||
# 2. Code Quality
|
||||
pnpm lint # ESLint checking
|
||||
@@ -74,8 +75,8 @@ pnpm type-check # TypeScript validation
|
||||
pnpm test # Run Vitest tests
|
||||
|
||||
# 4. Build & Package
|
||||
pnpm build # Production build
|
||||
pnpm build-local # Local testing build
|
||||
pnpm build:main # Production build (dist only)
|
||||
pnpm package:local # Local testing package
|
||||
```
|
||||
|
||||
## 🎯 Release Channels
|
||||
|
||||
@@ -29,7 +29,7 @@ LobeHub Desktop 是 [LobeChat](https://github.com/lobehub/lobe-chat) 的跨平
|
||||
pnpm install-isolated
|
||||
|
||||
# 启动开发服务器
|
||||
pnpm electron:dev
|
||||
pnpm dev
|
||||
|
||||
# 类型检查
|
||||
pnpm type-check
|
||||
@@ -51,19 +51,20 @@ cp .env.desktop .env
|
||||
|
||||
### 构建命令
|
||||
|
||||
| 命令 | 描述 |
|
||||
| ------------------ | ---------------------------------- |
|
||||
| `pnpm build` | 构建所有平台 |
|
||||
| `pnpm build:mac` | 构建 macOS (Intel + Apple Silicon) |
|
||||
| `pnpm build:win` | 构建 Windows |
|
||||
| `pnpm build:linux` | 构建 Linux |
|
||||
| `pnpm build-local` | 本地开发构建 |
|
||||
| 命令 | 描述 |
|
||||
| -------------------------- | ---------------------------------- |
|
||||
| `pnpm build:main` | 构建 main/preload(仅产出 dist) |
|
||||
| `pnpm package:mac` | 打包 macOS (Intel + Apple Silicon) |
|
||||
| `pnpm package:win` | 打包 Windows |
|
||||
| `pnpm package:linux` | 打包 Linux |
|
||||
| `pnpm package:local` | 本地打包(不打 ASAR) |
|
||||
| `pnpm package:local:reuse` | 本地打包复用已有 dist |
|
||||
|
||||
### 开发工作流
|
||||
|
||||
```bash
|
||||
# 1. 开发
|
||||
pnpm electron:dev # 启动热重载开发服务器
|
||||
pnpm dev # 启动热重载开发服务器
|
||||
|
||||
# 2. 代码质量
|
||||
pnpm lint # ESLint 检查
|
||||
@@ -74,8 +75,8 @@ pnpm type-check # TypeScript 验证
|
||||
pnpm test # 运行 Vitest 测试
|
||||
|
||||
# 4. 构建和打包
|
||||
pnpm build # 生产构建
|
||||
pnpm build-local # 本地测试构建
|
||||
pnpm build:main # 生产构建(仅 dist)
|
||||
pnpm package:local # 本地测试打包
|
||||
```
|
||||
|
||||
## 🎯 发布渠道
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 171 KiB After Width: | Height: | Size: 151 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 174 KiB |
+12
-12
@@ -11,16 +11,10 @@
|
||||
"author": "LobeHub",
|
||||
"main": "./dist/main/index.js",
|
||||
"scripts": {
|
||||
"build": "electron-vite build",
|
||||
"build-local": "npm run build && electron-builder --dir --config electron-builder.mjs --c.mac.notarize=false -c.mac.identity=null --c.asar=false",
|
||||
"build:linux": "npm run build && electron-builder --linux --config electron-builder.mjs --publish never",
|
||||
"build:mac": "npm run build && electron-builder --mac --config electron-builder.mjs --publish never",
|
||||
"build:mac:local": "npm run build && UPDATE_CHANNEL=nightly electron-builder --mac --config electron-builder.mjs --publish never",
|
||||
"build:win": "npm run build && electron-builder --win --config electron-builder.mjs --publish never",
|
||||
"build:main": "electron-vite build",
|
||||
"build:run-unpack": "electron .",
|
||||
"dev": "electron-vite dev",
|
||||
"dev:static": "cross-env DESKTOP_RENDERER_STATIC=1 npm run electron:dev",
|
||||
"electron:dev": "electron-vite dev",
|
||||
"electron:run-unpack": "electron .",
|
||||
"dev:static": "cross-env DESKTOP_RENDERER_STATIC=1 npm run dev",
|
||||
"format": "prettier --write ",
|
||||
"i18n": "tsx scripts/i18nWorkflow/index.ts && lobe-i18n",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
@@ -32,6 +26,12 @@
|
||||
"lint:md": "remark . --silent --output",
|
||||
"lint:style": "stylelint \"{src,tests}/**/*.{js,jsx,ts,tsx}\" --fix",
|
||||
"lint:ts": "eslint \"{src,tests}/**/*.{js,jsx,ts,tsx}\" --fix",
|
||||
"package:linux": "npm run build:main && electron-builder --linux --config electron-builder.mjs --publish never",
|
||||
"package:local": "npm run build:main && electron-builder --dir --config electron-builder.mjs --c.mac.notarize=false -c.mac.identity=null --c.asar=false",
|
||||
"package:local:reuse": "electron-builder --dir --config electron-builder.mjs --c.mac.notarize=false -c.mac.identity=null --c.asar=false",
|
||||
"package:mac": "npm run build:main && electron-builder --mac --config electron-builder.mjs --publish never",
|
||||
"package:mac:local": "npm run build:main && UPDATE_CHANNEL=nightly electron-builder --mac --config electron-builder.mjs --publish never",
|
||||
"package:win": "npm run build:main && electron-builder --win --config electron-builder.mjs --publish never",
|
||||
"start": "electron-vite preview",
|
||||
"stylelint": "stylelint \"src/**/*.{js,jsx,ts,tsx}\" --fix",
|
||||
"test": "vitest --run",
|
||||
@@ -47,9 +47,6 @@
|
||||
"get-port-please": "^3.2.0",
|
||||
"superjson": "^2.2.6"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"node-mac-permissions": "^2.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^3.1.0",
|
||||
@@ -103,6 +100,9 @@
|
||||
"vitest": "^3.2.4",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"node-mac-permissions": "^2.5.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@napi-rs/canvas",
|
||||
|
||||
@@ -41,6 +41,10 @@
|
||||
"edit.stopSpeaking": "إيقاف القراءة",
|
||||
"edit.title": "تحرير",
|
||||
"edit.undo": "تراجع",
|
||||
"file.newAgent": "وكيل جديد",
|
||||
"file.newAgentGroup": "مجموعة وكلاء جديدة",
|
||||
"file.newPage": "صفحة جديدة",
|
||||
"file.newTopic": "موضوع جديد",
|
||||
"file.preferences": "التفضيلات",
|
||||
"file.quit": "خروج",
|
||||
"file.title": "ملف",
|
||||
|
||||
@@ -41,6 +41,10 @@
|
||||
"edit.stopSpeaking": "Спри четенето",
|
||||
"edit.title": "Редактиране",
|
||||
"edit.undo": "Отмяна",
|
||||
"file.newAgent": "Нов агент",
|
||||
"file.newAgentGroup": "Нова група агенти",
|
||||
"file.newPage": "Нова страница",
|
||||
"file.newTopic": "Нова тема",
|
||||
"file.preferences": "Предпочитания",
|
||||
"file.quit": "Изход",
|
||||
"file.title": "Файл",
|
||||
|
||||
@@ -41,6 +41,10 @@
|
||||
"edit.stopSpeaking": "Stoppe das Sprechen",
|
||||
"edit.title": "Bearbeiten",
|
||||
"edit.undo": "Rückgängig",
|
||||
"file.newAgent": "Neuer Assistent",
|
||||
"file.newAgentGroup": "Neue Assistentengruppe",
|
||||
"file.newPage": "Neue Seite",
|
||||
"file.newTopic": "Neues Thema",
|
||||
"file.preferences": "Einstellungen",
|
||||
"file.quit": "Beenden",
|
||||
"file.title": "Datei",
|
||||
|
||||
@@ -41,6 +41,10 @@
|
||||
"edit.stopSpeaking": "Stop Speaking",
|
||||
"edit.title": "Edit",
|
||||
"edit.undo": "Undo",
|
||||
"file.newAgent": "New Agent",
|
||||
"file.newAgentGroup": "New Agent Group",
|
||||
"file.newPage": "New Page",
|
||||
"file.newTopic": "New Topic",
|
||||
"file.preferences": "Preferences",
|
||||
"file.quit": "Quit",
|
||||
"file.title": "File",
|
||||
@@ -79,4 +83,4 @@
|
||||
"window.title": "Window",
|
||||
"window.toggleFullscreen": "Toggle Fullscreen",
|
||||
"window.zoom": "Zoom"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,10 @@
|
||||
"edit.stopSpeaking": "Detener lectura en voz alta",
|
||||
"edit.title": "Editar",
|
||||
"edit.undo": "Deshacer",
|
||||
"file.newAgent": "Nuevo agente",
|
||||
"file.newAgentGroup": "Nuevo grupo de agentes",
|
||||
"file.newPage": "Nueva página",
|
||||
"file.newTopic": "Nuevo tema",
|
||||
"file.preferences": "Preferencias",
|
||||
"file.quit": "Salir",
|
||||
"file.title": "Archivo",
|
||||
|
||||
@@ -41,6 +41,10 @@
|
||||
"edit.stopSpeaking": "متوقف کردن خواندن",
|
||||
"edit.title": "ویرایش",
|
||||
"edit.undo": "بازگشت",
|
||||
"file.newAgent": "عامل جدید",
|
||||
"file.newAgentGroup": "گروه عامل جدید",
|
||||
"file.newPage": "صفحه جدید",
|
||||
"file.newTopic": "موضوع جدید",
|
||||
"file.preferences": "تنظیمات",
|
||||
"file.quit": "خروج",
|
||||
"file.title": "فایل",
|
||||
|
||||
@@ -41,6 +41,10 @@
|
||||
"edit.stopSpeaking": "Arrêter de lire",
|
||||
"edit.title": "Édition",
|
||||
"edit.undo": "Annuler",
|
||||
"file.newAgent": "Nouvel assistant",
|
||||
"file.newAgentGroup": "Nouveau groupe d'assistants",
|
||||
"file.newPage": "Nouvelle page",
|
||||
"file.newTopic": "Nouveau sujet",
|
||||
"file.preferences": "Préférences",
|
||||
"file.quit": "Quitter",
|
||||
"file.title": "Fichier",
|
||||
|
||||
@@ -41,6 +41,10 @@
|
||||
"edit.stopSpeaking": "Ferma la lettura",
|
||||
"edit.title": "Modifica",
|
||||
"edit.undo": "Annulla",
|
||||
"file.newAgent": "Nuovo agente",
|
||||
"file.newAgentGroup": "Nuovo gruppo di agenti",
|
||||
"file.newPage": "Nuova pagina",
|
||||
"file.newTopic": "Nuovo argomento",
|
||||
"file.preferences": "Preferenze",
|
||||
"file.quit": "Esci",
|
||||
"file.title": "File",
|
||||
|
||||
@@ -41,6 +41,10 @@
|
||||
"edit.stopSpeaking": "読み上げ停止",
|
||||
"edit.title": "編集",
|
||||
"edit.undo": "元に戻す",
|
||||
"file.newAgent": "新しいエージェント",
|
||||
"file.newAgentGroup": "新しいエージェントグループ",
|
||||
"file.newPage": "新しいページ",
|
||||
"file.newTopic": "新しいトピック",
|
||||
"file.preferences": "設定",
|
||||
"file.quit": "終了",
|
||||
"file.title": "ファイル",
|
||||
|
||||
@@ -41,6 +41,10 @@
|
||||
"edit.stopSpeaking": "읽기 중지",
|
||||
"edit.title": "편집",
|
||||
"edit.undo": "실행 취소",
|
||||
"file.newAgent": "새 에이전트",
|
||||
"file.newAgentGroup": "새 에이전트 그룹",
|
||||
"file.newPage": "새 페이지",
|
||||
"file.newTopic": "새 토픽",
|
||||
"file.preferences": "환경 설정",
|
||||
"file.quit": "종료",
|
||||
"file.title": "파일",
|
||||
|
||||
@@ -41,6 +41,10 @@
|
||||
"edit.stopSpeaking": "Stop met voorlezen",
|
||||
"edit.title": "Bewerken",
|
||||
"edit.undo": "Ongedaan maken",
|
||||
"file.newAgent": "Nieuwe agent",
|
||||
"file.newAgentGroup": "Nieuwe agentgroep",
|
||||
"file.newPage": "Nieuwe pagina",
|
||||
"file.newTopic": "Nieuw onderwerp",
|
||||
"file.preferences": "Voorkeuren",
|
||||
"file.quit": "Afsluiten",
|
||||
"file.title": "Bestand",
|
||||
|
||||
@@ -41,6 +41,10 @@
|
||||
"edit.stopSpeaking": "Zatrzymaj czytanie",
|
||||
"edit.title": "Edycja",
|
||||
"edit.undo": "Cofnij",
|
||||
"file.newAgent": "Nowy agent",
|
||||
"file.newAgentGroup": "Nowa grupa agentów",
|
||||
"file.newPage": "Nowa strona",
|
||||
"file.newTopic": "Nowy temat",
|
||||
"file.preferences": "Preferencje",
|
||||
"file.quit": "Zakończ",
|
||||
"file.title": "Plik",
|
||||
|
||||
@@ -41,6 +41,10 @@
|
||||
"edit.stopSpeaking": "Parar de Ler",
|
||||
"edit.title": "Edição",
|
||||
"edit.undo": "Desfazer",
|
||||
"file.newAgent": "Novo Agente",
|
||||
"file.newAgentGroup": "Novo Grupo de Agentes",
|
||||
"file.newPage": "Nova Página",
|
||||
"file.newTopic": "Novo Tópico",
|
||||
"file.preferences": "Preferências",
|
||||
"file.quit": "Sair",
|
||||
"file.title": "Arquivo",
|
||||
|
||||
@@ -41,6 +41,10 @@
|
||||
"edit.stopSpeaking": "Остановить чтение",
|
||||
"edit.title": "Редактирование",
|
||||
"edit.undo": "Отменить",
|
||||
"file.newAgent": "Новый агент",
|
||||
"file.newAgentGroup": "Новая группа агентов",
|
||||
"file.newPage": "Новая страница",
|
||||
"file.newTopic": "Новая тема",
|
||||
"file.preferences": "Настройки",
|
||||
"file.quit": "Выйти",
|
||||
"file.title": "Файл",
|
||||
|
||||
@@ -41,6 +41,10 @@
|
||||
"edit.stopSpeaking": "Okumayı Durdur",
|
||||
"edit.title": "Düzenle",
|
||||
"edit.undo": "Geri Al",
|
||||
"file.newAgent": "Yeni Ajan",
|
||||
"file.newAgentGroup": "Yeni Ajan Grubu",
|
||||
"file.newPage": "Yeni Sayfa",
|
||||
"file.newTopic": "Yeni Konu",
|
||||
"file.preferences": "Tercihler",
|
||||
"file.quit": "Çık",
|
||||
"file.title": "Dosya",
|
||||
|
||||
@@ -41,6 +41,10 @@
|
||||
"edit.stopSpeaking": "Dừng đọc",
|
||||
"edit.title": "Chỉnh sửa",
|
||||
"edit.undo": "Hoàn tác",
|
||||
"file.newAgent": "Tác nhân mới",
|
||||
"file.newAgentGroup": "Nhóm tác nhân mới",
|
||||
"file.newPage": "Trang mới",
|
||||
"file.newTopic": "Chủ đề mới",
|
||||
"file.preferences": "Tùy chọn",
|
||||
"file.quit": "Thoát",
|
||||
"file.title": "Tập tin",
|
||||
|
||||
@@ -41,6 +41,10 @@
|
||||
"edit.stopSpeaking": "停止朗读",
|
||||
"edit.title": "编辑",
|
||||
"edit.undo": "撤销",
|
||||
"file.newAgent": "新建助手",
|
||||
"file.newAgentGroup": "新建助手组",
|
||||
"file.newPage": "新建页面",
|
||||
"file.newTopic": "新建话题",
|
||||
"file.preferences": "设置…",
|
||||
"file.quit": "退出",
|
||||
"file.title": "文件",
|
||||
|
||||
@@ -41,6 +41,10 @@
|
||||
"edit.stopSpeaking": "停止朗讀",
|
||||
"edit.title": "編輯",
|
||||
"edit.undo": "撤銷",
|
||||
"file.newAgent": "新建助手",
|
||||
"file.newAgentGroup": "新建助手組",
|
||||
"file.newPage": "新建頁面",
|
||||
"file.newTopic": "新建話題",
|
||||
"file.preferences": "偏好設定",
|
||||
"file.quit": "退出",
|
||||
"file.title": "檔案",
|
||||
|
||||
@@ -41,8 +41,8 @@ chmod +x *.sh
|
||||
cd ../..
|
||||
|
||||
# 构建未签名的本地测试包
|
||||
bun run build
|
||||
bun run build-local
|
||||
bun run build:main
|
||||
bun run package:local
|
||||
```
|
||||
|
||||
如果需要模拟 CI 的渠道构建(Nightly / Beta / Stable),可以使用根目录脚本:
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
AuthorizationPhase,
|
||||
AuthorizationProgress,
|
||||
DataSyncConfig,
|
||||
MarketAuthorizationParams,
|
||||
@@ -18,6 +17,7 @@ const logger = createLogger('controllers:AuthCtr');
|
||||
|
||||
const MAX_POLL_TIME = 2 * 60 * 1000; // 2 minutes (reduced from 5 minutes for better UX)
|
||||
const POLL_INTERVAL = 3000; // 3 seconds
|
||||
const TOKEN_REFRESH_DEBOUNCE = 5 * 60 * 1000; // 5 minutes - debounce interval to prevent excessive refreshes on rapid app restarts
|
||||
|
||||
/**
|
||||
* Authentication Controller
|
||||
@@ -289,34 +289,35 @@ export default class AuthCtr extends ControllerModule {
|
||||
this.autoRefreshTimer = setInterval(async () => {
|
||||
try {
|
||||
// Check if token is expiring soon (refresh 5 minutes in advance)
|
||||
if (this.remoteServerConfigCtr.isTokenExpiringSoon()) {
|
||||
const expiresAt = this.remoteServerConfigCtr.getTokenExpiresAt();
|
||||
logger.info(
|
||||
`Token is expiring soon, triggering auto-refresh. Expires at: ${expiresAt ? new Date(expiresAt).toISOString() : 'unknown'}`,
|
||||
);
|
||||
if (!this.remoteServerConfigCtr.isTokenExpiringSoon()) {
|
||||
return;
|
||||
}
|
||||
const expiresAt = this.remoteServerConfigCtr.getTokenExpiresAt();
|
||||
logger.info(
|
||||
`Token is expiring soon, triggering auto-refresh. Expires at: ${expiresAt ? new Date(expiresAt).toISOString() : 'unknown'}`,
|
||||
);
|
||||
|
||||
const result = await this.remoteServerConfigCtr.refreshAccessToken();
|
||||
if (result.success) {
|
||||
logger.info('Auto-refresh successful');
|
||||
this.broadcastTokenRefreshed();
|
||||
const result = await this.remoteServerConfigCtr.refreshAccessToken();
|
||||
if (result.success) {
|
||||
logger.info('Auto-refresh successful');
|
||||
this.broadcastTokenRefreshed();
|
||||
} else {
|
||||
logger.error(`Auto-refresh failed after retries: ${result.error}`);
|
||||
|
||||
// Only clear tokens for non-retryable errors (e.g., invalid_grant)
|
||||
// The retry mechanism in RemoteServerConfigCtr already handles transient errors
|
||||
if (this.remoteServerConfigCtr.isNonRetryableError(result.error)) {
|
||||
logger.warn(
|
||||
'Non-retryable error detected, clearing tokens and requiring re-authorization',
|
||||
);
|
||||
this.stopAutoRefresh();
|
||||
await this.remoteServerConfigCtr.clearTokens();
|
||||
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
|
||||
this.broadcastAuthorizationRequired();
|
||||
} else {
|
||||
logger.error(`Auto-refresh failed after retries: ${result.error}`);
|
||||
|
||||
// Only clear tokens for non-retryable errors (e.g., invalid_grant)
|
||||
// The retry mechanism in RemoteServerConfigCtr already handles transient errors
|
||||
if (this.remoteServerConfigCtr.isNonRetryableError(result.error)) {
|
||||
logger.warn(
|
||||
'Non-retryable error detected, clearing tokens and requiring re-authorization',
|
||||
);
|
||||
this.stopAutoRefresh();
|
||||
await this.remoteServerConfigCtr.clearTokens();
|
||||
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
|
||||
this.broadcastAuthorizationRequired();
|
||||
} else {
|
||||
// For other errors (after retries exhausted), log but don't clear tokens immediately
|
||||
// The next refresh cycle will retry
|
||||
logger.warn('Refresh failed but error may be transient, will retry on next cycle');
|
||||
}
|
||||
// For other errors (after retries exhausted), log but don't clear tokens immediately
|
||||
// The next refresh cycle will retry
|
||||
logger.warn('Refresh failed but error may be transient, will retry on next cycle');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -663,13 +664,14 @@ export default class AuthCtr extends ControllerModule {
|
||||
/**
|
||||
* Initialize auto-refresh functionality
|
||||
* Checks for valid token at app startup and starts auto-refresh timer if token exists
|
||||
* Proactively refreshes token on every startup (with 5-minute debounce to prevent rapid restart issues)
|
||||
*/
|
||||
private async initializeAutoRefresh() {
|
||||
try {
|
||||
const config = await this.remoteServerConfigCtr.getRemoteServerConfig();
|
||||
|
||||
// Check if remote server is configured and active
|
||||
if (!config.active || !config.remoteServerUrl) {
|
||||
if (!(await this.remoteServerConfigCtr.isRemoteServerConfigured(config))) {
|
||||
logger.debug(
|
||||
'Remote server not active or configured, skipping auto-refresh initialization',
|
||||
);
|
||||
@@ -690,44 +692,121 @@ export default class AuthCtr extends ControllerModule {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if token has already expired
|
||||
const currentTime = Date.now();
|
||||
|
||||
// Check if token has already expired
|
||||
if (currentTime >= expiresAt) {
|
||||
logger.info('Token has expired, attempting to refresh it');
|
||||
await this.performProactiveRefresh();
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt to refresh token (includes retry mechanism)
|
||||
const refreshResult = await this.remoteServerConfigCtr.refreshAccessToken();
|
||||
if (refreshResult.success) {
|
||||
logger.info('Token refresh successful during initialization');
|
||||
this.broadcastTokenRefreshed();
|
||||
// Restart auto-refresh timer
|
||||
this.startAutoRefresh();
|
||||
return;
|
||||
} else {
|
||||
logger.error(`Token refresh failed during initialization: ${refreshResult.error}`);
|
||||
|
||||
// Only clear token for non-retryable errors
|
||||
if (this.remoteServerConfigCtr.isNonRetryableError(refreshResult.error)) {
|
||||
logger.warn('Non-retryable error during initialization, clearing tokens');
|
||||
await this.remoteServerConfigCtr.clearTokens();
|
||||
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
|
||||
this.broadcastAuthorizationRequired();
|
||||
} else {
|
||||
// For transient errors, still start auto-refresh timer to retry later
|
||||
logger.warn('Transient error during initialization, will retry via auto-refresh');
|
||||
this.startAutoRefresh();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Proactively refresh token if it hasn't been refreshed in the last 6 hours
|
||||
// This ensures token validity even if the server has revoked it
|
||||
if (this.shouldProactivelyRefresh()) {
|
||||
logger.info('Token refresh interval exceeded, proactively refreshing token on startup');
|
||||
await this.performProactiveRefresh();
|
||||
return;
|
||||
}
|
||||
|
||||
// Start auto-refresh timer
|
||||
logger.info(
|
||||
`Token is valid, starting auto-refresh timer. Token expires at: ${new Date(expiresAt).toISOString()}`,
|
||||
`Token is valid and recently refreshed, starting auto-refresh timer. Token expires at: ${new Date(expiresAt).toISOString()}`,
|
||||
);
|
||||
this.startAutoRefresh();
|
||||
} catch (error) {
|
||||
logger.error('Error during auto-refresh initialization:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token should be proactively refreshed
|
||||
* Returns true if the token hasn't been refreshed recently (within debounce interval)
|
||||
* This ensures we refresh on every app launch while preventing excessive refreshes on rapid restarts
|
||||
*/
|
||||
private shouldProactivelyRefresh(): boolean {
|
||||
const lastRefreshAt = this.remoteServerConfigCtr.getLastTokenRefreshAt();
|
||||
|
||||
// If never refreshed, should refresh
|
||||
if (!lastRefreshAt) {
|
||||
logger.debug('No last refresh time found, should proactively refresh');
|
||||
return true;
|
||||
}
|
||||
|
||||
const timeSinceLastRefresh = Date.now() - lastRefreshAt;
|
||||
const shouldRefresh = timeSinceLastRefresh >= TOKEN_REFRESH_DEBOUNCE;
|
||||
|
||||
if (shouldRefresh) {
|
||||
logger.debug(
|
||||
`Time since last refresh: ${Math.round(timeSinceLastRefresh / 1000 / 60)} minutes, exceeds ${TOKEN_REFRESH_DEBOUNCE / 1000 / 60} minutes debounce threshold`,
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`Time since last refresh: ${Math.round(timeSinceLastRefresh / 1000 / 60)} minutes, within ${TOKEN_REFRESH_DEBOUNCE / 1000 / 60} minutes debounce threshold, skipping refresh`,
|
||||
);
|
||||
}
|
||||
|
||||
return shouldRefresh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform proactive token refresh (used on startup and app activation)
|
||||
*/
|
||||
private async performProactiveRefresh(): Promise<void> {
|
||||
const refreshResult = await this.remoteServerConfigCtr.refreshAccessToken();
|
||||
if (refreshResult.success) {
|
||||
logger.info('Proactive token refresh successful');
|
||||
this.broadcastTokenRefreshed();
|
||||
this.startAutoRefresh();
|
||||
} else {
|
||||
logger.error(`Proactive token refresh failed: ${refreshResult.error}`);
|
||||
|
||||
// Only clear token for non-retryable errors
|
||||
if (this.remoteServerConfigCtr.isNonRetryableError(refreshResult.error)) {
|
||||
logger.warn('Non-retryable error during proactive refresh, clearing tokens');
|
||||
await this.remoteServerConfigCtr.clearTokens();
|
||||
await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
|
||||
this.broadcastAuthorizationRequired();
|
||||
} else {
|
||||
// For transient errors, still start auto-refresh timer to retry later
|
||||
logger.warn('Transient error during proactive refresh, will retry via auto-refresh');
|
||||
this.startAutoRefresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle app activation event (e.g., Mac dock click, window focus)
|
||||
* Proactively refresh token if needed (respects 6-hour interval)
|
||||
*/
|
||||
async onAppActivate(): Promise<void> {
|
||||
logger.debug('App activated, checking if token refresh is needed');
|
||||
|
||||
try {
|
||||
const config = await this.remoteServerConfigCtr.getRemoteServerConfig();
|
||||
|
||||
// Check if remote server is configured and active
|
||||
if (!(await this.remoteServerConfigCtr.isRemoteServerConfigured(config))) {
|
||||
logger.debug('Remote server not active, skipping activation refresh');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if valid access token exists
|
||||
const accessToken = await this.remoteServerConfigCtr.getAccessToken();
|
||||
if (!accessToken) {
|
||||
logger.debug('No access token found, skipping activation refresh');
|
||||
return;
|
||||
}
|
||||
|
||||
// Only refresh if interval has passed
|
||||
if (this.shouldProactivelyRefresh()) {
|
||||
logger.info('Token refresh interval exceeded on app activation, refreshing token');
|
||||
await this.performProactiveRefresh();
|
||||
} else {
|
||||
logger.debug('Token was recently refreshed, skipping activation refresh');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error during app activation refresh check:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable unicorn/no-array-push-push */
|
||||
import {
|
||||
EditLocalFileParams,
|
||||
EditLocalFileResult,
|
||||
@@ -22,13 +23,13 @@ import {
|
||||
import { SYSTEM_FILES_TO_IGNORE, loadFile } from '@lobechat/file-loaders';
|
||||
import { createPatch } from 'diff';
|
||||
import { dialog, shell } from 'electron';
|
||||
import fg from 'fast-glob';
|
||||
import { Stats, constants } from 'node:fs';
|
||||
import { constants } from 'node:fs';
|
||||
import { access, mkdir, readFile, readdir, rename, stat, writeFile } from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { FileResult, SearchOptions } from '@/modules/fileSearch';
|
||||
import ContentSearchService from '@/services/contentSearchSrv';
|
||||
import FileSearchService from '@/services/fileSearchSrv';
|
||||
import { FileResult, SearchOptions } from '@/types/fileSearch';
|
||||
import { makeSureDirExist } from '@/utils/file-system';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
@@ -43,6 +44,10 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
return this.app.getService(FileSearchService);
|
||||
}
|
||||
|
||||
private get contentSearchService() {
|
||||
return this.app.getService(ContentSearchService);
|
||||
}
|
||||
|
||||
// ==================== File Operation ====================
|
||||
|
||||
@IpcMethod()
|
||||
@@ -550,163 +555,12 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
|
||||
@IpcMethod()
|
||||
async handleGrepContent(params: GrepContentParams): Promise<GrepContentResult> {
|
||||
const {
|
||||
pattern,
|
||||
path: searchPath = process.cwd(),
|
||||
output_mode = 'files_with_matches',
|
||||
} = params;
|
||||
const logPrefix = `[grepContent: ${pattern}]`;
|
||||
logger.debug(`${logPrefix} Starting content search`, { output_mode, searchPath });
|
||||
|
||||
try {
|
||||
const regex = new RegExp(
|
||||
pattern,
|
||||
`g${params['-i'] ? 'i' : ''}${params.multiline ? 's' : ''}`,
|
||||
);
|
||||
|
||||
// Determine files to search
|
||||
let filesToSearch: string[] = [];
|
||||
const stats = await stat(searchPath);
|
||||
|
||||
if (stats.isFile()) {
|
||||
filesToSearch = [searchPath];
|
||||
} else {
|
||||
// Use glob pattern if provided, otherwise search all files
|
||||
// If glob doesn't contain directory separator and doesn't start with **,
|
||||
// auto-prefix with **/ to make it recursive
|
||||
let globPattern = params.glob || '**/*';
|
||||
if (params.glob && !params.glob.includes('/') && !params.glob.startsWith('**')) {
|
||||
globPattern = `**/${params.glob}`;
|
||||
}
|
||||
|
||||
filesToSearch = await fg(globPattern, {
|
||||
absolute: true,
|
||||
cwd: searchPath,
|
||||
dot: true,
|
||||
ignore: ['**/node_modules/**', '**/.git/**'],
|
||||
});
|
||||
|
||||
// Filter by type if provided
|
||||
if (params.type) {
|
||||
const ext = `.${params.type}`;
|
||||
filesToSearch = filesToSearch.filter((file) => file.endsWith(ext));
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`${logPrefix} Found ${filesToSearch.length} files to search`);
|
||||
|
||||
const matches: string[] = [];
|
||||
let totalMatches = 0;
|
||||
|
||||
for (const filePath of filesToSearch) {
|
||||
try {
|
||||
const fileStats = await stat(filePath);
|
||||
if (!fileStats.isFile()) continue;
|
||||
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
switch (output_mode) {
|
||||
case 'files_with_matches': {
|
||||
if (regex.test(content)) {
|
||||
matches.push(filePath);
|
||||
totalMatches++;
|
||||
if (params.head_limit && matches.length >= params.head_limit) break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'content': {
|
||||
const matchedLines: string[] = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (regex.test(lines[i])) {
|
||||
const contextBefore = params['-B'] || params['-C'] || 0;
|
||||
const contextAfter = params['-A'] || params['-C'] || 0;
|
||||
|
||||
const startLine = Math.max(0, i - contextBefore);
|
||||
const endLine = Math.min(lines.length - 1, i + contextAfter);
|
||||
|
||||
for (let j = startLine; j <= endLine; j++) {
|
||||
const lineNum = params['-n'] ? `${j + 1}:` : '';
|
||||
matchedLines.push(`${filePath}:${lineNum}${lines[j]}`);
|
||||
}
|
||||
totalMatches++;
|
||||
}
|
||||
}
|
||||
matches.push(...matchedLines);
|
||||
if (params.head_limit && matches.length >= params.head_limit) break;
|
||||
break;
|
||||
}
|
||||
case 'count': {
|
||||
const fileMatches = (content.match(regex) || []).length;
|
||||
if (fileMatches > 0) {
|
||||
matches.push(`${filePath}:${fileMatches}`);
|
||||
totalMatches += fileMatches;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`${logPrefix} Skipping file ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`${logPrefix} Search completed`, {
|
||||
matchCount: matches.length,
|
||||
totalMatches,
|
||||
});
|
||||
|
||||
return {
|
||||
matches: params.head_limit ? matches.slice(0, params.head_limit) : matches,
|
||||
success: true,
|
||||
total_matches: totalMatches,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Grep failed:`, error);
|
||||
return {
|
||||
matches: [],
|
||||
success: false,
|
||||
total_matches: 0,
|
||||
};
|
||||
}
|
||||
return this.contentSearchService.grep(params);
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async handleGlobFiles({
|
||||
path: searchPath = process.cwd(),
|
||||
pattern,
|
||||
}: GlobFilesParams): Promise<GlobFilesResult> {
|
||||
const logPrefix = `[globFiles: ${pattern}]`;
|
||||
logger.debug(`${logPrefix} Starting glob search`, { searchPath });
|
||||
|
||||
try {
|
||||
const files = await fg(pattern, {
|
||||
absolute: true,
|
||||
cwd: searchPath,
|
||||
dot: true,
|
||||
onlyFiles: false,
|
||||
stats: true,
|
||||
});
|
||||
|
||||
// Sort by modification time (most recent first)
|
||||
const sortedFiles = (files as unknown as Array<{ path: string; stats: Stats }>)
|
||||
.sort((a, b) => b.stats.mtime.getTime() - a.stats.mtime.getTime())
|
||||
.map((f) => f.path);
|
||||
|
||||
logger.info(`${logPrefix} Glob completed`, { fileCount: sortedFiles.length });
|
||||
|
||||
return {
|
||||
files: sortedFiles,
|
||||
success: true,
|
||||
total_files: sortedFiles.length,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Glob failed:`, error);
|
||||
return {
|
||||
files: [],
|
||||
success: false,
|
||||
total_files: 0,
|
||||
};
|
||||
}
|
||||
async handleGlobFiles(params: GlobFilesParams): Promise<GlobFilesResult> {
|
||||
return this.searchService.glob(params);
|
||||
}
|
||||
|
||||
// ==================== File Editing ====================
|
||||
|
||||
@@ -82,6 +82,21 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if remote server is properly configured and ready for use
|
||||
* For 'cloud' mode, only checks if active (remoteServerUrl is undefined, uses OFFICIAL_CLOUD_SERVER)
|
||||
* For 'selfHost' mode, checks if active AND remoteServerUrl is configured
|
||||
* @param config Optional config object, if not provided will fetch current config
|
||||
* @returns true if remote server is properly configured
|
||||
*/
|
||||
async isRemoteServerConfigured(config?: DataSyncConfig): Promise<boolean> {
|
||||
const effectiveConfig = config ?? (await this.getRemoteServerConfig());
|
||||
return (
|
||||
effectiveConfig.active &&
|
||||
(effectiveConfig.storageMode !== 'selfHost' || !!effectiveConfig.remoteServerUrl)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set remote server configuration
|
||||
*/
|
||||
@@ -139,6 +154,12 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
*/
|
||||
private tokenExpiresAt?: number;
|
||||
|
||||
/**
|
||||
* Last token refresh time (timestamp in milliseconds)
|
||||
* Used to control refresh frequency on app startup/activate
|
||||
*/
|
||||
private lastRefreshAt?: number;
|
||||
|
||||
/**
|
||||
* Promise representing the ongoing token refresh operation.
|
||||
* Used to prevent concurrent refreshes and allow callers to wait.
|
||||
@@ -162,6 +183,10 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
this.tokenExpiresAt = undefined;
|
||||
}
|
||||
|
||||
// Update last refresh time
|
||||
this.lastRefreshAt = Date.now();
|
||||
logger.debug(`Token last refreshed at: ${new Date(this.lastRefreshAt).toISOString()}`);
|
||||
|
||||
// If platform doesn't support secure storage, store raw tokens
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
logger.warn('Safe storage not available, storing tokens unencrypted');
|
||||
@@ -171,6 +196,7 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
this.app.storeManager.set(this.encryptedTokensKey, {
|
||||
accessToken: this.encryptedAccessToken,
|
||||
expiresAt: this.tokenExpiresAt,
|
||||
lastRefreshAt: this.lastRefreshAt,
|
||||
refreshToken: this.encryptedRefreshToken,
|
||||
});
|
||||
return;
|
||||
@@ -191,6 +217,7 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
this.app.storeManager.set(this.encryptedTokensKey, {
|
||||
accessToken: this.encryptedAccessToken,
|
||||
expiresAt: this.tokenExpiresAt,
|
||||
lastRefreshAt: this.lastRefreshAt,
|
||||
refreshToken: this.encryptedRefreshToken,
|
||||
});
|
||||
}
|
||||
@@ -285,10 +312,10 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
|
||||
/**
|
||||
* Check if token is expired or will expire soon
|
||||
* @param bufferTimeMs Buffer time in milliseconds (default 5 minutes)
|
||||
* @param bufferTimeMs Buffer time in milliseconds (default 1 day)
|
||||
* @returns true if token is expired or will expire soon
|
||||
*/
|
||||
isTokenExpiringSoon(bufferTimeMs: number = 5 * 60 * 1000): boolean {
|
||||
isTokenExpiringSoon(bufferTimeMs: number = 24 * 60 * 60 * 1000): boolean {
|
||||
if (!this.tokenExpiresAt) {
|
||||
return false; // No expiration time available
|
||||
}
|
||||
@@ -401,7 +428,7 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
// Get configuration information
|
||||
const config = await this.getRemoteServerConfig();
|
||||
|
||||
if (!config.remoteServerUrl || !config.active) {
|
||||
if (!(await this.isRemoteServerConfigured(config))) {
|
||||
logger.warn('Remote server not active or configured, skipping refresh.');
|
||||
return { error: 'Remote server is not active or configured', success: false };
|
||||
}
|
||||
@@ -480,17 +507,29 @@ export default class RemoteServerConfigCtr extends ControllerModule {
|
||||
this.encryptedAccessToken = storedTokens.accessToken;
|
||||
this.encryptedRefreshToken = storedTokens.refreshToken;
|
||||
this.tokenExpiresAt = storedTokens.expiresAt;
|
||||
this.lastRefreshAt = storedTokens.lastRefreshAt;
|
||||
|
||||
if (this.tokenExpiresAt) {
|
||||
logger.debug(
|
||||
`Loaded token expiration time: ${new Date(this.tokenExpiresAt).toISOString()}`,
|
||||
);
|
||||
}
|
||||
if (this.lastRefreshAt) {
|
||||
logger.debug(`Loaded last refresh time: ${new Date(this.lastRefreshAt).toISOString()}`);
|
||||
}
|
||||
} else {
|
||||
logger.debug('No valid tokens found in store.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last token refresh time
|
||||
* @returns The timestamp (in milliseconds) of the last token refresh, or undefined if never refreshed
|
||||
*/
|
||||
getLastTokenRefreshAt(): number | undefined {
|
||||
return this.lastRefreshAt;
|
||||
}
|
||||
|
||||
// Initialize by loading tokens from store when the controller is ready
|
||||
// We might need a dedicated lifecycle method if constructor is too early for storeManager
|
||||
afterAppReady() {
|
||||
|
||||
@@ -55,8 +55,7 @@ export default class RemoteServerSyncCtr extends ControllerModule {
|
||||
logger.debug(`${logPrefix} Received stream:start IPC call`);
|
||||
|
||||
try {
|
||||
const config = await this.remoteServerConfigCtr.getRemoteServerConfig();
|
||||
if (!config.active || (config.storageMode === 'selfHost' && !config.remoteServerUrl)) {
|
||||
if (!(await this.remoteServerConfigCtr.isRemoteServerConfigured())) {
|
||||
logger.warn(`${logPrefix} Remote server sync not active or configured.`);
|
||||
event.sender.send(
|
||||
`stream:error:${requestId}`,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { ToolCategory, ToolStatus } from '@/core/infrastructure/ToolDetectorManager';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
const logger = createLogger('controllers:ToolDetectorCtr');
|
||||
|
||||
/**
|
||||
* Tool Detector Controller
|
||||
*
|
||||
* Provides IPC interface for querying tool detection status.
|
||||
* Frontend can use these methods to display tool availability to users.
|
||||
*/
|
||||
export default class ToolDetectorCtr extends ControllerModule {
|
||||
static override readonly groupName = 'toolDetector';
|
||||
|
||||
private get manager() {
|
||||
return this.app.toolDetectorManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a single tool
|
||||
*/
|
||||
@IpcMethod()
|
||||
async detectTool(name: string, force = false): Promise<ToolStatus> {
|
||||
logger.debug(`Detecting tool: ${name}, force: ${force}`);
|
||||
return this.manager.detect(name, force);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect all registered tools
|
||||
*/
|
||||
@IpcMethod()
|
||||
async detectAllTools(force = false): Promise<Record<string, ToolStatus>> {
|
||||
logger.debug(`Detecting all tools, force: ${force}`);
|
||||
const results = await this.manager.detectAll(force);
|
||||
return Object.fromEntries(results);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect all tools in a category
|
||||
*/
|
||||
@IpcMethod()
|
||||
async detectCategory(category: ToolCategory, force = false): Promise<Record<string, ToolStatus>> {
|
||||
logger.debug(`Detecting category: ${category}, force: ${force}`);
|
||||
const results = await this.manager.detectCategory(category, force);
|
||||
return Object.fromEntries(results);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the best available tool in a category
|
||||
*/
|
||||
@IpcMethod()
|
||||
async getBestTool(category: ToolCategory): Promise<string | null> {
|
||||
logger.debug(`Getting best tool for category: ${category}`);
|
||||
return this.manager.getBestTool(category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached status for a tool (no detection)
|
||||
*/
|
||||
@IpcMethod()
|
||||
getToolStatus(name: string): ToolStatus | null {
|
||||
return this.manager.getStatus(name) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cached statuses (no detection)
|
||||
*/
|
||||
@IpcMethod()
|
||||
getAllToolStatus(): Record<string, ToolStatus> {
|
||||
return Object.fromEntries(this.manager.getAllStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear tool status cache
|
||||
*/
|
||||
@IpcMethod()
|
||||
clearToolCache(name?: string): void {
|
||||
this.manager.clearCache(name);
|
||||
logger.debug(`Cleared tool cache${name ? ` for: ${name}` : ''}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of registered tools
|
||||
*/
|
||||
@IpcMethod()
|
||||
getRegisteredTools(): string[] {
|
||||
return this.manager.getRegisteredTools();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all categories
|
||||
*/
|
||||
@IpcMethod()
|
||||
getCategories(): ToolCategory[] {
|
||||
return this.manager.getCategories();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tools in a category with their info
|
||||
*/
|
||||
@IpcMethod()
|
||||
getToolsInCategory(category: ToolCategory): Array<{
|
||||
description?: string;
|
||||
name: string;
|
||||
priority?: number;
|
||||
}> {
|
||||
return this.manager.getToolsInCategory(category).map((detector) => ({
|
||||
description: detector.description,
|
||||
name: detector.name,
|
||||
priority: detector.priority,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,7 @@ vi.mock('node:crypto', () => ({
|
||||
const mockRemoteServerConfigCtr = {
|
||||
clearTokens: vi.fn().mockResolvedValue(undefined),
|
||||
getAccessToken: vi.fn().mockResolvedValue('mock-access-token'),
|
||||
getLastTokenRefreshAt: vi.fn().mockReturnValue(Date.now()),
|
||||
getRemoteServerConfig: vi.fn().mockResolvedValue({ active: true, storageMode: 'cloud' }),
|
||||
getRemoteServerUrl: vi.fn().mockImplementation(async (config?: DataSyncConfig) => {
|
||||
if (config?.storageMode === 'selfHost') {
|
||||
@@ -84,6 +85,8 @@ const mockRemoteServerConfigCtr = {
|
||||
return 'https://lobehub-cloud.com'; // OFFICIAL_CLOUD_SERVER
|
||||
}),
|
||||
getTokenExpiresAt: vi.fn().mockReturnValue(Date.now() + 3600000),
|
||||
isNonRetryableError: vi.fn().mockReturnValue(false),
|
||||
isRemoteServerConfigured: vi.fn().mockResolvedValue(true),
|
||||
isTokenExpiringSoon: vi.fn().mockReturnValue(false),
|
||||
refreshAccessToken: vi.fn().mockResolvedValue({ success: true }),
|
||||
saveTokens: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -711,4 +714,202 @@ describe('AuthCtr', () => {
|
||||
expect(handoffCalls.length).toBeLessThanOrEqual(5);
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
describe('Proactive Token Refresh', () => {
|
||||
const FIVE_MINUTES = 5 * 60 * 1000; // Debounce interval
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks for proactive refresh tests
|
||||
vi.mocked(mockRemoteServerConfigCtr.getRemoteServerConfig).mockResolvedValue({
|
||||
active: true,
|
||||
remoteServerUrl: 'https://lobehub-cloud.com',
|
||||
storageMode: 'cloud',
|
||||
});
|
||||
vi.mocked(mockRemoteServerConfigCtr.isRemoteServerConfigured).mockResolvedValue(true);
|
||||
vi.mocked(mockRemoteServerConfigCtr.getAccessToken).mockResolvedValue('mock-access-token');
|
||||
vi.mocked(mockRemoteServerConfigCtr.getTokenExpiresAt).mockReturnValue(
|
||||
Date.now() + 3600000, // Token valid for 1 hour
|
||||
);
|
||||
// Reset getLastTokenRefreshAt to a recent value by default
|
||||
// Individual tests will override this as needed
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(Date.now());
|
||||
});
|
||||
|
||||
describe('onAppActivate', () => {
|
||||
it('should refresh token when last refresh was more than 5 minutes ago', async () => {
|
||||
// Last refresh was 10 minutes ago (exceeds 5-minute debounce)
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
|
||||
Date.now() - 10 * 60 * 1000,
|
||||
);
|
||||
vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
|
||||
await authCtr.onAppActivate();
|
||||
|
||||
expect(mockRemoteServerConfigCtr.refreshAccessToken).toHaveBeenCalled();
|
||||
expect(mockWindow.webContents.send).toHaveBeenCalledWith('tokenRefreshed');
|
||||
});
|
||||
|
||||
it('should NOT refresh token when last refresh was within 5 minutes', async () => {
|
||||
// Last refresh was 2 minutes ago (within 5-minute debounce)
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
|
||||
Date.now() - 2 * 60 * 1000,
|
||||
);
|
||||
|
||||
await authCtr.onAppActivate();
|
||||
|
||||
expect(mockRemoteServerConfigCtr.refreshAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should refresh token when lastRefreshAt is undefined (never refreshed)', async () => {
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(undefined);
|
||||
vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
|
||||
await authCtr.onAppActivate();
|
||||
|
||||
expect(mockRemoteServerConfigCtr.refreshAccessToken).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip refresh when remote server is not active', async () => {
|
||||
vi.mocked(mockRemoteServerConfigCtr.isRemoteServerConfigured).mockResolvedValue(false);
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
|
||||
Date.now() - 10 * 60 * 1000,
|
||||
);
|
||||
|
||||
await authCtr.onAppActivate();
|
||||
|
||||
expect(mockRemoteServerConfigCtr.refreshAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip refresh when no access token exists', async () => {
|
||||
vi.mocked(mockRemoteServerConfigCtr.getAccessToken).mockResolvedValue(null);
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
|
||||
Date.now() - 10 * 60 * 1000,
|
||||
);
|
||||
|
||||
await authCtr.onAppActivate();
|
||||
|
||||
expect(mockRemoteServerConfigCtr.refreshAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle refresh failure with non-retryable error', async () => {
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
|
||||
Date.now() - 10 * 60 * 1000,
|
||||
);
|
||||
vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({
|
||||
error: 'invalid_grant',
|
||||
success: false,
|
||||
});
|
||||
vi.mocked(mockRemoteServerConfigCtr.isNonRetryableError).mockReturnValue(true);
|
||||
|
||||
await authCtr.onAppActivate();
|
||||
|
||||
expect(mockRemoteServerConfigCtr.clearTokens).toHaveBeenCalled();
|
||||
expect(mockRemoteServerConfigCtr.setRemoteServerConfig).toHaveBeenCalledWith({
|
||||
active: false,
|
||||
});
|
||||
expect(mockWindow.webContents.send).toHaveBeenCalledWith('authorizationRequired');
|
||||
});
|
||||
|
||||
it('should handle refresh failure with transient error (start auto-refresh)', async () => {
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
|
||||
Date.now() - 10 * 60 * 1000,
|
||||
);
|
||||
vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({
|
||||
error: 'network_error',
|
||||
success: false,
|
||||
});
|
||||
vi.mocked(mockRemoteServerConfigCtr.isNonRetryableError).mockReturnValue(false);
|
||||
|
||||
await authCtr.onAppActivate();
|
||||
|
||||
// Should not clear tokens for transient errors
|
||||
expect(mockRemoteServerConfigCtr.clearTokens).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('afterAppReady (initializeAutoRefresh)', () => {
|
||||
it('should proactively refresh token on startup when debounce interval exceeded', async () => {
|
||||
// Last refresh was 10 minutes ago (exceeds 5-minute debounce)
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
|
||||
Date.now() - 10 * 60 * 1000,
|
||||
);
|
||||
vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
|
||||
authCtr.afterAppReady();
|
||||
|
||||
// Wait for async initialization
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(mockRemoteServerConfigCtr.refreshAccessToken).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should NOT refresh on startup when token was recently refreshed (within debounce)', async () => {
|
||||
// Last refresh was 2 minutes ago (within 5-minute debounce)
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
|
||||
Date.now() - 2 * 60 * 1000,
|
||||
);
|
||||
|
||||
authCtr.afterAppReady();
|
||||
|
||||
// Wait for async initialization
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(mockRemoteServerConfigCtr.refreshAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should refresh on startup when token is expired regardless of last refresh time', async () => {
|
||||
// Token expired 1 hour ago
|
||||
vi.mocked(mockRemoteServerConfigCtr.getTokenExpiresAt).mockReturnValue(
|
||||
Date.now() - 60 * 60 * 1000,
|
||||
);
|
||||
// Last refresh was 2 minutes ago (within debounce, but token is expired)
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
|
||||
Date.now() - 2 * 60 * 1000,
|
||||
);
|
||||
vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
|
||||
authCtr.afterAppReady();
|
||||
|
||||
// Wait for async initialization
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(mockRemoteServerConfigCtr.refreshAccessToken).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh debounce boundary tests', () => {
|
||||
it('should NOT refresh at exactly 5 minutes minus 1 second', async () => {
|
||||
// Last refresh was 4 minutes 59 seconds ago
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
|
||||
Date.now() - (FIVE_MINUTES - 1000),
|
||||
);
|
||||
|
||||
await authCtr.onAppActivate();
|
||||
|
||||
expect(mockRemoteServerConfigCtr.refreshAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should refresh at exactly 5 minutes', async () => {
|
||||
// Last refresh was exactly 5 minutes ago
|
||||
vi.mocked(mockRemoteServerConfigCtr.getLastTokenRefreshAt).mockReturnValue(
|
||||
Date.now() - FIVE_MINUTES,
|
||||
);
|
||||
vi.mocked(mockRemoteServerConfigCtr.refreshAccessToken).mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
|
||||
await authCtr.onAppActivate();
|
||||
|
||||
expect(mockRemoteServerConfigCtr.refreshAccessToken).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,11 +34,6 @@ vi.mock('electron', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock fast-glob
|
||||
vi.mock('fast-glob', () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock node:fs/promises and node:fs
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
stat: vi.fn(),
|
||||
@@ -66,6 +61,14 @@ vi.mock('node:fs', () => ({
|
||||
// Mock FileSearchService
|
||||
const mockSearchService = {
|
||||
search: vi.fn(),
|
||||
glob: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock ContentSearchService
|
||||
const mockContentSearchService = {
|
||||
grep: vi.fn(),
|
||||
astGrep: vi.fn(),
|
||||
checkToolAvailable: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock makeSureDirExist
|
||||
@@ -74,13 +77,21 @@ vi.mock('@/utils/file-system', () => ({
|
||||
}));
|
||||
|
||||
const mockApp = {
|
||||
getService: vi.fn(() => mockSearchService),
|
||||
getService: vi.fn((ServiceClass: any) => {
|
||||
// Return different mock based on service class name
|
||||
if (ServiceClass?.name === 'ContentSearchService') {
|
||||
return mockContentSearchService;
|
||||
}
|
||||
return mockSearchService;
|
||||
}),
|
||||
toolDetectorManager: {
|
||||
getBestTool: vi.fn(() => null), // No external tools available, use Node.js fallback
|
||||
},
|
||||
} as unknown as App;
|
||||
|
||||
describe('LocalFileCtr', () => {
|
||||
let localFileCtr: LocalFileCtr;
|
||||
let mockShell: any;
|
||||
let mockFg: any;
|
||||
let mockLoadFile: any;
|
||||
let mockFsPromises: any;
|
||||
|
||||
@@ -89,7 +100,6 @@ describe('LocalFileCtr', () => {
|
||||
|
||||
// Import mocks
|
||||
mockShell = (await import('electron')).shell;
|
||||
mockFg = (await import('fast-glob')).default;
|
||||
mockLoadFile = (await import('@lobechat/file-loaders')).loadFile;
|
||||
mockFsPromises = await import('node:fs/promises');
|
||||
|
||||
@@ -389,11 +399,12 @@ describe('LocalFileCtr', () => {
|
||||
|
||||
describe('handleGlobFiles', () => {
|
||||
it('should glob files successfully', async () => {
|
||||
const mockFiles = [
|
||||
{ path: '/test/file1.txt', stats: { mtime: new Date('2024-01-02') } },
|
||||
{ path: '/test/file2.txt', stats: { mtime: new Date('2024-01-01') } },
|
||||
];
|
||||
vi.mocked(mockFg).mockResolvedValue(mockFiles);
|
||||
const mockResult = {
|
||||
success: true,
|
||||
files: ['/test/file1.txt', '/test/file2.txt'],
|
||||
total_files: 2,
|
||||
};
|
||||
mockSearchService.glob.mockResolvedValue(mockResult);
|
||||
|
||||
const result = await localFileCtr.handleGlobFiles({
|
||||
pattern: '*.txt',
|
||||
@@ -403,10 +414,20 @@ describe('LocalFileCtr', () => {
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.files).toEqual(['/test/file1.txt', '/test/file2.txt']);
|
||||
expect(result.total_files).toBe(2);
|
||||
expect(mockSearchService.glob).toHaveBeenCalledWith({
|
||||
pattern: '*.txt',
|
||||
path: '/test',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle glob error', async () => {
|
||||
vi.mocked(mockFg).mockRejectedValue(new Error('Glob failed'));
|
||||
const mockResult = {
|
||||
success: false,
|
||||
files: [],
|
||||
total_files: 0,
|
||||
error: 'Glob failed',
|
||||
};
|
||||
mockSearchService.glob.mockResolvedValue(mockResult);
|
||||
|
||||
const result = await localFileCtr.handleGlobFiles({
|
||||
pattern: '*.txt',
|
||||
@@ -416,6 +437,7 @@ describe('LocalFileCtr', () => {
|
||||
success: false,
|
||||
files: [],
|
||||
total_files: 0,
|
||||
error: 'Glob failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -554,231 +576,38 @@ describe('LocalFileCtr', () => {
|
||||
});
|
||||
|
||||
describe('handleGrepContent', () => {
|
||||
it('should search content in a single file', async () => {
|
||||
vi.mocked(mockFsPromises.stat).mockResolvedValue({
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
} as any);
|
||||
vi.mocked(mockFsPromises.readFile).mockResolvedValue('Hello world\nTest line\nAnother test');
|
||||
beforeEach(() => {
|
||||
vi.mocked(mockContentSearchService.grep).mockReset();
|
||||
});
|
||||
|
||||
const result = await localFileCtr.handleGrepContent({
|
||||
it('should delegate grep to contentSearchService', async () => {
|
||||
const mockResult = {
|
||||
success: true,
|
||||
matches: ['/test/file.txt'],
|
||||
total_matches: 1,
|
||||
};
|
||||
vi.mocked(mockContentSearchService.grep).mockResolvedValue(mockResult);
|
||||
|
||||
const params = {
|
||||
'pattern': 'test',
|
||||
'path': '/test/file.txt',
|
||||
'-i': true,
|
||||
});
|
||||
};
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.matches).toContain('/test/file.txt');
|
||||
expect(result.total_matches).toBe(1);
|
||||
const result = await localFileCtr.handleGrepContent(params);
|
||||
|
||||
expect(mockContentSearchService.grep).toHaveBeenCalledWith(params);
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
|
||||
it('should search content in directory with default glob pattern', async () => {
|
||||
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
|
||||
if (filePath === '/test') {
|
||||
return { isFile: () => false, isDirectory: () => true } as any;
|
||||
}
|
||||
return { isFile: () => true, isDirectory: () => false } as any;
|
||||
});
|
||||
vi.mocked(mockFg).mockResolvedValue(['/test/file1.txt', '/test/file2.txt']);
|
||||
vi.mocked(mockFsPromises.readFile).mockImplementation(async (filePath) => {
|
||||
if (filePath === '/test/file1.txt') return 'Hello world';
|
||||
if (filePath === '/test/file2.txt') return 'Test content';
|
||||
return '';
|
||||
});
|
||||
|
||||
const result = await localFileCtr.handleGrepContent({
|
||||
pattern: 'Hello',
|
||||
path: '/test',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.matches).toContain('/test/file1.txt');
|
||||
expect(result.total_matches).toBe(1);
|
||||
expect(mockFg).toHaveBeenCalledWith('**/*', expect.objectContaining({ cwd: '/test' }));
|
||||
});
|
||||
|
||||
it('should auto-prefix glob pattern with **/ for non-recursive patterns', async () => {
|
||||
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
|
||||
if (filePath === '/test') {
|
||||
return { isFile: () => false, isDirectory: () => true } as any;
|
||||
}
|
||||
return { isFile: () => true, isDirectory: () => false } as any;
|
||||
});
|
||||
vi.mocked(mockFg).mockResolvedValue(['/test/src/file1.ts', '/test/lib/file2.tsx']);
|
||||
vi.mocked(mockFsPromises.readFile).mockResolvedValue('const test = "hello";');
|
||||
|
||||
const result = await localFileCtr.handleGrepContent({
|
||||
pattern: 'test',
|
||||
path: '/test',
|
||||
glob: '*.{ts,tsx}',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Should auto-prefix *.{ts,tsx} with **/ to make it recursive
|
||||
expect(mockFg).toHaveBeenCalledWith(
|
||||
'**/*.{ts,tsx}',
|
||||
expect.objectContaining({ cwd: '/test' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not modify glob pattern that already contains path separator', async () => {
|
||||
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
|
||||
if (filePath === '/test') {
|
||||
return { isFile: () => false, isDirectory: () => true } as any;
|
||||
}
|
||||
return { isFile: () => true, isDirectory: () => false } as any;
|
||||
});
|
||||
vi.mocked(mockFg).mockResolvedValue(['/test/src/file1.ts']);
|
||||
vi.mocked(mockFsPromises.readFile).mockResolvedValue('const test = "hello";');
|
||||
|
||||
const result = await localFileCtr.handleGrepContent({
|
||||
pattern: 'test',
|
||||
path: '/test',
|
||||
glob: 'src/*.ts',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Should not modify glob pattern that already contains /
|
||||
expect(mockFg).toHaveBeenCalledWith('src/*.ts', expect.objectContaining({ cwd: '/test' }));
|
||||
});
|
||||
|
||||
it('should not modify glob pattern that starts with **', async () => {
|
||||
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
|
||||
if (filePath === '/test') {
|
||||
return { isFile: () => false, isDirectory: () => true } as any;
|
||||
}
|
||||
return { isFile: () => true, isDirectory: () => false } as any;
|
||||
});
|
||||
vi.mocked(mockFg).mockResolvedValue(['/test/src/file1.ts']);
|
||||
vi.mocked(mockFsPromises.readFile).mockResolvedValue('const test = "hello";');
|
||||
|
||||
const result = await localFileCtr.handleGrepContent({
|
||||
pattern: 'test',
|
||||
path: '/test',
|
||||
glob: '**/components/*.tsx',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Should not modify glob pattern that already starts with **
|
||||
expect(mockFg).toHaveBeenCalledWith(
|
||||
'**/components/*.tsx',
|
||||
expect.objectContaining({ cwd: '/test' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by type when provided', async () => {
|
||||
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
|
||||
if (filePath === '/test') {
|
||||
return { isFile: () => false, isDirectory: () => true } as any;
|
||||
}
|
||||
return { isFile: () => true, isDirectory: () => false } as any;
|
||||
});
|
||||
// fast-glob returns all files, then type filter is applied
|
||||
vi.mocked(mockFg).mockResolvedValue(['/test/file1.ts', '/test/file2.js', '/test/file3.ts']);
|
||||
vi.mocked(mockFsPromises.readFile).mockResolvedValue('unique_pattern');
|
||||
|
||||
const result = await localFileCtr.handleGrepContent({
|
||||
pattern: 'unique_pattern',
|
||||
path: '/test',
|
||||
type: 'ts',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Type filter should exclude .js files from being searched
|
||||
// Only .ts files should be in the results
|
||||
expect(result.matches).not.toContain('/test/file2.js');
|
||||
// At least one .ts file should match
|
||||
expect(result.matches.length).toBeGreaterThan(0);
|
||||
expect(result.matches.every((m) => m.endsWith('.ts'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should return content mode with line numbers', async () => {
|
||||
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
|
||||
if (filePath === '/test') {
|
||||
return { isFile: () => false, isDirectory: () => true } as any;
|
||||
}
|
||||
return { isFile: () => true, isDirectory: () => false } as any;
|
||||
});
|
||||
vi.mocked(mockFg).mockResolvedValue(['/test/file.txt']);
|
||||
vi.mocked(mockFsPromises.readFile).mockResolvedValue('line 1\ntest line\nline 3');
|
||||
|
||||
const result = await localFileCtr.handleGrepContent({
|
||||
'pattern': 'test',
|
||||
'path': '/test',
|
||||
'output_mode': 'content',
|
||||
'-n': true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.matches.some((m) => m.includes('2:'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should return count mode', async () => {
|
||||
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
|
||||
if (filePath === '/test') {
|
||||
return { isFile: () => false, isDirectory: () => true } as any;
|
||||
}
|
||||
return { isFile: () => true, isDirectory: () => false } as any;
|
||||
});
|
||||
vi.mocked(mockFg).mockResolvedValue(['/test/file.txt']);
|
||||
vi.mocked(mockFsPromises.readFile).mockResolvedValue('test one\ntest two\ntest three');
|
||||
|
||||
const result = await localFileCtr.handleGrepContent({
|
||||
pattern: 'test',
|
||||
path: '/test',
|
||||
output_mode: 'count',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.matches).toContain('/test/file.txt:3');
|
||||
expect(result.total_matches).toBe(3);
|
||||
});
|
||||
|
||||
it('should respect head_limit', async () => {
|
||||
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
|
||||
if (filePath === '/test') {
|
||||
return { isFile: () => false, isDirectory: () => true } as any;
|
||||
}
|
||||
return { isFile: () => true, isDirectory: () => false } as any;
|
||||
});
|
||||
vi.mocked(mockFg).mockResolvedValue([
|
||||
'/test/file1.txt',
|
||||
'/test/file2.txt',
|
||||
'/test/file3.txt',
|
||||
'/test/file4.txt',
|
||||
'/test/file5.txt',
|
||||
]);
|
||||
vi.mocked(mockFsPromises.readFile).mockResolvedValue('test content');
|
||||
|
||||
const result = await localFileCtr.handleGrepContent({
|
||||
pattern: 'test',
|
||||
path: '/test',
|
||||
head_limit: 2,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.matches.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle case insensitive search', async () => {
|
||||
vi.mocked(mockFsPromises.stat).mockResolvedValue({
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
} as any);
|
||||
vi.mocked(mockFsPromises.readFile).mockResolvedValue('Hello World\nHELLO world\nhello WORLD');
|
||||
|
||||
const result = await localFileCtr.handleGrepContent({
|
||||
'pattern': 'hello',
|
||||
'path': '/test/file.txt',
|
||||
'-i': true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.matches).toContain('/test/file.txt');
|
||||
});
|
||||
|
||||
it('should handle grep error gracefully', async () => {
|
||||
vi.mocked(mockFsPromises.stat).mockRejectedValue(new Error('Path not found'));
|
||||
it('should return error result from contentSearchService', async () => {
|
||||
const mockResult = {
|
||||
success: false,
|
||||
matches: [],
|
||||
total_matches: 0,
|
||||
error: 'Search failed',
|
||||
};
|
||||
vi.mocked(mockContentSearchService.grep).mockResolvedValue(mockResult);
|
||||
|
||||
const result = await localFileCtr.handleGrepContent({
|
||||
pattern: 'test',
|
||||
@@ -786,31 +615,30 @@ describe('LocalFileCtr', () => {
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.matches).toEqual([]);
|
||||
expect(result.total_matches).toBe(0);
|
||||
expect(result.error).toBe('Search failed');
|
||||
});
|
||||
|
||||
it('should skip unreadable files gracefully', async () => {
|
||||
vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => {
|
||||
if (filePath === '/test') {
|
||||
return { isFile: () => false, isDirectory: () => true } as any;
|
||||
}
|
||||
return { isFile: () => true, isDirectory: () => false } as any;
|
||||
});
|
||||
vi.mocked(mockFg).mockResolvedValue(['/test/file1.txt', '/test/file2.txt']);
|
||||
vi.mocked(mockFsPromises.readFile).mockImplementation(async (filePath) => {
|
||||
if (filePath === '/test/file1.txt') throw new Error('Permission denied');
|
||||
return 'test content';
|
||||
});
|
||||
it('should pass all parameters to contentSearchService', async () => {
|
||||
const mockResult = {
|
||||
success: true,
|
||||
matches: ['/test/file.txt:2:test line'],
|
||||
total_matches: 1,
|
||||
};
|
||||
vi.mocked(mockContentSearchService.grep).mockResolvedValue(mockResult);
|
||||
|
||||
const result = await localFileCtr.handleGrepContent({
|
||||
pattern: 'test',
|
||||
path: '/test',
|
||||
});
|
||||
const params = {
|
||||
'pattern': 'test',
|
||||
'path': '/test',
|
||||
'output_mode': 'content' as const,
|
||||
'-n': true,
|
||||
'-i': true,
|
||||
'glob': '*.ts',
|
||||
'head_limit': 10,
|
||||
};
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Should still find match in file2.txt despite file1.txt error
|
||||
expect(result.matches).toContain('/test/file2.txt');
|
||||
await localFileCtr.handleGrepContent(params);
|
||||
|
||||
expect(mockContentSearchService.grep).toHaveBeenCalledWith(params);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -335,10 +335,10 @@ describe('RemoteServerConfigCtr', () => {
|
||||
const { safeStorage } = await import('electron');
|
||||
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
||||
|
||||
// Token expires in 1 hour
|
||||
await controller.saveTokens('access', 'refresh', 3600);
|
||||
// Token expires in 2 days (well beyond the 24-hour default buffer)
|
||||
await controller.saveTokens('access', 'refresh', 2 * 24 * 3600);
|
||||
|
||||
// Default buffer is 5 minutes
|
||||
// Default buffer is 24 hours
|
||||
const result = controller.isTokenExpiringSoon();
|
||||
|
||||
expect(result).toBe(false);
|
||||
@@ -657,6 +657,56 @@ describe('RemoteServerConfigCtr', () => {
|
||||
// Verify tokens were loaded by checking getTokenExpiresAt
|
||||
expect(newController.getTokenExpiresAt()).toBeDefined();
|
||||
});
|
||||
|
||||
it('should load lastRefreshAt from store', () => {
|
||||
const lastRefreshTime = Date.now() - 3600000; // 1 hour ago
|
||||
mockStoreManager.get.mockImplementation((key) => {
|
||||
if (key === 'encryptedTokens') {
|
||||
return {
|
||||
accessToken: 'stored-access',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
lastRefreshAt: lastRefreshTime,
|
||||
refreshToken: 'stored-refresh',
|
||||
};
|
||||
}
|
||||
return { active: false, storageMode: 'cloud' };
|
||||
});
|
||||
|
||||
const newController = new RemoteServerConfigCtr(mockApp);
|
||||
newController.afterAppReady();
|
||||
|
||||
// Verify lastRefreshAt was loaded
|
||||
expect(newController.getLastTokenRefreshAt()).toBe(lastRefreshTime);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLastTokenRefreshAt', () => {
|
||||
it('should return undefined when no tokens have been saved', () => {
|
||||
expect(controller.getLastTokenRefreshAt()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return the last refresh time after saving tokens', async () => {
|
||||
const beforeSave = Date.now();
|
||||
await controller.saveTokens('access', 'refresh', 3600);
|
||||
const afterSave = Date.now();
|
||||
|
||||
const lastRefreshAt = controller.getLastTokenRefreshAt();
|
||||
|
||||
expect(lastRefreshAt).toBeDefined();
|
||||
expect(lastRefreshAt).toBeGreaterThanOrEqual(beforeSave);
|
||||
expect(lastRefreshAt).toBeLessThanOrEqual(afterSave);
|
||||
});
|
||||
|
||||
it('should persist lastRefreshAt to store when saving tokens', async () => {
|
||||
await controller.saveTokens('access', 'refresh', 3600);
|
||||
|
||||
expect(mockStoreManager.set).toHaveBeenCalledWith(
|
||||
'encryptedTokens',
|
||||
expect.objectContaining({
|
||||
lastRefreshAt: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRemoteServerUrl', () => {
|
||||
@@ -695,4 +745,69 @@ describe('RemoteServerConfigCtr', () => {
|
||||
expect(result).toBe('https://custom-server.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRemoteServerConfigured', () => {
|
||||
it('should return true for active cloud mode (no remoteServerUrl needed)', async () => {
|
||||
mockStoreManager.get.mockReturnValue({
|
||||
active: true,
|
||||
storageMode: 'cloud',
|
||||
// remoteServerUrl is undefined for cloud mode
|
||||
});
|
||||
|
||||
const result = await controller.isRemoteServerConfigured();
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for active selfHost mode with remoteServerUrl', async () => {
|
||||
mockStoreManager.get.mockReturnValue({
|
||||
active: true,
|
||||
remoteServerUrl: 'https://my-server.com',
|
||||
storageMode: 'selfHost',
|
||||
});
|
||||
|
||||
const result = await controller.isRemoteServerConfigured();
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for inactive config', async () => {
|
||||
mockStoreManager.get.mockReturnValue({
|
||||
active: false,
|
||||
storageMode: 'cloud',
|
||||
});
|
||||
|
||||
const result = await controller.isRemoteServerConfigured();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for selfHost mode without remoteServerUrl', async () => {
|
||||
mockStoreManager.get.mockReturnValue({
|
||||
active: true,
|
||||
storageMode: 'selfHost',
|
||||
// remoteServerUrl is undefined
|
||||
});
|
||||
|
||||
const result = await controller.isRemoteServerConfigured();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should use provided config instead of fetching', async () => {
|
||||
// Store has inactive config
|
||||
mockStoreManager.get.mockReturnValue({
|
||||
active: false,
|
||||
storageMode: 'cloud',
|
||||
});
|
||||
|
||||
// But we provide an active config
|
||||
const result = await controller.isRemoteServerConfigured({
|
||||
active: true,
|
||||
storageMode: 'cloud',
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import RemoteServerSyncCtr from './RemoteServerSyncCtr';
|
||||
import ShellCommandCtr from './ShellCommandCtr';
|
||||
import ShortcutController from './ShortcutCtr';
|
||||
import SystemController from './SystemCtr';
|
||||
import ToolDetectorCtr from './ToolDetectorCtr';
|
||||
import TrayMenuCtr from './TrayMenuCtr';
|
||||
import UpdaterCtr from './UpdaterCtr';
|
||||
import UploadFileCtr from './UploadFileCtr';
|
||||
@@ -33,6 +34,7 @@ export const controllerIpcConstructors = [
|
||||
ShellCommandCtr,
|
||||
ShortcutController,
|
||||
SystemController,
|
||||
ToolDetectorCtr,
|
||||
TrayMenuCtr,
|
||||
UpdaterCtr,
|
||||
UploadFileCtr,
|
||||
|
||||
@@ -10,6 +10,12 @@ import { buildDir } from '@/const/dir';
|
||||
import { isDev } from '@/const/env';
|
||||
import { ELECTRON_BE_PROTOCOL_SCHEME } from '@/const/protocol';
|
||||
import { IControlModule } from '@/controllers';
|
||||
import AuthCtr from '@/controllers/AuthCtr';
|
||||
import {
|
||||
astSearchDetectors,
|
||||
contentSearchDetectors,
|
||||
fileSearchDetectors,
|
||||
} from '@/modules/toolDetectors';
|
||||
import { IServiceModule } from '@/services';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
@@ -20,6 +26,7 @@ import { ProtocolManager } from './infrastructure/ProtocolManager';
|
||||
import { RendererUrlManager } from './infrastructure/RendererUrlManager';
|
||||
import { StaticFileServerManager } from './infrastructure/StaticFileServerManager';
|
||||
import { StoreManager } from './infrastructure/StoreManager';
|
||||
import { ToolDetectorManager } from './infrastructure/ToolDetectorManager';
|
||||
import { UpdaterManager } from './infrastructure/UpdaterManager';
|
||||
import { MenuManager } from './ui/MenuManager';
|
||||
import { ShortcutManager } from './ui/ShortcutManager';
|
||||
@@ -46,6 +53,7 @@ export class App {
|
||||
staticFileServerManager: StaticFileServerManager;
|
||||
protocolManager: ProtocolManager;
|
||||
rendererUrlManager: RendererUrlManager;
|
||||
toolDetectorManager: ToolDetectorManager;
|
||||
chromeFlags: string[] = ['OverlayScrollbar', 'FluentOverlayScrollbar', 'FluentScrollbar'];
|
||||
|
||||
/**
|
||||
@@ -119,6 +127,10 @@ export class App {
|
||||
this.trayManager = new TrayManager(this);
|
||||
this.staticFileServerManager = new StaticFileServerManager(this);
|
||||
this.protocolManager = new ProtocolManager(this);
|
||||
this.toolDetectorManager = new ToolDetectorManager(this);
|
||||
|
||||
// Register built-in tool detectors
|
||||
this.registerBuiltinToolDetectors();
|
||||
|
||||
// Configure renderer loading strategy (dev server vs static export)
|
||||
// should register before app ready
|
||||
@@ -158,6 +170,32 @@ export class App {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register built-in tool detectors for content search and file search
|
||||
*/
|
||||
private registerBuiltinToolDetectors() {
|
||||
logger.debug('Registering built-in tool detectors');
|
||||
|
||||
// Register content search tools (rg, ag, grep)
|
||||
for (const detector of contentSearchDetectors) {
|
||||
this.toolDetectorManager.register(detector, 'content-search');
|
||||
}
|
||||
|
||||
// Register AST-based code search tools (ast-grep)
|
||||
for (const detector of astSearchDetectors) {
|
||||
this.toolDetectorManager.register(detector, 'ast-search');
|
||||
}
|
||||
|
||||
// Register file search tools (mdfind, fd, find)
|
||||
for (const detector of fileSearchDetectors) {
|
||||
this.toolDetectorManager.register(detector, 'file-search');
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Registered ${this.toolDetectorManager.getRegisteredTools().length} tool detectors`,
|
||||
);
|
||||
}
|
||||
|
||||
bootstrap = async () => {
|
||||
logger.info('Bootstrapping application');
|
||||
// make single instance
|
||||
@@ -251,6 +289,14 @@ export class App {
|
||||
private onActivate = () => {
|
||||
logger.debug('Application activated');
|
||||
this.browserManager.showMainWindow();
|
||||
|
||||
// Trigger proactive token refresh on app activation (respects 6-hour interval)
|
||||
const authCtr = this.getController(AuthCtr);
|
||||
if (authCtr) {
|
||||
authCtr.onAppActivate().catch((error) => {
|
||||
logger.error('Error during app activation token refresh:', error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import { exec } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import { App } from '@/core/App';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
const execPromise = promisify(exec);
|
||||
const logger = createLogger('core:ToolDetectorManager');
|
||||
|
||||
/**
|
||||
* Tool detection status
|
||||
*/
|
||||
export interface ToolStatus {
|
||||
available: boolean;
|
||||
error?: string;
|
||||
lastChecked?: Date;
|
||||
path?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool detector interface - modules implement this to register detection logic
|
||||
*/
|
||||
export interface IToolDetector {
|
||||
/** Description */
|
||||
description?: string;
|
||||
/** Detection method */
|
||||
detect(): Promise<ToolStatus>;
|
||||
/** Tool name, e.g., 'rg', 'mdfind' */
|
||||
name: string;
|
||||
/** Priority within category, lower number = higher priority */
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool categories
|
||||
*/
|
||||
export type ToolCategory = 'content-search' | 'ast-search' | 'file-search' | 'system' | 'custom';
|
||||
|
||||
/**
|
||||
* Tool Detector Manager
|
||||
*
|
||||
* A plugin-style manager for detecting system tools availability.
|
||||
* Modules can register their own detectors and query tool status.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Register a detector
|
||||
* manager.register({
|
||||
* name: 'rg',
|
||||
* description: 'ripgrep',
|
||||
* priority: 1,
|
||||
* async detect() { ... }
|
||||
* }, 'content-search');
|
||||
*
|
||||
* // Query status
|
||||
* const status = await manager.detect('rg');
|
||||
* const bestTool = await manager.getBestTool('content-search');
|
||||
* ```
|
||||
*/
|
||||
export class ToolDetectorManager {
|
||||
private app: App;
|
||||
private detectors = new Map<string, IToolDetector>();
|
||||
private statusCache = new Map<string, ToolStatus>();
|
||||
private categoryMap = new Map<ToolCategory, Set<string>>();
|
||||
private initialized = false;
|
||||
|
||||
constructor(app: App) {
|
||||
logger.debug('Initializing ToolDetectorManager');
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a tool detector
|
||||
* @param detector The detector to register
|
||||
* @param category Tool category for grouping
|
||||
*/
|
||||
register(detector: IToolDetector, category: ToolCategory = 'custom'): void {
|
||||
const { name } = detector;
|
||||
|
||||
if (this.detectors.has(name)) {
|
||||
logger.warn(`Detector for '${name}' already registered, overwriting`);
|
||||
}
|
||||
|
||||
this.detectors.set(name, detector);
|
||||
|
||||
// Add to category
|
||||
if (!this.categoryMap.has(category)) {
|
||||
this.categoryMap.set(category, new Set());
|
||||
}
|
||||
this.categoryMap.get(category)!.add(name);
|
||||
|
||||
logger.debug(
|
||||
`Registered detector: ${name} (category: ${category}, priority: ${detector.priority ?? 'default'})`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a tool detector
|
||||
* @param name Tool name to unregister
|
||||
*/
|
||||
unregister(name: string): boolean {
|
||||
if (!this.detectors.has(name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.detectors.delete(name);
|
||||
this.statusCache.delete(name);
|
||||
|
||||
// Remove from category
|
||||
for (const tools of this.categoryMap.values()) {
|
||||
tools.delete(name);
|
||||
}
|
||||
|
||||
logger.debug(`Unregistered detector: ${name}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a single tool
|
||||
* @param name Tool name
|
||||
* @param force Force detection, bypass cache
|
||||
*/
|
||||
async detect(name: string, force = false): Promise<ToolStatus> {
|
||||
const detector = this.detectors.get(name);
|
||||
if (!detector) {
|
||||
return {
|
||||
available: false,
|
||||
error: `No detector registered for '${name}'`,
|
||||
};
|
||||
}
|
||||
|
||||
// Return cached result if available and not forced
|
||||
if (!force && this.statusCache.has(name)) {
|
||||
return this.statusCache.get(name)!;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug(`Detecting tool: ${name}`);
|
||||
const status = await detector.detect();
|
||||
status.lastChecked = new Date();
|
||||
this.statusCache.set(name, status);
|
||||
|
||||
logger.debug(`Tool ${name} detection result:`, {
|
||||
available: status.available,
|
||||
path: status.path,
|
||||
version: status.version,
|
||||
});
|
||||
|
||||
return status;
|
||||
} catch (error) {
|
||||
const status: ToolStatus = {
|
||||
available: false,
|
||||
error: (error as Error).message,
|
||||
lastChecked: new Date(),
|
||||
};
|
||||
this.statusCache.set(name, status);
|
||||
logger.error(`Error detecting tool ${name}:`, error);
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect all registered tools
|
||||
* @param force Force detection, bypass cache
|
||||
*/
|
||||
async detectAll(force = false): Promise<Map<string, ToolStatus>> {
|
||||
const results = new Map<string, ToolStatus>();
|
||||
|
||||
await Promise.all(
|
||||
Array.from(this.detectors.keys()).map(async (name) => {
|
||||
const status = await this.detect(name, force);
|
||||
results.set(name, status);
|
||||
}),
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect all tools in a category
|
||||
* @param category Tool category
|
||||
* @param force Force detection, bypass cache
|
||||
*/
|
||||
async detectCategory(category: ToolCategory, force = false): Promise<Map<string, ToolStatus>> {
|
||||
const tools = this.categoryMap.get(category);
|
||||
if (!tools) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const results = new Map<string, ToolStatus>();
|
||||
|
||||
await Promise.all(
|
||||
Array.from(tools).map(async (name) => {
|
||||
const status = await this.detect(name, force);
|
||||
results.set(name, status);
|
||||
}),
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached status for a tool
|
||||
* @param name Tool name
|
||||
*/
|
||||
getStatus(name: string): ToolStatus | undefined {
|
||||
return this.statusCache.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cached statuses
|
||||
*/
|
||||
getAllStatus(): Map<string, ToolStatus> {
|
||||
return new Map(this.statusCache);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the best available tool in a category
|
||||
* Returns the first available tool sorted by priority
|
||||
* @param category Tool category
|
||||
*/
|
||||
async getBestTool(category: ToolCategory): Promise<string | null> {
|
||||
const tools = this.categoryMap.get(category);
|
||||
if (!tools || tools.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get detectors and sort by priority
|
||||
const sortedDetectors = Array.from(tools)
|
||||
.map((name) => this.detectors.get(name)!)
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100));
|
||||
|
||||
// Find first available tool
|
||||
for (const detector of sortedDetectors) {
|
||||
const status = await this.detect(detector.name);
|
||||
if (status.available) {
|
||||
return detector.name;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tools in a category, sorted by priority
|
||||
* @param category Tool category
|
||||
*/
|
||||
getToolsInCategory(category: ToolCategory): IToolDetector[] {
|
||||
const tools = this.categoryMap.get(category);
|
||||
if (!tools) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(tools)
|
||||
.map((name) => this.detectors.get(name)!)
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear status cache
|
||||
* @param name Optional tool name; if not provided, clears all
|
||||
*/
|
||||
clearCache(name?: string): void {
|
||||
if (name) {
|
||||
this.statusCache.delete(name);
|
||||
logger.debug(`Cleared cache for: ${name}`);
|
||||
} else {
|
||||
this.statusCache.clear();
|
||||
logger.debug('Cleared all cache');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered tool names
|
||||
*/
|
||||
getRegisteredTools(): string[] {
|
||||
return Array.from(this.detectors.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all categories
|
||||
*/
|
||||
getCategories(): ToolCategory[] {
|
||||
return Array.from(this.categoryMap.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool is registered
|
||||
*/
|
||||
isRegistered(name: string): boolean {
|
||||
return this.detectors.has(name);
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Helper: Create a command-based detector
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Create a simple command-based detector
|
||||
* Useful for common tools that follow standard patterns
|
||||
*/
|
||||
export function createCommandDetector(
|
||||
name: string,
|
||||
options: {
|
||||
description?: string;
|
||||
priority?: number;
|
||||
versionFlag?: string;
|
||||
whichCommand?: string;
|
||||
} = {},
|
||||
): IToolDetector {
|
||||
const { description, priority, versionFlag = '--version', whichCommand } = options;
|
||||
|
||||
return {
|
||||
description,
|
||||
async detect(): Promise<ToolStatus> {
|
||||
try {
|
||||
// Check if tool exists
|
||||
const whichCmd = whichCommand || (process.platform === 'win32' ? 'where' : 'which');
|
||||
const { stdout: pathOut } = await execPromise(`${whichCmd} ${name}`, { timeout: 3000 });
|
||||
const toolPath = pathOut.trim().split('\n')[0];
|
||||
|
||||
// Try to get version
|
||||
let version: string | undefined;
|
||||
try {
|
||||
const { stdout: versionOut } = await execPromise(`${name} ${versionFlag}`, {
|
||||
timeout: 3000,
|
||||
});
|
||||
version = versionOut.trim().split('\n')[0];
|
||||
} catch {
|
||||
// Some tools don't support version flag
|
||||
}
|
||||
|
||||
return {
|
||||
available: true,
|
||||
path: toolPath,
|
||||
version,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
available: false,
|
||||
};
|
||||
}
|
||||
},
|
||||
name,
|
||||
priority,
|
||||
};
|
||||
}
|
||||
@@ -41,6 +41,10 @@ const menu = {
|
||||
'edit.stopSpeaking': 'Stop Speaking',
|
||||
'edit.title': 'Edit',
|
||||
'edit.undo': 'Undo',
|
||||
'file.newAgent': 'New Agent',
|
||||
'file.newAgentGroup': 'New Agent Group',
|
||||
'file.newPage': 'New Page',
|
||||
'file.newTopic': 'New Topic',
|
||||
'file.preferences': 'Preferences',
|
||||
'file.quit': 'Quit',
|
||||
'file.title': 'File',
|
||||
|
||||
@@ -55,6 +55,44 @@ export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||
{
|
||||
label: t('file.title'),
|
||||
submenu: [
|
||||
{
|
||||
accelerator: 'Ctrl+N',
|
||||
click: () => {
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
mainWindow.show();
|
||||
mainWindow.broadcast('createNewTopic');
|
||||
},
|
||||
label: t('file.newTopic'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
accelerator: 'Alt+Ctrl+A',
|
||||
click: () => {
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
mainWindow.show();
|
||||
mainWindow.broadcast('createNewAgent');
|
||||
},
|
||||
label: t('file.newAgent'),
|
||||
},
|
||||
{
|
||||
accelerator: 'Alt+Ctrl+G',
|
||||
click: () => {
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
mainWindow.show();
|
||||
mainWindow.broadcast('createNewAgentGroup');
|
||||
},
|
||||
label: t('file.newAgentGroup'),
|
||||
},
|
||||
{
|
||||
accelerator: 'Alt+Ctrl+P',
|
||||
click: () => {
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
mainWindow.show();
|
||||
mainWindow.broadcast('createNewPage');
|
||||
},
|
||||
label: t('file.newPage'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
click: () => this.app.browserManager.retrieveByIdentifier('settings').show(),
|
||||
label: t('file.preferences'),
|
||||
|
||||
@@ -126,6 +126,44 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||
{
|
||||
label: t('file.title'),
|
||||
submenu: [
|
||||
{
|
||||
accelerator: 'Command+N',
|
||||
click: () => {
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
mainWindow.show();
|
||||
mainWindow.broadcast('createNewTopic');
|
||||
},
|
||||
label: t('file.newTopic'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
accelerator: 'Alt+Command+A',
|
||||
click: () => {
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
mainWindow.show();
|
||||
mainWindow.broadcast('createNewAgent');
|
||||
},
|
||||
label: t('file.newAgent'),
|
||||
},
|
||||
{
|
||||
accelerator: 'Alt+Command+G',
|
||||
click: () => {
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
mainWindow.show();
|
||||
mainWindow.broadcast('createNewAgentGroup');
|
||||
},
|
||||
label: t('file.newAgentGroup'),
|
||||
},
|
||||
{
|
||||
accelerator: 'Alt+Command+P',
|
||||
click: () => {
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
mainWindow.show();
|
||||
mainWindow.broadcast('createNewPage');
|
||||
},
|
||||
label: t('file.newPage'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
accelerator: 'Command+W',
|
||||
label: t('window.close'),
|
||||
|
||||
@@ -54,6 +54,44 @@ export class WindowsMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||
{
|
||||
label: t('file.title'),
|
||||
submenu: [
|
||||
{
|
||||
accelerator: 'Ctrl+N',
|
||||
click: () => {
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
mainWindow.show();
|
||||
mainWindow.broadcast('createNewTopic');
|
||||
},
|
||||
label: t('file.newTopic'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
accelerator: 'Alt+Ctrl+A',
|
||||
click: () => {
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
mainWindow.show();
|
||||
mainWindow.broadcast('createNewAgent');
|
||||
},
|
||||
label: t('file.newAgent'),
|
||||
},
|
||||
{
|
||||
accelerator: 'Alt+Ctrl+G',
|
||||
click: () => {
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
mainWindow.show();
|
||||
mainWindow.broadcast('createNewAgentGroup');
|
||||
},
|
||||
label: t('file.newAgentGroup'),
|
||||
},
|
||||
{
|
||||
accelerator: 'Alt+Ctrl+P',
|
||||
click: () => {
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
mainWindow.show();
|
||||
mainWindow.broadcast('createNewPage');
|
||||
},
|
||||
label: t('file.newPage'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
click: () => this.app.browserManager.retrieveByIdentifier('settings').show(),
|
||||
label: t('file.preferences'),
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import { GrepContentParams, GrepContentResult } from '@lobechat/electron-client-ipc';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { BaseContentSearch } from '../base';
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock fast-glob
|
||||
vi.mock('fast-glob', () => ({
|
||||
default: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
readFile: vi.fn().mockResolvedValue(''),
|
||||
stat: vi.fn().mockResolvedValue({
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
}),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Concrete implementation for testing
|
||||
*/
|
||||
class TestContentSearch extends BaseContentSearch {
|
||||
public currentTool: string | null = null;
|
||||
|
||||
async grep(params: GrepContentParams): Promise<GrepContentResult> {
|
||||
return this.grepWithNodejs(params);
|
||||
}
|
||||
|
||||
async checkToolAvailable(tool: string): Promise<boolean> {
|
||||
return tool === 'nodejs';
|
||||
}
|
||||
|
||||
// Expose protected methods for testing
|
||||
public testBuildGrepArgs(tool: 'rg' | 'ag' | 'grep', params: GrepContentParams): string[] {
|
||||
return this.buildGrepArgs(tool, params);
|
||||
}
|
||||
|
||||
public testGetDefaultIgnorePatterns(): string[] {
|
||||
return this.getDefaultIgnorePatterns();
|
||||
}
|
||||
}
|
||||
|
||||
describe('BaseContentSearch', () => {
|
||||
let contentSearch: TestContentSearch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
contentSearch = new TestContentSearch();
|
||||
});
|
||||
|
||||
describe('buildGrepArgs', () => {
|
||||
describe('ripgrep (rg)', () => {
|
||||
it('should build basic rg args for files_with_matches mode', () => {
|
||||
const params: GrepContentParams = {
|
||||
pattern: 'test',
|
||||
output_mode: 'files_with_matches',
|
||||
};
|
||||
|
||||
const args = contentSearch.testBuildGrepArgs('rg', params);
|
||||
|
||||
expect(args).toContain('-l');
|
||||
expect(args).toContain('test');
|
||||
expect(args).toContain('--glob');
|
||||
expect(args).toContain('!**/node_modules/**');
|
||||
expect(args).toContain('!**/.git/**');
|
||||
});
|
||||
|
||||
it('should build rg args with case insensitive flag', () => {
|
||||
const params: GrepContentParams = {
|
||||
'-i': true,
|
||||
'pattern': 'test',
|
||||
};
|
||||
|
||||
const args = contentSearch.testBuildGrepArgs('rg', params);
|
||||
|
||||
expect(args).toContain('-i');
|
||||
});
|
||||
|
||||
it('should build rg args with line numbers', () => {
|
||||
const params: GrepContentParams = {
|
||||
'-n': true,
|
||||
'pattern': 'test',
|
||||
};
|
||||
|
||||
const args = contentSearch.testBuildGrepArgs('rg', params);
|
||||
|
||||
expect(args).toContain('-n');
|
||||
});
|
||||
|
||||
it('should build rg args with context lines', () => {
|
||||
const params: GrepContentParams = {
|
||||
'-A': 3,
|
||||
'-B': 2,
|
||||
'-C': 1,
|
||||
'pattern': 'test',
|
||||
};
|
||||
|
||||
const args = contentSearch.testBuildGrepArgs('rg', params);
|
||||
|
||||
expect(args).toContain('-A');
|
||||
expect(args).toContain('3');
|
||||
expect(args).toContain('-B');
|
||||
expect(args).toContain('2');
|
||||
expect(args).toContain('-C');
|
||||
expect(args).toContain('1');
|
||||
});
|
||||
|
||||
it('should build rg args with multiline flag', () => {
|
||||
const params: GrepContentParams = {
|
||||
multiline: true,
|
||||
pattern: 'test',
|
||||
};
|
||||
|
||||
const args = contentSearch.testBuildGrepArgs('rg', params);
|
||||
|
||||
expect(args).toContain('-U');
|
||||
});
|
||||
|
||||
it('should build rg args with glob filter', () => {
|
||||
const params: GrepContentParams = {
|
||||
glob: '*.ts',
|
||||
pattern: 'test',
|
||||
};
|
||||
|
||||
const args = contentSearch.testBuildGrepArgs('rg', params);
|
||||
|
||||
expect(args).toContain('-g');
|
||||
expect(args).toContain('*.ts');
|
||||
});
|
||||
|
||||
it('should build rg args with type filter', () => {
|
||||
const params: GrepContentParams = {
|
||||
pattern: 'test',
|
||||
type: 'ts',
|
||||
};
|
||||
|
||||
const args = contentSearch.testBuildGrepArgs('rg', params);
|
||||
|
||||
expect(args).toContain('-t');
|
||||
expect(args).toContain('ts');
|
||||
});
|
||||
|
||||
it('should build rg args for count mode', () => {
|
||||
const params: GrepContentParams = {
|
||||
output_mode: 'count',
|
||||
pattern: 'test',
|
||||
};
|
||||
|
||||
const args = contentSearch.testBuildGrepArgs('rg', params);
|
||||
|
||||
expect(args).toContain('-c');
|
||||
});
|
||||
|
||||
it('should build rg args for content mode', () => {
|
||||
const params: GrepContentParams = {
|
||||
output_mode: 'content',
|
||||
pattern: 'test',
|
||||
};
|
||||
|
||||
const args = contentSearch.testBuildGrepArgs('rg', params);
|
||||
|
||||
expect(args).not.toContain('-l');
|
||||
expect(args).not.toContain('-c');
|
||||
});
|
||||
});
|
||||
|
||||
describe('silver searcher (ag)', () => {
|
||||
it('should build basic ag args for files_with_matches mode', () => {
|
||||
const params: GrepContentParams = {
|
||||
output_mode: 'files_with_matches',
|
||||
pattern: 'test',
|
||||
};
|
||||
|
||||
const args = contentSearch.testBuildGrepArgs('ag', params);
|
||||
|
||||
expect(args).toContain('-l');
|
||||
expect(args).toContain('--ignore-dir');
|
||||
expect(args).toContain('node_modules');
|
||||
});
|
||||
|
||||
it('should build ag args with glob filter', () => {
|
||||
const params: GrepContentParams = {
|
||||
glob: '*.tsx',
|
||||
pattern: 'test',
|
||||
};
|
||||
|
||||
const args = contentSearch.testBuildGrepArgs('ag', params);
|
||||
|
||||
expect(args).toContain('-G');
|
||||
expect(args).toContain('*.tsx');
|
||||
});
|
||||
|
||||
it('should build ag args for count mode', () => {
|
||||
const params: GrepContentParams = {
|
||||
output_mode: 'count',
|
||||
pattern: 'test',
|
||||
};
|
||||
|
||||
const args = contentSearch.testBuildGrepArgs('ag', params);
|
||||
|
||||
expect(args).toContain('-c');
|
||||
});
|
||||
});
|
||||
|
||||
describe('grep', () => {
|
||||
it('should build basic grep args for files_with_matches mode', () => {
|
||||
const params: GrepContentParams = {
|
||||
output_mode: 'files_with_matches',
|
||||
pattern: 'test',
|
||||
};
|
||||
|
||||
const args = contentSearch.testBuildGrepArgs('grep', params);
|
||||
|
||||
expect(args).toContain('-r');
|
||||
expect(args).toContain('-l');
|
||||
expect(args).toContain('-E');
|
||||
expect(args).toContain('--exclude-dir');
|
||||
expect(args).toContain('node_modules');
|
||||
});
|
||||
|
||||
it('should build grep args with include filter', () => {
|
||||
const params: GrepContentParams = {
|
||||
glob: '*.js',
|
||||
pattern: 'test',
|
||||
};
|
||||
|
||||
const args = contentSearch.testBuildGrepArgs('grep', params);
|
||||
|
||||
expect(args).toContain('--include');
|
||||
expect(args).toContain('*.js');
|
||||
});
|
||||
|
||||
it('should build grep args with type filter', () => {
|
||||
const params: GrepContentParams = {
|
||||
pattern: 'test',
|
||||
type: 'py',
|
||||
};
|
||||
|
||||
const args = contentSearch.testBuildGrepArgs('grep', params);
|
||||
|
||||
expect(args).toContain('--include');
|
||||
expect(args).toContain('*.py');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDefaultIgnorePatterns', () => {
|
||||
it('should return default ignore patterns', () => {
|
||||
const patterns = contentSearch.testGetDefaultIgnorePatterns();
|
||||
|
||||
expect(patterns).toContain('**/node_modules/**');
|
||||
expect(patterns).toContain('**/.git/**');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkToolAvailable', () => {
|
||||
it('should return true for nodejs', async () => {
|
||||
const available = await contentSearch.checkToolAvailable('nodejs');
|
||||
|
||||
expect(available).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for other tools', async () => {
|
||||
const available = await contentSearch.checkToolAvailable('rg');
|
||||
|
||||
expect(available).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setToolDetectorManager', () => {
|
||||
it('should set the tool detector manager', () => {
|
||||
const mockManager = {} as any;
|
||||
|
||||
contentSearch.setToolDetectorManager(mockManager);
|
||||
|
||||
expect((contentSearch as any).toolDetectorManager).toBe(mockManager);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import * as os from 'node:os';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { LinuxContentSearchImpl } from '../impl/linux';
|
||||
import { MacOSContentSearchImpl } from '../impl/macOS';
|
||||
import { WindowsContentSearchImpl } from '../impl/windows';
|
||||
import { createContentSearchImpl } from '../index';
|
||||
|
||||
// Mock os module before imports
|
||||
vi.mock('node:os', () => ({
|
||||
platform: vi.fn().mockReturnValue('linux'),
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('createContentSearchImpl', () => {
|
||||
it('should create MacOSContentSearchImpl on darwin', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
|
||||
const impl = createContentSearchImpl();
|
||||
|
||||
expect(impl).toBeInstanceOf(MacOSContentSearchImpl);
|
||||
});
|
||||
|
||||
it('should create WindowsContentSearchImpl on win32', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('win32');
|
||||
|
||||
const impl = createContentSearchImpl();
|
||||
|
||||
expect(impl).toBeInstanceOf(WindowsContentSearchImpl);
|
||||
});
|
||||
|
||||
it('should create LinuxContentSearchImpl on linux', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
|
||||
const impl = createContentSearchImpl();
|
||||
|
||||
expect(impl).toBeInstanceOf(LinuxContentSearchImpl);
|
||||
});
|
||||
|
||||
it('should create LinuxContentSearchImpl on unknown platform', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('freebsd' as any);
|
||||
|
||||
const impl = createContentSearchImpl();
|
||||
|
||||
expect(impl).toBeInstanceOf(LinuxContentSearchImpl);
|
||||
});
|
||||
|
||||
it('should pass toolDetectorManager to implementation', () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
const mockManager = {} as any;
|
||||
|
||||
const impl = createContentSearchImpl(mockManager);
|
||||
|
||||
expect((impl as any).toolDetectorManager).toBe(mockManager);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
import { GrepContentParams, GrepContentResult } from '@lobechat/electron-client-ipc';
|
||||
import fg from 'fast-glob';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
|
||||
import { ToolDetectorManager } from '@/core/infrastructure/ToolDetectorManager';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
const logger = createLogger('module:ContentSearch:base');
|
||||
|
||||
/**
|
||||
* Content search tool type
|
||||
*/
|
||||
export type ContentSearchTool = 'rg' | 'ag' | 'grep' | 'nodejs';
|
||||
|
||||
/**
|
||||
* Content Search Service Implementation Abstract Class
|
||||
* Defines the interface that different platform content search implementations need to implement
|
||||
*/
|
||||
export abstract class BaseContentSearch {
|
||||
protected toolDetectorManager?: ToolDetectorManager;
|
||||
|
||||
constructor(toolDetectorManager?: ToolDetectorManager) {
|
||||
this.toolDetectorManager = toolDetectorManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the tool detector manager
|
||||
* @param manager ToolDetectorManager instance
|
||||
*/
|
||||
setToolDetectorManager(manager: ToolDetectorManager): void {
|
||||
this.toolDetectorManager = manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform content search (grep)
|
||||
* @param params Grep parameters
|
||||
* @returns Promise of grep result
|
||||
*/
|
||||
abstract grep(params: GrepContentParams): Promise<GrepContentResult>;
|
||||
|
||||
/**
|
||||
* Check if a specific tool is available
|
||||
* @param tool Tool name to check
|
||||
* @returns Promise indicating if tool is available
|
||||
*/
|
||||
abstract checkToolAvailable(tool: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Build command-line arguments for grep tools
|
||||
*/
|
||||
protected buildGrepArgs(tool: 'rg' | 'ag' | 'grep', params: GrepContentParams): string[] {
|
||||
const { pattern, output_mode = 'files_with_matches' } = params;
|
||||
const args: string[] = [];
|
||||
|
||||
switch (tool) {
|
||||
case 'rg': {
|
||||
// ripgrep arguments
|
||||
if (params['-i']) args.push('-i');
|
||||
if (params['-n']) args.push('-n');
|
||||
if (params['-A']) args.push('-A', String(params['-A']));
|
||||
if (params['-B']) args.push('-B', String(params['-B']));
|
||||
if (params['-C']) args.push('-C', String(params['-C']));
|
||||
if (params.multiline) args.push('-U');
|
||||
if (params.glob) args.push('-g', params.glob);
|
||||
if (params.type) args.push('-t', params.type);
|
||||
|
||||
// Output mode
|
||||
switch (output_mode) {
|
||||
case 'files_with_matches': {
|
||||
args.push('-l');
|
||||
break;
|
||||
}
|
||||
case 'count': {
|
||||
args.push('-c');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Ignore common directories (use **/ prefix to match nested paths)
|
||||
args.push('--glob', '!**/node_modules/**', '--glob', '!**/.git/**', pattern, '.');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ag': {
|
||||
// Silver Searcher arguments
|
||||
if (params['-i']) args.push('-i');
|
||||
if (params['-A']) args.push('-A', String(params['-A']));
|
||||
if (params['-B']) args.push('-B', String(params['-B']));
|
||||
if (params['-C']) args.push('-C', String(params['-C']));
|
||||
if (params.glob) args.push('-G', params.glob);
|
||||
|
||||
// Output mode
|
||||
switch (output_mode) {
|
||||
case 'files_with_matches': {
|
||||
args.push('-l');
|
||||
break;
|
||||
}
|
||||
case 'count': {
|
||||
args.push('-c');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
args.push('--ignore-dir', 'node_modules', '--ignore-dir', '.git', pattern, '.');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'grep': {
|
||||
// GNU grep arguments
|
||||
args.push('-r'); // recursive
|
||||
if (params['-i']) args.push('-i');
|
||||
if (params['-n']) args.push('-n');
|
||||
if (params['-A']) args.push('-A', String(params['-A']));
|
||||
if (params['-B']) args.push('-B', String(params['-B']));
|
||||
if (params['-C']) args.push('-C', String(params['-C']));
|
||||
if (params.glob) args.push('--include', params.glob);
|
||||
if (params.type) args.push('--include', `*.${params.type}`);
|
||||
|
||||
// Output mode
|
||||
switch (output_mode) {
|
||||
case 'files_with_matches': {
|
||||
args.push('-l');
|
||||
break;
|
||||
}
|
||||
case 'count': {
|
||||
args.push('-c');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
args.push('--exclude-dir', 'node_modules', '--exclude-dir', '.git', '-E', pattern, '.');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grep using Node.js native implementation (fallback)
|
||||
*/
|
||||
protected async grepWithNodejs(params: GrepContentParams): Promise<GrepContentResult> {
|
||||
const {
|
||||
pattern,
|
||||
path: searchPath = process.cwd(),
|
||||
output_mode = 'files_with_matches',
|
||||
} = params;
|
||||
const logPrefix = `[grepContent:nodejs]`;
|
||||
|
||||
const flags = `${params['-i'] ? 'i' : ''}${params.multiline ? 's' : ''}`;
|
||||
const regex = new RegExp(pattern, flags);
|
||||
|
||||
// Determine files to search
|
||||
let filesToSearch: string[] = [];
|
||||
const stats = await stat(searchPath);
|
||||
|
||||
if (stats.isFile()) {
|
||||
filesToSearch = [searchPath];
|
||||
} else {
|
||||
// Use glob pattern if provided, otherwise search all files
|
||||
let globPattern = params.glob || '**/*';
|
||||
if (params.glob && !params.glob.includes('/') && !params.glob.startsWith('**')) {
|
||||
globPattern = `**/${params.glob}`;
|
||||
}
|
||||
|
||||
filesToSearch = await fg(globPattern, {
|
||||
absolute: true,
|
||||
cwd: searchPath,
|
||||
dot: true,
|
||||
ignore: this.getDefaultIgnorePatterns(),
|
||||
});
|
||||
|
||||
// Filter by type if provided
|
||||
if (params.type) {
|
||||
const ext = `.${params.type}`;
|
||||
filesToSearch = filesToSearch.filter((file) => file.endsWith(ext));
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`${logPrefix} Found ${filesToSearch.length} files to search`);
|
||||
|
||||
const matches: string[] = [];
|
||||
let totalMatches = 0;
|
||||
|
||||
for (const filePath of filesToSearch) {
|
||||
try {
|
||||
const fileStats = await stat(filePath);
|
||||
if (!fileStats.isFile()) continue;
|
||||
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
switch (output_mode) {
|
||||
case 'files_with_matches': {
|
||||
if (regex.test(content)) {
|
||||
matches.push(filePath);
|
||||
totalMatches++;
|
||||
if (params.head_limit && matches.length >= params.head_limit) break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'content': {
|
||||
const matchedLines: string[] = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (regex.test(lines[i])) {
|
||||
const contextBefore = params['-B'] || params['-C'] || 0;
|
||||
const contextAfter = params['-A'] || params['-C'] || 0;
|
||||
|
||||
const startLine = Math.max(0, i - contextBefore);
|
||||
const endLine = Math.min(lines.length - 1, i + contextAfter);
|
||||
|
||||
for (let j = startLine; j <= endLine; j++) {
|
||||
const lineNum = params['-n'] ? `${j + 1}:` : '';
|
||||
matchedLines.push(`${filePath}:${lineNum}${lines[j]}`);
|
||||
}
|
||||
totalMatches++;
|
||||
}
|
||||
}
|
||||
matches.push(...matchedLines);
|
||||
if (params.head_limit && matches.length >= params.head_limit) break;
|
||||
break;
|
||||
}
|
||||
case 'count': {
|
||||
const globalRegex = new RegExp(pattern, `g${flags}`);
|
||||
const fileMatches = (content.match(globalRegex) || []).length;
|
||||
if (fileMatches > 0) {
|
||||
matches.push(`${filePath}:${fileMatches}`);
|
||||
totalMatches += fileMatches;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`${logPrefix} Skipping file ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`${logPrefix} Search completed`, {
|
||||
matchCount: matches.length,
|
||||
totalMatches,
|
||||
});
|
||||
|
||||
return {
|
||||
engine: 'nodejs',
|
||||
matches: params.head_limit ? matches.slice(0, params.head_limit) : matches,
|
||||
success: true,
|
||||
total_matches: totalMatches,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default ignore patterns
|
||||
* Can be overridden by subclasses for platform-specific patterns
|
||||
*/
|
||||
protected getDefaultIgnorePatterns(): string[] {
|
||||
return ['**/node_modules/**', '**/.git/**'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ToolDetectorManager } from '@/core/infrastructure/ToolDetectorManager';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { UnixContentSearch } from './unix';
|
||||
|
||||
const logger = createLogger('module:ContentSearch:linux');
|
||||
|
||||
/**
|
||||
* Linux content search implementation
|
||||
* Inherits from UnixContentSearch with Linux-specific optimizations
|
||||
*/
|
||||
export class LinuxContentSearchImpl extends UnixContentSearch {
|
||||
constructor(toolDetectorManager?: ToolDetectorManager) {
|
||||
super(toolDetectorManager);
|
||||
logger.debug('LinuxContentSearchImpl initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Linux-specific ignore patterns
|
||||
*/
|
||||
protected override getDefaultIgnorePatterns(): string[] {
|
||||
return [...super.getDefaultIgnorePatterns(), '**/.cache/**', '**/snap/**'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ToolDetectorManager } from '@/core/infrastructure/ToolDetectorManager';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { UnixContentSearch } from './unix';
|
||||
|
||||
const logger = createLogger('module:ContentSearch:macOS');
|
||||
|
||||
/**
|
||||
* macOS content search implementation
|
||||
* Inherits from UnixContentSearch with macOS-specific optimizations
|
||||
*/
|
||||
export class MacOSContentSearchImpl extends UnixContentSearch {
|
||||
constructor(toolDetectorManager?: ToolDetectorManager) {
|
||||
super(toolDetectorManager);
|
||||
logger.debug('MacOSContentSearchImpl initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get macOS-specific ignore patterns
|
||||
* Includes Library/Caches which is specific to macOS
|
||||
*/
|
||||
protected override getDefaultIgnorePatterns(): string[] {
|
||||
return [
|
||||
...super.getDefaultIgnorePatterns(),
|
||||
'**/Library/Caches/**',
|
||||
'**/.cache/**',
|
||||
'**/snap/**',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
/* eslint-disable unicorn/no-array-push-push */
|
||||
import { GrepContentParams, GrepContentResult } from '@lobechat/electron-client-ipc';
|
||||
import { execa } from 'execa';
|
||||
|
||||
import { ToolDetectorManager } from '@/core/infrastructure/ToolDetectorManager';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { BaseContentSearch } from '../base';
|
||||
|
||||
const logger = createLogger('module:ContentSearch:unix');
|
||||
|
||||
/**
|
||||
* Unix content search tool type
|
||||
* Priority: rg (1) > ag (2) > grep (3)
|
||||
*/
|
||||
export type UnixContentSearchTool = 'rg' | 'ag' | 'grep' | 'nodejs';
|
||||
|
||||
/**
|
||||
* Unix content search base class
|
||||
* Provides common search implementations for macOS and Linux
|
||||
*/
|
||||
export abstract class UnixContentSearch extends BaseContentSearch {
|
||||
/**
|
||||
* Current tool being used
|
||||
*/
|
||||
protected currentTool: UnixContentSearchTool | null = null;
|
||||
|
||||
constructor(toolDetectorManager?: ToolDetectorManager) {
|
||||
super(toolDetectorManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool is available using 'which' command
|
||||
* @param tool Tool name to check
|
||||
* @returns Promise indicating if tool is available
|
||||
*/
|
||||
async checkToolAvailable(tool: string): Promise<boolean> {
|
||||
try {
|
||||
await execa('which', [tool], { timeout: 3000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the best available Unix tool based on priority
|
||||
* Priority: rg > ag > grep > nodejs
|
||||
* @returns The best available tool
|
||||
*/
|
||||
protected async determineBestUnixTool(): Promise<UnixContentSearchTool> {
|
||||
if (this.toolDetectorManager) {
|
||||
const bestTool = await this.toolDetectorManager.getBestTool('content-search');
|
||||
if (bestTool && ['rg', 'ag', 'grep'].includes(bestTool)) {
|
||||
return bestTool as UnixContentSearchTool;
|
||||
}
|
||||
}
|
||||
|
||||
if (await this.checkToolAvailable('rg')) {
|
||||
return 'rg';
|
||||
}
|
||||
|
||||
if (await this.checkToolAvailable('ag')) {
|
||||
return 'ag';
|
||||
}
|
||||
|
||||
if (await this.checkToolAvailable('grep')) {
|
||||
return 'grep';
|
||||
}
|
||||
|
||||
return 'nodejs';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback to the next available tool
|
||||
* @param currentTool Current tool that failed
|
||||
* @returns Next tool to try
|
||||
*/
|
||||
protected async fallbackToNextTool(
|
||||
currentTool: UnixContentSearchTool,
|
||||
): Promise<UnixContentSearchTool> {
|
||||
const priority: UnixContentSearchTool[] = ['rg', 'ag', 'grep', 'nodejs'];
|
||||
const currentIndex = priority.indexOf(currentTool);
|
||||
|
||||
for (let i = currentIndex + 1; i < priority.length; i++) {
|
||||
const nextTool = priority[i];
|
||||
if (nextTool === 'nodejs') {
|
||||
return 'nodejs'; // Always available
|
||||
}
|
||||
if (await this.checkToolAvailable(nextTool)) {
|
||||
return nextTool;
|
||||
}
|
||||
}
|
||||
|
||||
return 'nodejs';
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform content search (grep)
|
||||
*/
|
||||
async grep(params: GrepContentParams): Promise<GrepContentResult> {
|
||||
const { tool: preferredTool } = params;
|
||||
const logPrefix = `[grepContent: ${params.pattern}]`;
|
||||
|
||||
try {
|
||||
// If user specified a grep tool, try to use it
|
||||
if (preferredTool && ['rg', 'ag', 'grep'].includes(preferredTool)) {
|
||||
logger.debug(`${logPrefix} Using preferred tool: ${preferredTool}`);
|
||||
return this.grepWithTool(preferredTool as UnixContentSearchTool, params);
|
||||
}
|
||||
|
||||
// Determine the best available tool on first search
|
||||
if (this.currentTool === null) {
|
||||
this.currentTool = await this.determineBestUnixTool();
|
||||
logger.info(`Using content search tool: ${this.currentTool}`);
|
||||
}
|
||||
|
||||
return this.grepWithTool(this.currentTool, params);
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Grep failed:`, error);
|
||||
return {
|
||||
engine: this.currentTool || 'nodejs',
|
||||
error: (error as Error).message,
|
||||
matches: [],
|
||||
success: false,
|
||||
total_matches: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search using the specified tool
|
||||
*/
|
||||
protected async grepWithTool(
|
||||
tool: UnixContentSearchTool,
|
||||
params: GrepContentParams,
|
||||
): Promise<GrepContentResult> {
|
||||
switch (tool) {
|
||||
case 'rg': {
|
||||
return this.grepWithRipgrep(params);
|
||||
}
|
||||
case 'ag': {
|
||||
return this.grepWithAg(params);
|
||||
}
|
||||
case 'grep': {
|
||||
return this.grepWithGrep(params);
|
||||
}
|
||||
default: {
|
||||
return this.grepWithNodejs(params);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Grep using ripgrep (rg)
|
||||
*/
|
||||
protected async grepWithRipgrep(params: GrepContentParams): Promise<GrepContentResult> {
|
||||
return this.grepWithExternalTool('rg', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grep using The Silver Searcher (ag)
|
||||
*/
|
||||
protected async grepWithAg(params: GrepContentParams): Promise<GrepContentResult> {
|
||||
return this.grepWithExternalTool('ag', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grep using GNU grep
|
||||
*/
|
||||
protected async grepWithGrep(params: GrepContentParams): Promise<GrepContentResult> {
|
||||
return this.grepWithExternalTool('grep', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grep using external tools (rg, ag, grep)
|
||||
*/
|
||||
protected async grepWithExternalTool(
|
||||
tool: 'rg' | 'ag' | 'grep',
|
||||
params: GrepContentParams,
|
||||
): Promise<GrepContentResult> {
|
||||
const { path: searchPath = process.cwd(), output_mode = 'files_with_matches' } = params;
|
||||
const logPrefix = `[grepContent:${tool}]`;
|
||||
|
||||
try {
|
||||
const args = this.buildGrepArgs(tool, params);
|
||||
logger.debug(`${logPrefix} Executing: ${tool} ${args.join(' ')}`);
|
||||
|
||||
const { stdout, stderr, exitCode } = await execa(tool, args, {
|
||||
cwd: searchPath,
|
||||
reject: false, // Don't throw on non-zero exit code
|
||||
});
|
||||
|
||||
// ripgrep returns 1 when no matches found, which is not an error
|
||||
if (exitCode !== 0 && exitCode !== 1 && stderr) {
|
||||
logger.warn(`${logPrefix} Tool exited with code ${exitCode}: ${stderr}`);
|
||||
}
|
||||
|
||||
const lines = stdout.trim().split('\n').filter(Boolean);
|
||||
let matches: string[] = [];
|
||||
let totalMatches = 0;
|
||||
|
||||
switch (output_mode) {
|
||||
case 'files_with_matches': {
|
||||
matches = lines;
|
||||
totalMatches = lines.length;
|
||||
break;
|
||||
}
|
||||
case 'content': {
|
||||
matches = lines;
|
||||
// When context lines are used, lines.length includes context lines
|
||||
// We need to get the actual match count separately
|
||||
const hasContext = params['-A'] || params['-B'] || params['-C'];
|
||||
if (hasContext) {
|
||||
// Run a separate count query to get accurate match count
|
||||
totalMatches = await this.getActualMatchCount(tool, params);
|
||||
} else {
|
||||
totalMatches = lines.length;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'count': {
|
||||
// Parse count output (file:count format)
|
||||
for (const line of lines) {
|
||||
const match = line.match(/:(\d+)$/);
|
||||
if (match) {
|
||||
totalMatches += parseInt(match[1], 10);
|
||||
}
|
||||
}
|
||||
matches = lines;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply head_limit
|
||||
if (params.head_limit && matches.length > params.head_limit) {
|
||||
matches = matches.slice(0, params.head_limit);
|
||||
}
|
||||
|
||||
logger.info(`${logPrefix} Search completed`, {
|
||||
matchCount: matches.length,
|
||||
totalMatches,
|
||||
});
|
||||
|
||||
return {
|
||||
engine: tool,
|
||||
matches,
|
||||
success: true,
|
||||
total_matches: totalMatches,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(`${logPrefix} External tool failed, falling back to next tool:`, error);
|
||||
// Fallback to next tool
|
||||
this.currentTool = await this.fallbackToNextTool(tool as UnixContentSearchTool);
|
||||
logger.info(`Falling back to: ${this.currentTool}`);
|
||||
return this.grepWithTool(this.currentTool, params);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get actual match count for content mode when context lines are used
|
||||
*/
|
||||
protected async getActualMatchCount(
|
||||
tool: 'rg' | 'ag' | 'grep',
|
||||
params: GrepContentParams,
|
||||
): Promise<number> {
|
||||
const countParams = { ...params, '-A': undefined, '-B': undefined, '-C': undefined };
|
||||
const args = this.buildGrepArgs(tool, {
|
||||
...countParams,
|
||||
output_mode: 'count',
|
||||
} as GrepContentParams);
|
||||
|
||||
try {
|
||||
const { stdout } = await execa(tool, args, {
|
||||
cwd: params.path || process.cwd(),
|
||||
reject: false,
|
||||
});
|
||||
|
||||
let total = 0;
|
||||
for (const line of stdout.trim().split('\n').filter(Boolean)) {
|
||||
const match = line.match(/:(\d+)$/);
|
||||
if (match) {
|
||||
total += parseInt(match[1], 10);
|
||||
}
|
||||
}
|
||||
return total;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
/* eslint-disable unicorn/no-array-push-push */
|
||||
import { GrepContentParams, GrepContentResult } from '@lobechat/electron-client-ipc';
|
||||
import { execa } from 'execa';
|
||||
|
||||
import { ToolDetectorManager } from '@/core/infrastructure/ToolDetectorManager';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { BaseContentSearch } from '../base';
|
||||
|
||||
const logger = createLogger('module:ContentSearch:windows');
|
||||
|
||||
/**
|
||||
* Windows content search tool type
|
||||
* Priority: rg > findstr/powershell > nodejs
|
||||
*/
|
||||
type WindowsContentSearchTool = 'rg' | 'findstr' | 'nodejs';
|
||||
|
||||
/**
|
||||
* Windows content search implementation
|
||||
* Uses rg > findstr > nodejs fallback strategy
|
||||
*/
|
||||
export class WindowsContentSearchImpl extends BaseContentSearch {
|
||||
/**
|
||||
* Current tool being used
|
||||
*/
|
||||
private currentTool: WindowsContentSearchTool | null = null;
|
||||
|
||||
constructor(toolDetectorManager?: ToolDetectorManager) {
|
||||
super(toolDetectorManager);
|
||||
logger.debug('WindowsContentSearchImpl initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool is available using 'where' command (Windows equivalent of 'which')
|
||||
* @param tool Tool name to check
|
||||
* @returns Promise indicating if tool is available
|
||||
*/
|
||||
async checkToolAvailable(tool: string): Promise<boolean> {
|
||||
try {
|
||||
await execa('where', [tool], { timeout: 3000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the best available tool based on priority
|
||||
* Priority: rg > findstr > nodejs
|
||||
*/
|
||||
private async determineBestTool(): Promise<WindowsContentSearchTool> {
|
||||
if (this.toolDetectorManager) {
|
||||
const bestTool = await this.toolDetectorManager.getBestTool('content-search');
|
||||
if (bestTool === 'rg') {
|
||||
return 'rg';
|
||||
}
|
||||
}
|
||||
|
||||
if (await this.checkToolAvailable('rg')) {
|
||||
return 'rg';
|
||||
}
|
||||
|
||||
// findstr is always available on Windows
|
||||
return 'findstr';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback to the next available tool
|
||||
*/
|
||||
private async fallbackToNextTool(
|
||||
currentTool: WindowsContentSearchTool,
|
||||
): Promise<WindowsContentSearchTool> {
|
||||
const priority: WindowsContentSearchTool[] = ['rg', 'findstr', 'nodejs'];
|
||||
const currentIndex = priority.indexOf(currentTool);
|
||||
|
||||
for (let i = currentIndex + 1; i < priority.length; i++) {
|
||||
const nextTool = priority[i];
|
||||
if (nextTool === 'nodejs' || nextTool === 'findstr') {
|
||||
return nextTool; // Always available
|
||||
}
|
||||
if (await this.checkToolAvailable(nextTool)) {
|
||||
return nextTool;
|
||||
}
|
||||
}
|
||||
|
||||
return 'nodejs';
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform content search (grep)
|
||||
*/
|
||||
async grep(params: GrepContentParams): Promise<GrepContentResult> {
|
||||
const { tool: preferredTool } = params;
|
||||
const logPrefix = `[grepContent: ${params.pattern}]`;
|
||||
|
||||
try {
|
||||
// If user specified ripgrep, try to use it
|
||||
if (preferredTool === 'rg') {
|
||||
if (await this.checkToolAvailable('rg')) {
|
||||
logger.debug(`${logPrefix} Using preferred tool: rg`);
|
||||
return this.grepWithRipgrep(params);
|
||||
}
|
||||
logger.warn(`${logPrefix} ripgrep (rg) not available, falling back to other tools`);
|
||||
}
|
||||
|
||||
// Determine the best available tool on first search
|
||||
if (this.currentTool === null) {
|
||||
this.currentTool = await this.determineBestTool();
|
||||
logger.info(`Using content search tool: ${this.currentTool}`);
|
||||
}
|
||||
|
||||
return this.grepWithTool(this.currentTool, params);
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Grep failed:`, error);
|
||||
return {
|
||||
engine: this.currentTool || 'nodejs',
|
||||
error: (error as Error).message,
|
||||
matches: [],
|
||||
success: false,
|
||||
total_matches: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search using the specified tool
|
||||
*/
|
||||
private async grepWithTool(
|
||||
tool: WindowsContentSearchTool,
|
||||
params: GrepContentParams,
|
||||
): Promise<GrepContentResult> {
|
||||
switch (tool) {
|
||||
case 'rg': {
|
||||
return this.grepWithRipgrep(params);
|
||||
}
|
||||
case 'findstr': {
|
||||
return this.grepWithFindstr(params);
|
||||
}
|
||||
default: {
|
||||
return this.grepWithNodejs(params);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Grep using ripgrep (rg) - cross-platform
|
||||
*/
|
||||
private async grepWithRipgrep(params: GrepContentParams): Promise<GrepContentResult> {
|
||||
const { path: searchPath = process.cwd(), output_mode = 'files_with_matches' } = params;
|
||||
const logPrefix = `[grepContent:rg]`;
|
||||
|
||||
try {
|
||||
const args = this.buildGrepArgs('rg', params);
|
||||
logger.debug(`${logPrefix} Executing: rg ${args.join(' ')}`);
|
||||
|
||||
const { stdout, stderr, exitCode } = await execa('rg', args, {
|
||||
cwd: searchPath,
|
||||
reject: false,
|
||||
});
|
||||
|
||||
// ripgrep returns 1 when no matches found, which is not an error
|
||||
if (exitCode !== 0 && exitCode !== 1 && stderr) {
|
||||
logger.warn(`${logPrefix} rg exited with code ${exitCode}: ${stderr}`);
|
||||
}
|
||||
|
||||
const lines = stdout.trim().split('\n').filter(Boolean);
|
||||
let matches: string[] = [];
|
||||
let totalMatches = 0;
|
||||
|
||||
switch (output_mode) {
|
||||
case 'files_with_matches': {
|
||||
matches = lines;
|
||||
totalMatches = lines.length;
|
||||
break;
|
||||
}
|
||||
case 'content': {
|
||||
matches = lines;
|
||||
const hasContext = params['-A'] || params['-B'] || params['-C'];
|
||||
if (hasContext) {
|
||||
totalMatches = await this.getActualMatchCount(params);
|
||||
} else {
|
||||
totalMatches = lines.length;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'count': {
|
||||
for (const line of lines) {
|
||||
const match = line.match(/:(\d+)$/);
|
||||
if (match) {
|
||||
totalMatches += parseInt(match[1], 10);
|
||||
}
|
||||
}
|
||||
matches = lines;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply head_limit
|
||||
if (params.head_limit && matches.length > params.head_limit) {
|
||||
matches = matches.slice(0, params.head_limit);
|
||||
}
|
||||
|
||||
logger.info(`${logPrefix} Search completed`, {
|
||||
matchCount: matches.length,
|
||||
totalMatches,
|
||||
});
|
||||
|
||||
return {
|
||||
engine: 'rg',
|
||||
matches,
|
||||
success: true,
|
||||
total_matches: totalMatches,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(`${logPrefix} rg failed, falling back to findstr:`, error);
|
||||
this.currentTool = await this.fallbackToNextTool('rg');
|
||||
return this.grepWithTool(this.currentTool, params);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get actual match count using ripgrep
|
||||
*/
|
||||
private async getActualMatchCount(params: GrepContentParams): Promise<number> {
|
||||
const countParams = { ...params, '-A': undefined, '-B': undefined, '-C': undefined };
|
||||
const args = this.buildGrepArgs('rg', {
|
||||
...countParams,
|
||||
output_mode: 'count',
|
||||
} as GrepContentParams);
|
||||
|
||||
try {
|
||||
const { stdout } = await execa('rg', args, {
|
||||
cwd: params.path || process.cwd(),
|
||||
reject: false,
|
||||
});
|
||||
|
||||
let total = 0;
|
||||
for (const line of stdout.trim().split('\n').filter(Boolean)) {
|
||||
const match = line.match(/:(\d+)$/);
|
||||
if (match) {
|
||||
total += parseInt(match[1], 10);
|
||||
}
|
||||
}
|
||||
return total;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Grep using Windows findstr command
|
||||
* Note: findstr has limited functionality compared to ripgrep
|
||||
*/
|
||||
private async grepWithFindstr(params: GrepContentParams): Promise<GrepContentResult> {
|
||||
const {
|
||||
pattern,
|
||||
path: searchPath = process.cwd(),
|
||||
output_mode = 'files_with_matches',
|
||||
} = params;
|
||||
const logPrefix = `[grepContent:findstr]`;
|
||||
|
||||
try {
|
||||
const args: string[] = ['/S']; // Recursive search
|
||||
|
||||
if (params['-i']) {
|
||||
args.push('/I'); // Case insensitive
|
||||
}
|
||||
|
||||
if (params['-n']) {
|
||||
args.push('/N'); // Line numbers
|
||||
}
|
||||
|
||||
// Pattern
|
||||
args.push('/R'); // Regex
|
||||
args.push(`"${pattern}"`);
|
||||
|
||||
// Search files pattern
|
||||
const filePattern = params.glob || params.type ? `*.${params.type || '*'}` : '*.*';
|
||||
args.push(filePattern);
|
||||
|
||||
logger.debug(`${logPrefix} Executing: findstr ${args.join(' ')}`);
|
||||
|
||||
const { stdout, exitCode } = await execa('cmd', ['/c', `findstr ${args.join(' ')}`], {
|
||||
cwd: searchPath,
|
||||
reject: false,
|
||||
});
|
||||
|
||||
// findstr returns 1 when no matches found
|
||||
if (exitCode !== 0 && exitCode !== 1) {
|
||||
logger.warn(`${logPrefix} findstr exited with code ${exitCode}`);
|
||||
}
|
||||
|
||||
const lines = stdout.trim().split('\r\n').filter(Boolean);
|
||||
let matches: string[] = [];
|
||||
let totalMatches = 0;
|
||||
|
||||
switch (output_mode) {
|
||||
case 'files_with_matches': {
|
||||
// Extract unique file names from output
|
||||
const files = new Set<string>();
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^([^:]+):/);
|
||||
if (match) {
|
||||
files.add(match[1]);
|
||||
}
|
||||
}
|
||||
matches = Array.from(files);
|
||||
totalMatches = matches.length;
|
||||
break;
|
||||
}
|
||||
case 'content': {
|
||||
matches = lines;
|
||||
totalMatches = lines.length;
|
||||
break;
|
||||
}
|
||||
case 'count': {
|
||||
// Count matches per file
|
||||
const fileCounts = new Map<string, number>();
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^([^:]+):/);
|
||||
if (match) {
|
||||
fileCounts.set(match[1], (fileCounts.get(match[1]) || 0) + 1);
|
||||
}
|
||||
}
|
||||
matches = Array.from(fileCounts.entries()).map(([file, count]) => `${file}:${count}`);
|
||||
totalMatches = lines.length;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply head_limit
|
||||
if (params.head_limit && matches.length > params.head_limit) {
|
||||
matches = matches.slice(0, params.head_limit);
|
||||
}
|
||||
|
||||
logger.info(`${logPrefix} Search completed`, {
|
||||
matchCount: matches.length,
|
||||
totalMatches,
|
||||
});
|
||||
|
||||
return {
|
||||
engine: 'findstr',
|
||||
matches,
|
||||
success: true,
|
||||
total_matches: totalMatches,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(`${logPrefix} findstr failed, falling back to Node.js:`, error);
|
||||
this.currentTool = 'nodejs';
|
||||
return this.grepWithNodejs(params);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Windows-specific ignore patterns
|
||||
*/
|
||||
protected override getDefaultIgnorePatterns(): string[] {
|
||||
return [
|
||||
...super.getDefaultIgnorePatterns(),
|
||||
'**/AppData/Local/Temp/**',
|
||||
'**/AppData/Local/Microsoft/**',
|
||||
'**/$Recycle.Bin/**',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as os from 'node:os';
|
||||
|
||||
import { ToolDetectorManager } from '@/core/infrastructure/ToolDetectorManager';
|
||||
|
||||
import { BaseContentSearch } from './base';
|
||||
import { LinuxContentSearchImpl } from './impl/linux';
|
||||
import { MacOSContentSearchImpl } from './impl/macOS';
|
||||
import { WindowsContentSearchImpl } from './impl/windows';
|
||||
|
||||
export { BaseContentSearch } from './base';
|
||||
export { LinuxContentSearchImpl } from './impl/linux';
|
||||
export { MacOSContentSearchImpl } from './impl/macOS';
|
||||
export { UnixContentSearch } from './impl/unix';
|
||||
export { WindowsContentSearchImpl } from './impl/windows';
|
||||
|
||||
/**
|
||||
* Create platform-specific content search implementation
|
||||
* @param toolDetectorManager Optional tool detector manager
|
||||
* @returns Platform-specific content search implementation
|
||||
*/
|
||||
export function createContentSearchImpl(
|
||||
toolDetectorManager?: ToolDetectorManager,
|
||||
): BaseContentSearch {
|
||||
const platform = os.platform();
|
||||
|
||||
switch (platform) {
|
||||
case 'darwin': {
|
||||
return new MacOSContentSearchImpl(toolDetectorManager);
|
||||
}
|
||||
case 'win32': {
|
||||
return new WindowsContentSearchImpl(toolDetectorManager);
|
||||
}
|
||||
default: {
|
||||
// Linux and other Unix-like systems
|
||||
return new LinuxContentSearchImpl(toolDetectorManager);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import { GlobFilesParams, GlobFilesResult } from '@lobechat/electron-client-ipc';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { BaseFileSearch } from '../base';
|
||||
import { FileResult, SearchOptions } from '../types';
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
stat: vi.fn().mockResolvedValue({
|
||||
atime: new Date('2024-01-03'),
|
||||
birthtime: new Date('2024-01-01'),
|
||||
isDirectory: () => false,
|
||||
mtime: new Date('2024-01-02'),
|
||||
size: 1024,
|
||||
}),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Concrete implementation for testing
|
||||
*/
|
||||
class TestFileSearch extends BaseFileSearch {
|
||||
async search(options: SearchOptions): Promise<FileResult[]> {
|
||||
const files = ['/test/file.ts'];
|
||||
return this.processFilePaths(files, options, 'test-engine');
|
||||
}
|
||||
|
||||
async glob(_params: GlobFilesParams): Promise<GlobFilesResult> {
|
||||
return {
|
||||
engine: 'test-engine',
|
||||
files: [],
|
||||
success: true,
|
||||
total_files: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async checkSearchServiceStatus(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async updateSearchIndex(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Expose protected methods for testing
|
||||
public testDetermineContentType(ext: string): string {
|
||||
return this.determineContentType(ext);
|
||||
}
|
||||
|
||||
public testEscapeGlobPattern(pattern: string): string {
|
||||
return this.escapeGlobPattern(pattern);
|
||||
}
|
||||
|
||||
public testProcessFilePaths(
|
||||
filePaths: string[],
|
||||
options: SearchOptions,
|
||||
engine?: string,
|
||||
): Promise<FileResult[]> {
|
||||
return this.processFilePaths(filePaths, options, engine);
|
||||
}
|
||||
|
||||
public testSortResults(
|
||||
results: FileResult[],
|
||||
sortBy?: 'name' | 'date' | 'size',
|
||||
direction?: 'asc' | 'desc',
|
||||
): FileResult[] {
|
||||
return this.sortResults(results, sortBy, direction);
|
||||
}
|
||||
}
|
||||
|
||||
describe('BaseFileSearch', () => {
|
||||
let fileSearch: TestFileSearch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
fileSearch = new TestFileSearch();
|
||||
});
|
||||
|
||||
describe('determineContentType', () => {
|
||||
it('should return archive for zip extension', () => {
|
||||
expect(fileSearch.testDetermineContentType('zip')).toBe('archive');
|
||||
expect(fileSearch.testDetermineContentType('tar')).toBe('archive');
|
||||
expect(fileSearch.testDetermineContentType('gz')).toBe('archive');
|
||||
});
|
||||
|
||||
it('should return audio for audio extensions', () => {
|
||||
expect(fileSearch.testDetermineContentType('mp3')).toBe('audio');
|
||||
expect(fileSearch.testDetermineContentType('wav')).toBe('audio');
|
||||
expect(fileSearch.testDetermineContentType('ogg')).toBe('audio');
|
||||
});
|
||||
|
||||
it('should return video for video extensions', () => {
|
||||
expect(fileSearch.testDetermineContentType('mp4')).toBe('video');
|
||||
expect(fileSearch.testDetermineContentType('avi')).toBe('video');
|
||||
expect(fileSearch.testDetermineContentType('mkv')).toBe('video');
|
||||
});
|
||||
|
||||
it('should return image for image extensions', () => {
|
||||
expect(fileSearch.testDetermineContentType('png')).toBe('image');
|
||||
expect(fileSearch.testDetermineContentType('jpg')).toBe('image');
|
||||
expect(fileSearch.testDetermineContentType('gif')).toBe('image');
|
||||
});
|
||||
|
||||
it('should return document for document extensions', () => {
|
||||
expect(fileSearch.testDetermineContentType('pdf')).toBe('document');
|
||||
expect(fileSearch.testDetermineContentType('doc')).toBe('document');
|
||||
expect(fileSearch.testDetermineContentType('docx')).toBe('document');
|
||||
});
|
||||
|
||||
it('should return code for code extensions', () => {
|
||||
expect(fileSearch.testDetermineContentType('ts')).toBe('code');
|
||||
expect(fileSearch.testDetermineContentType('js')).toBe('code');
|
||||
expect(fileSearch.testDetermineContentType('py')).toBe('code');
|
||||
});
|
||||
|
||||
it('should return unknown for unrecognized extensions', () => {
|
||||
expect(fileSearch.testDetermineContentType('xyz')).toBe('unknown');
|
||||
expect(fileSearch.testDetermineContentType('foo')).toBe('unknown');
|
||||
});
|
||||
|
||||
it('should be case insensitive', () => {
|
||||
expect(fileSearch.testDetermineContentType('PNG')).toBe('image');
|
||||
expect(fileSearch.testDetermineContentType('MP3')).toBe('audio');
|
||||
});
|
||||
});
|
||||
|
||||
describe('escapeGlobPattern', () => {
|
||||
it('should escape special glob characters', () => {
|
||||
// The function escapes . as well since it's a regex special character
|
||||
expect(fileSearch.testEscapeGlobPattern('file*.ts')).toBe('file\\*\\.ts');
|
||||
expect(fileSearch.testEscapeGlobPattern('file?.ts')).toBe('file\\?\\.ts');
|
||||
expect(fileSearch.testEscapeGlobPattern('file[0-9].ts')).toBe('file\\[0-9\\]\\.ts');
|
||||
});
|
||||
|
||||
it('should escape parentheses', () => {
|
||||
expect(fileSearch.testEscapeGlobPattern('file(1).ts')).toBe('file\\(1\\)\\.ts');
|
||||
});
|
||||
|
||||
it('should escape curly braces', () => {
|
||||
expect(fileSearch.testEscapeGlobPattern('file{a,b}.ts')).toBe('file\\{a,b\\}\\.ts');
|
||||
});
|
||||
|
||||
it('should escape backslashes', () => {
|
||||
expect(fileSearch.testEscapeGlobPattern('path\\file.ts')).toBe('path\\\\file\\.ts');
|
||||
});
|
||||
|
||||
it('should escape dots', () => {
|
||||
expect(fileSearch.testEscapeGlobPattern('normal-file.ts')).toBe('normal-file\\.ts');
|
||||
});
|
||||
|
||||
it('should return unchanged string if no special characters', () => {
|
||||
expect(fileSearch.testEscapeGlobPattern('normal-file-ts')).toBe('normal-file-ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('processFilePaths', () => {
|
||||
it('should process file paths and return FileResult array', async () => {
|
||||
const options: SearchOptions = { keywords: 'test' };
|
||||
const results = await fileSearch.testProcessFilePaths(['/test/file.ts'], options, 'fd');
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].path).toBe('/test/file.ts');
|
||||
expect(results[0].name).toBe('file.ts');
|
||||
expect(results[0].type).toBe('ts');
|
||||
expect(results[0].engine).toBe('fd');
|
||||
});
|
||||
|
||||
it('should include engine in results', async () => {
|
||||
const options: SearchOptions = { keywords: 'test' };
|
||||
const results = await fileSearch.testProcessFilePaths(['/test/file.ts'], options, 'mdfind');
|
||||
|
||||
expect(results[0].engine).toBe('mdfind');
|
||||
});
|
||||
|
||||
it('should handle undefined engine', async () => {
|
||||
const options: SearchOptions = { keywords: 'test' };
|
||||
const results = await fileSearch.testProcessFilePaths(['/test/file.ts'], options);
|
||||
|
||||
expect(results[0].engine).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should determine content type from extension', async () => {
|
||||
const options: SearchOptions = { keywords: 'test' };
|
||||
const results = await fileSearch.testProcessFilePaths(['/test/file.ts'], options);
|
||||
|
||||
expect(results[0].contentType).toBe('code');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortResults', () => {
|
||||
const createMockResult = (name: string, size: number, modifiedTime: Date): FileResult => ({
|
||||
contentType: 'code',
|
||||
createdTime: new Date('2024-01-01'),
|
||||
isDirectory: false,
|
||||
lastAccessTime: new Date('2024-01-03'),
|
||||
metadata: {},
|
||||
modifiedTime,
|
||||
name,
|
||||
path: `/test/${name}`,
|
||||
size,
|
||||
type: 'ts',
|
||||
});
|
||||
|
||||
it('should sort by name ascending', () => {
|
||||
const results = [
|
||||
createMockResult('c.ts', 100, new Date('2024-01-01')),
|
||||
createMockResult('a.ts', 200, new Date('2024-01-02')),
|
||||
createMockResult('b.ts', 150, new Date('2024-01-03')),
|
||||
];
|
||||
|
||||
const sorted = fileSearch.testSortResults(results, 'name', 'asc');
|
||||
|
||||
expect(sorted[0].name).toBe('a.ts');
|
||||
expect(sorted[1].name).toBe('b.ts');
|
||||
expect(sorted[2].name).toBe('c.ts');
|
||||
});
|
||||
|
||||
it('should sort by name descending', () => {
|
||||
const results = [
|
||||
createMockResult('a.ts', 100, new Date('2024-01-01')),
|
||||
createMockResult('c.ts', 200, new Date('2024-01-02')),
|
||||
createMockResult('b.ts', 150, new Date('2024-01-03')),
|
||||
];
|
||||
|
||||
const sorted = fileSearch.testSortResults(results, 'name', 'desc');
|
||||
|
||||
expect(sorted[0].name).toBe('c.ts');
|
||||
expect(sorted[1].name).toBe('b.ts');
|
||||
expect(sorted[2].name).toBe('a.ts');
|
||||
});
|
||||
|
||||
it('should sort by size ascending', () => {
|
||||
const results = [
|
||||
createMockResult('a.ts', 300, new Date('2024-01-01')),
|
||||
createMockResult('b.ts', 100, new Date('2024-01-02')),
|
||||
createMockResult('c.ts', 200, new Date('2024-01-03')),
|
||||
];
|
||||
|
||||
const sorted = fileSearch.testSortResults(results, 'size', 'asc');
|
||||
|
||||
expect(sorted[0].size).toBe(100);
|
||||
expect(sorted[1].size).toBe(200);
|
||||
expect(sorted[2].size).toBe(300);
|
||||
});
|
||||
|
||||
it('should sort by date ascending', () => {
|
||||
const results = [
|
||||
createMockResult('a.ts', 100, new Date('2024-03-01')),
|
||||
createMockResult('b.ts', 200, new Date('2024-01-01')),
|
||||
createMockResult('c.ts', 150, new Date('2024-02-01')),
|
||||
];
|
||||
|
||||
const sorted = fileSearch.testSortResults(results, 'date', 'asc');
|
||||
|
||||
expect(sorted[0].name).toBe('b.ts');
|
||||
expect(sorted[1].name).toBe('c.ts');
|
||||
expect(sorted[2].name).toBe('a.ts');
|
||||
});
|
||||
|
||||
it('should return original array if no sortBy specified', () => {
|
||||
const results = [
|
||||
createMockResult('c.ts', 100, new Date('2024-01-01')),
|
||||
createMockResult('a.ts', 200, new Date('2024-01-02')),
|
||||
];
|
||||
|
||||
const sorted = fileSearch.testSortResults(results);
|
||||
|
||||
expect(sorted[0].name).toBe('c.ts');
|
||||
expect(sorted[1].name).toBe('a.ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setToolDetectorManager', () => {
|
||||
it('should set the tool detector manager', () => {
|
||||
const mockManager = {} as any;
|
||||
|
||||
fileSearch.setToolDetectorManager(mockManager);
|
||||
|
||||
expect((fileSearch as any).toolDetectorManager).toBe(mockManager);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
it('should return results with engine', async () => {
|
||||
const results = await fileSearch.search({ keywords: 'test' });
|
||||
|
||||
expect(results[0].engine).toBe('test-engine');
|
||||
});
|
||||
});
|
||||
|
||||
describe('glob', () => {
|
||||
it('should return GlobFilesResult with engine', async () => {
|
||||
const result = await fileSearch.glob({ pattern: '*.ts' });
|
||||
|
||||
expect(result.engine).toBe('test-engine');
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkSearchServiceStatus', () => {
|
||||
it('should return true', async () => {
|
||||
const status = await fileSearch.checkSearchServiceStatus();
|
||||
|
||||
expect(status).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSearchIndex', () => {
|
||||
it('should return true', async () => {
|
||||
const result = await fileSearch.updateSearchIndex();
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { platform } from 'node:os';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { LinuxSearchServiceImpl } from '../impl/linux';
|
||||
import { MacOSSearchServiceImpl } from '../impl/macOS';
|
||||
import { WindowsSearchServiceImpl } from '../impl/windows';
|
||||
import { createFileSearchModule } from '../index';
|
||||
|
||||
// Mock os module before imports
|
||||
vi.mock('node:os', () => ({
|
||||
homedir: vi.fn().mockReturnValue('/home/user'),
|
||||
platform: vi.fn().mockReturnValue('linux'),
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('createFileSearchModule', () => {
|
||||
it('should create MacOSSearchServiceImpl on darwin', () => {
|
||||
vi.mocked(platform).mockReturnValue('darwin');
|
||||
|
||||
const impl = createFileSearchModule();
|
||||
|
||||
expect(impl).toBeInstanceOf(MacOSSearchServiceImpl);
|
||||
});
|
||||
|
||||
it('should create WindowsSearchServiceImpl on win32', () => {
|
||||
vi.mocked(platform).mockReturnValue('win32');
|
||||
|
||||
const impl = createFileSearchModule();
|
||||
|
||||
expect(impl).toBeInstanceOf(WindowsSearchServiceImpl);
|
||||
});
|
||||
|
||||
it('should create LinuxSearchServiceImpl on linux', () => {
|
||||
vi.mocked(platform).mockReturnValue('linux');
|
||||
|
||||
const impl = createFileSearchModule();
|
||||
|
||||
expect(impl).toBeInstanceOf(LinuxSearchServiceImpl);
|
||||
});
|
||||
|
||||
it('should create LinuxSearchServiceImpl on unknown platform', () => {
|
||||
vi.mocked(platform).mockReturnValue('freebsd' as any);
|
||||
|
||||
const impl = createFileSearchModule();
|
||||
|
||||
expect(impl).toBeInstanceOf(LinuxSearchServiceImpl);
|
||||
});
|
||||
|
||||
it('should pass toolDetectorManager to implementation', () => {
|
||||
vi.mocked(platform).mockReturnValue('linux');
|
||||
const mockManager = {} as any;
|
||||
|
||||
const impl = createFileSearchModule(mockManager);
|
||||
|
||||
expect((impl as any).toolDetectorManager).toBe(mockManager);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
import { GlobFilesParams, GlobFilesResult } from '@lobechat/electron-client-ipc';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { ToolDetectorManager } from '@/core/infrastructure/ToolDetectorManager';
|
||||
|
||||
import { FileResult, SearchOptions } from './types';
|
||||
|
||||
/**
|
||||
* Content type mapping for common file extensions
|
||||
*/
|
||||
const CONTENT_TYPE_MAP: Record<string, string> = {
|
||||
// Archive
|
||||
'7z': 'archive',
|
||||
'gz': 'archive',
|
||||
'rar': 'archive',
|
||||
'tar': 'archive',
|
||||
'zip': 'archive',
|
||||
// Audio
|
||||
'aac': 'audio',
|
||||
'mp3': 'audio',
|
||||
'ogg': 'audio',
|
||||
'wav': 'audio',
|
||||
// Video
|
||||
'avi': 'video',
|
||||
'mkv': 'video',
|
||||
'mov': 'video',
|
||||
'mp4': 'video',
|
||||
// Image
|
||||
'gif': 'image',
|
||||
'heic': 'image',
|
||||
'ico': 'image',
|
||||
'jpeg': 'image',
|
||||
'jpg': 'image',
|
||||
'png': 'image',
|
||||
'svg': 'image',
|
||||
'webp': 'image',
|
||||
// Document
|
||||
'doc': 'document',
|
||||
'docx': 'document',
|
||||
'pdf': 'document',
|
||||
'rtf': 'text',
|
||||
'txt': 'text',
|
||||
// Spreadsheet
|
||||
'xls': 'spreadsheet',
|
||||
'xlsx': 'spreadsheet',
|
||||
// Presentation
|
||||
'ppt': 'presentation',
|
||||
'pptx': 'presentation',
|
||||
// Code
|
||||
'bat': 'code',
|
||||
'c': 'code',
|
||||
'cmd': 'code',
|
||||
'cpp': 'code',
|
||||
'cs': 'code',
|
||||
'css': 'code',
|
||||
'html': 'code',
|
||||
'java': 'code',
|
||||
'js': 'code',
|
||||
'json': 'code',
|
||||
'ps1': 'code',
|
||||
'py': 'code',
|
||||
'sh': 'code',
|
||||
'swift': 'code',
|
||||
'ts': 'code',
|
||||
'tsx': 'code',
|
||||
'vbs': 'code',
|
||||
// Application/Installer (platform-specific)
|
||||
'app': 'application',
|
||||
'deb': 'package',
|
||||
'dmg': 'disk-image',
|
||||
'exe': 'application',
|
||||
'iso': 'disk-image',
|
||||
'msi': 'installer',
|
||||
'rpm': 'package',
|
||||
};
|
||||
|
||||
/**
|
||||
* File Search Service Implementation Abstract Class
|
||||
* Defines the interface that different platform file search implementations need to implement
|
||||
*/
|
||||
export abstract class BaseFileSearch {
|
||||
protected toolDetectorManager?: ToolDetectorManager;
|
||||
|
||||
constructor(toolDetectorManager?: ToolDetectorManager) {
|
||||
this.toolDetectorManager = toolDetectorManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the tool detector manager
|
||||
* @param manager ToolDetectorManager instance
|
||||
*/
|
||||
setToolDetectorManager(manager: ToolDetectorManager): void {
|
||||
this.toolDetectorManager = manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine content type from file extension
|
||||
* @param extension File extension (without dot)
|
||||
* @returns Content type description
|
||||
*/
|
||||
protected determineContentType(extension: string): string {
|
||||
return CONTENT_TYPE_MAP[extension.toLowerCase()] || 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special glob characters in the search pattern
|
||||
* @param pattern The pattern to escape
|
||||
* @returns Escaped pattern safe for glob matching
|
||||
*/
|
||||
protected escapeGlobPattern(pattern: string): string {
|
||||
return pattern.replaceAll(/[$()*+.?[\\\]^{|}]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Process file paths and return FileResult objects
|
||||
* @param filePaths Array of file path strings
|
||||
* @param options Search options
|
||||
* @param engine Optional search engine identifier
|
||||
* @returns Formatted file result list
|
||||
*/
|
||||
protected async processFilePaths(
|
||||
filePaths: string[],
|
||||
options: SearchOptions,
|
||||
engine?: string,
|
||||
): Promise<FileResult[]> {
|
||||
const results: FileResult[] = [];
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
try {
|
||||
const stats = await stat(filePath);
|
||||
const ext = path.extname(filePath).toLowerCase().replace('.', '');
|
||||
|
||||
results.push({
|
||||
contentType: this.determineContentType(ext),
|
||||
createdTime: stats.birthtime,
|
||||
engine,
|
||||
isDirectory: stats.isDirectory(),
|
||||
lastAccessTime: stats.atime,
|
||||
metadata: {},
|
||||
modifiedTime: stats.mtime,
|
||||
name: path.basename(filePath),
|
||||
path: filePath,
|
||||
size: stats.size,
|
||||
type: ext,
|
||||
});
|
||||
} catch {
|
||||
// Skip files that can't be accessed
|
||||
}
|
||||
}
|
||||
|
||||
return this.sortResults(results, options.sortBy, options.sortDirection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort results based on options
|
||||
* @param results Result list
|
||||
* @param sortBy Sort field
|
||||
* @param direction Sort direction
|
||||
* @returns Sorted result list
|
||||
*/
|
||||
protected sortResults(
|
||||
results: FileResult[],
|
||||
sortBy?: 'name' | 'date' | 'size',
|
||||
direction: 'asc' | 'desc' = 'asc',
|
||||
): FileResult[] {
|
||||
if (!sortBy) return results;
|
||||
|
||||
return [...results].sort((a, b) => {
|
||||
let comparison = 0;
|
||||
switch (sortBy) {
|
||||
case 'name': {
|
||||
comparison = a.name.localeCompare(b.name);
|
||||
break;
|
||||
}
|
||||
case 'date': {
|
||||
comparison = a.modifiedTime.getTime() - b.modifiedTime.getTime();
|
||||
break;
|
||||
}
|
||||
case 'size': {
|
||||
comparison = a.size - b.size;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return direction === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform file search
|
||||
* @param options Search options
|
||||
* @returns Promise of search result list
|
||||
*/
|
||||
abstract search(options: SearchOptions): Promise<FileResult[]>;
|
||||
|
||||
/**
|
||||
* Perform glob pattern matching
|
||||
* @param params Glob parameters
|
||||
* @returns Promise of glob result
|
||||
*/
|
||||
abstract glob(params: GlobFilesParams): Promise<GlobFilesResult>;
|
||||
|
||||
/**
|
||||
* Check search service status
|
||||
* @returns Promise indicating if service is available
|
||||
*/
|
||||
abstract checkSearchServiceStatus(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Update search index
|
||||
* @param path Optional specified path
|
||||
* @returns Promise indicating operation success
|
||||
*/
|
||||
abstract updateSearchIndex(path?: string): Promise<boolean>;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ToolDetectorManager } from '@/core/infrastructure/ToolDetectorManager';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { FileResult, SearchOptions } from '../types';
|
||||
import { UnixFileSearch, UnixSearchTool } from './unix';
|
||||
|
||||
const logger = createLogger('module:FileSearch:linux');
|
||||
|
||||
/**
|
||||
* Linux file search implementation
|
||||
* Uses fd > find > fast-glob fallback strategy
|
||||
*/
|
||||
export class LinuxSearchServiceImpl extends UnixFileSearch {
|
||||
constructor(toolDetectorManager?: ToolDetectorManager) {
|
||||
super(toolDetectorManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform file search
|
||||
* @param options Search options
|
||||
* @returns Promise of search result list
|
||||
*/
|
||||
async search(options: SearchOptions): Promise<FileResult[]> {
|
||||
// Determine the best available tool on first search
|
||||
if (this.currentTool === null) {
|
||||
this.currentTool = await this.determineBestUnixTool();
|
||||
logger.info(`Using file search tool: ${this.currentTool}`);
|
||||
}
|
||||
|
||||
return this.searchWithUnixTool(this.currentTool as UnixSearchTool, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check search service status
|
||||
* @returns Promise indicating if service is available (always true for Linux)
|
||||
*/
|
||||
async checkSearchServiceStatus(): Promise<boolean> {
|
||||
// At minimum, fast-glob is always available
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update search index
|
||||
* Linux doesn't have a system-wide search index like Spotlight
|
||||
* @returns Promise indicating operation result (always false for Linux)
|
||||
*/
|
||||
async updateSearchIndex(): Promise<boolean> {
|
||||
logger.warn('updateSearchIndex is not supported on Linux (no system-wide index)');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,119 +1,153 @@
|
||||
import { exec, spawn } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import { execa } from 'execa';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import readline from 'node:readline';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import { FileResult, SearchOptions } from '@/types/fileSearch';
|
||||
import { ToolDetectorManager } from '@/core/infrastructure/ToolDetectorManager';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { FileSearchImpl } from '../type';
|
||||
import { FileResult, SearchOptions } from '../types';
|
||||
import { UnixFileSearch, UnixSearchTool } from './unix';
|
||||
|
||||
const execPromise = promisify(exec);
|
||||
const statPromise = promisify(fs.stat);
|
||||
|
||||
// Create logger
|
||||
const logger = createLogger('module:FileSearch:macOS');
|
||||
|
||||
export class MacOSSearchServiceImpl extends FileSearchImpl {
|
||||
/**
|
||||
* Fallback tool type for macOS file search
|
||||
* Priority: mdfind > fd > find > fast-glob
|
||||
*/
|
||||
type MacOSSearchTool = 'mdfind' | UnixSearchTool;
|
||||
|
||||
export class MacOSSearchServiceImpl extends UnixFileSearch {
|
||||
/**
|
||||
* Cache for Spotlight availability status
|
||||
* null = not checked, true = available, false = not available
|
||||
*/
|
||||
private spotlightAvailable: boolean | null = null;
|
||||
|
||||
/**
|
||||
* Current tool being used (macOS specific, includes mdfind)
|
||||
*/
|
||||
private macOSCurrentTool: MacOSSearchTool | null = null;
|
||||
|
||||
constructor(toolDetectorManager?: ToolDetectorManager) {
|
||||
super(toolDetectorManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform file search
|
||||
* @param options Search options
|
||||
* @returns Promise of search result list
|
||||
*/
|
||||
async search(options: SearchOptions): Promise<FileResult[]> {
|
||||
// Build the command first, regardless of execution method
|
||||
// Determine the best available tool on first search
|
||||
if (this.macOSCurrentTool === null) {
|
||||
this.macOSCurrentTool = await this.determineBestTool();
|
||||
logger.info(`Using file search tool: ${this.macOSCurrentTool}`);
|
||||
}
|
||||
|
||||
return this.searchWithTool(this.macOSCurrentTool, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the best available tool based on priority
|
||||
* Priority: mdfind > fd > find > fast-glob
|
||||
*/
|
||||
private async determineBestTool(): Promise<MacOSSearchTool> {
|
||||
if (this.toolDetectorManager) {
|
||||
const bestTool = await this.toolDetectorManager.getBestTool('file-search');
|
||||
if (bestTool && ['mdfind', 'fd', 'find'].includes(bestTool)) {
|
||||
return bestTool as MacOSSearchTool;
|
||||
}
|
||||
}
|
||||
|
||||
if (await this.checkSpotlightStatus()) {
|
||||
return 'mdfind';
|
||||
}
|
||||
|
||||
// Fallback to Unix tool detection
|
||||
return this.determineBestUnixTool();
|
||||
}
|
||||
|
||||
/**
|
||||
* Search using the specified tool
|
||||
*/
|
||||
private async searchWithTool(
|
||||
tool: MacOSSearchTool,
|
||||
options: SearchOptions,
|
||||
): Promise<FileResult[]> {
|
||||
if (tool === 'mdfind') {
|
||||
return this.searchWithSpotlight(options);
|
||||
}
|
||||
// Use parent class Unix tool implementation
|
||||
return this.searchWithUnixTool(tool, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback to the next available tool (macOS specific)
|
||||
*/
|
||||
private async fallbackFromMdfind(): Promise<MacOSSearchTool> {
|
||||
return this.determineBestUnixTool();
|
||||
}
|
||||
|
||||
/**
|
||||
* Search using Spotlight (mdfind)
|
||||
*/
|
||||
private async searchWithSpotlight(options: SearchOptions): Promise<FileResult[]> {
|
||||
const { cmd, args, commandString } = this.buildSearchCommand(options);
|
||||
logger.debug(`Executing command: ${commandString}`);
|
||||
|
||||
// Use spawn for both live and non-live updates to handle large outputs
|
||||
return new Promise((resolve, reject) => {
|
||||
const childProcess = spawn(cmd, args);
|
||||
|
||||
let results: string[] = []; // Store raw file paths
|
||||
let stderrData = '';
|
||||
|
||||
// Create a readline interface to process stdout line by line
|
||||
const rl = readline.createInterface({
|
||||
crlfDelay: Infinity,
|
||||
input: childProcess.stdout, // Handle different line endings
|
||||
try {
|
||||
const { stdout, stderr, exitCode } = await execa(cmd, args, {
|
||||
reject: false,
|
||||
});
|
||||
|
||||
rl.on('line', (line) => {
|
||||
const trimmedLine = line.trim();
|
||||
if (trimmedLine) {
|
||||
results.push(trimmedLine);
|
||||
|
||||
// If we have a limit and we've reached it (in non-live mode), stop processing
|
||||
if (!options.liveUpdate && options.limit && results.length >= options.limit) {
|
||||
logger.debug(`Reached limit (${options.limit}), closing readline and killing process.`);
|
||||
rl.close(); // Stop reading lines
|
||||
childProcess.kill(); // Terminate the mdfind process
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
childProcess.stderr.on('data', (data) => {
|
||||
const errorMsg = data.toString();
|
||||
stderrData += errorMsg;
|
||||
logger.warn(`Search stderr: ${errorMsg}`);
|
||||
});
|
||||
|
||||
childProcess.on('error', (error) => {
|
||||
logger.error(`Search process error: ${error.message}`, error);
|
||||
reject(new Error(`Search process failed to start: ${error.message}`));
|
||||
});
|
||||
|
||||
childProcess.on('close', async (code) => {
|
||||
logger.debug(`Search process exited with code ${code}`);
|
||||
|
||||
// Even if the process was killed due to limit, code might be null or non-zero.
|
||||
// Process the results collected so far.
|
||||
if (code !== 0 && stderrData && results.length === 0) {
|
||||
// If exited with error code and we have stderr message and no results, reject.
|
||||
// Filter specific ignorable errors if necessary
|
||||
if (!stderrData.includes('Index is unavailable') && !stderrData.includes('kMD')) {
|
||||
// Avoid rejecting for common Spotlight query syntax errors or index issues if some results might still be valid
|
||||
reject(new Error(`Search process exited with code ${code}: ${stderrData}`));
|
||||
return;
|
||||
} else {
|
||||
logger.warn(
|
||||
`Search process exited with code ${code} but contained potentially ignorable errors: ${stderrData}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Process the collected file paths
|
||||
// Ensure limit is applied again here in case killing the process didn't stop exactly at the limit
|
||||
const limitedResults =
|
||||
options.limit && results.length > options.limit
|
||||
? results.slice(0, options.limit)
|
||||
: results;
|
||||
|
||||
const processedResults = await this.processSearchResultsFromPaths(
|
||||
limitedResults,
|
||||
options,
|
||||
);
|
||||
resolve(processedResults);
|
||||
} catch (processingError) {
|
||||
logger.error('Error processing search results:', processingError);
|
||||
reject(new Error(`Failed to process search results: ${processingError.message}`));
|
||||
}
|
||||
});
|
||||
|
||||
// Handle live update specific logic (if needed in the future, e.g., sending initial batch)
|
||||
if (options.liveUpdate) {
|
||||
// For live update, we might want to resolve an initial batch
|
||||
// or rely purely on events sent elsewhere.
|
||||
// Current implementation resolves when the stream closes.
|
||||
// We could add a timeout to resolve with initial results if needed.
|
||||
logger.debug('Live update enabled, results will be processed on close.');
|
||||
// Note: The previous `executeLiveSearch` logic is now integrated here.
|
||||
// If specific live update event emission is needed, it would be added here,
|
||||
// potentially calling a callback provided in options.
|
||||
if (stderr) {
|
||||
logger.warn(`Search stderr: ${stderr}`);
|
||||
}
|
||||
});
|
||||
|
||||
logger.debug(`Search process exited with code ${exitCode}`);
|
||||
|
||||
const results = stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.trim());
|
||||
|
||||
// If exited with error code and we have stderr and no results, fallback
|
||||
if (exitCode !== 0 && stderr && results.length === 0) {
|
||||
if (!stderr.includes('Index is unavailable') && !stderr.includes('kMD')) {
|
||||
logger.warn(
|
||||
`Spotlight search failed with code ${exitCode}, falling back to next tool: ${stderr}`,
|
||||
);
|
||||
this.spotlightAvailable = false;
|
||||
this.macOSCurrentTool = await this.fallbackFromMdfind();
|
||||
logger.info(`Falling back to: ${this.macOSCurrentTool}`);
|
||||
return this.searchWithTool(this.macOSCurrentTool, options);
|
||||
} else {
|
||||
logger.warn(
|
||||
`Search process exited with code ${exitCode} but contained potentially ignorable errors: ${stderr}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply limit
|
||||
const limitedResults =
|
||||
options.limit && results.length > options.limit ? results.slice(0, options.limit) : results;
|
||||
|
||||
return this.processSpotlightResults(limitedResults, options, 'mdfind');
|
||||
} catch (error) {
|
||||
logger.error(`Search process error: ${(error as Error).message}`, error);
|
||||
this.spotlightAvailable = false;
|
||||
this.macOSCurrentTool = await this.fallbackFromMdfind();
|
||||
logger.warn(`Spotlight search failed, falling back to: ${this.macOSCurrentTool}`);
|
||||
return this.searchWithTool(this.macOSCurrentTool, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get macOS-specific ignore patterns including Library/Caches
|
||||
*/
|
||||
protected override getDefaultIgnorePatterns(): string[] {
|
||||
return [...super.getDefaultIgnorePatterns(), '**/Library/Caches/**'];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,37 +163,29 @@ export class MacOSSearchServiceImpl extends FileSearchImpl {
|
||||
* @param path Optional specified path
|
||||
* @returns Promise indicating operation success
|
||||
*/
|
||||
async updateSearchIndex(path?: string): Promise<boolean> {
|
||||
return this.updateSpotlightIndex(path);
|
||||
async updateSearchIndex(updatePath?: string): Promise<boolean> {
|
||||
return this.updateSpotlightIndex(updatePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build mdfind command string
|
||||
* @param options Search options
|
||||
* @returns Command components (cmd, args array, and command string for logging)
|
||||
*/
|
||||
private buildSearchCommand(options: SearchOptions): {
|
||||
args: string[];
|
||||
cmd: string;
|
||||
commandString: string;
|
||||
} {
|
||||
// Command and arguments array
|
||||
const cmd = 'mdfind';
|
||||
const args: string[] = [];
|
||||
|
||||
// macOS mdfind doesn't support -limit parameter, we'll limit results in post-processing
|
||||
|
||||
// Search in specific directory
|
||||
if (options.onlyIn) {
|
||||
args.push('-onlyin', options.onlyIn);
|
||||
}
|
||||
|
||||
// Live update
|
||||
if (options.liveUpdate) {
|
||||
args.push('-live');
|
||||
}
|
||||
|
||||
// Detailed metadata
|
||||
if (options.detailed) {
|
||||
args.push(
|
||||
'-attr',
|
||||
@@ -172,22 +198,16 @@ export class MacOSSearchServiceImpl extends FileSearchImpl {
|
||||
);
|
||||
}
|
||||
|
||||
// Build query expression
|
||||
let queryExpression = '';
|
||||
|
||||
// Basic query
|
||||
if (options.keywords) {
|
||||
// If the query string doesn't use Spotlight query syntax (doesn't contain kMDItem properties),
|
||||
// treat it as a flexible name search rather than exact phrase match
|
||||
if (!options.keywords.includes('kMDItem')) {
|
||||
// Use kMDItemFSName for filename matching with wildcards for better flexibility
|
||||
queryExpression = `kMDItemFSName == "*${options.keywords.replaceAll('"', '\\"')}*"cd`;
|
||||
} else {
|
||||
queryExpression = options.keywords;
|
||||
}
|
||||
}
|
||||
|
||||
// File content search
|
||||
if (options.contentContains) {
|
||||
if (queryExpression) {
|
||||
queryExpression = `${queryExpression} && kMDItemTextContent == "*${options.contentContains}*"cd`;
|
||||
@@ -196,7 +216,6 @@ export class MacOSSearchServiceImpl extends FileSearchImpl {
|
||||
}
|
||||
}
|
||||
|
||||
// File type filtering
|
||||
if (options.fileTypes && options.fileTypes.length > 0) {
|
||||
const typeConditions = options.fileTypes
|
||||
.map((type) => `kMDItemContentType == "${type}"`)
|
||||
@@ -208,7 +227,6 @@ export class MacOSSearchServiceImpl extends FileSearchImpl {
|
||||
}
|
||||
}
|
||||
|
||||
// Date filtering - Modified date
|
||||
if (options.modifiedAfter || options.modifiedBefore) {
|
||||
let dateCondition = '';
|
||||
|
||||
@@ -230,7 +248,6 @@ export class MacOSSearchServiceImpl extends FileSearchImpl {
|
||||
}
|
||||
}
|
||||
|
||||
// Date filtering - Creation date
|
||||
if (options.createdAfter || options.createdBefore) {
|
||||
let dateCondition = '';
|
||||
|
||||
@@ -252,46 +269,30 @@ export class MacOSSearchServiceImpl extends FileSearchImpl {
|
||||
}
|
||||
}
|
||||
|
||||
// Add query expression to args
|
||||
if (queryExpression) {
|
||||
args.push(queryExpression);
|
||||
}
|
||||
|
||||
// Build command string for logging
|
||||
const commandString = `${cmd} ${args.map((arg) => (arg.includes(' ') || arg.includes('*') ? `"${arg}"` : arg)).join(' ')}`;
|
||||
|
||||
return { args, cmd, commandString };
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute live search, returns initial results and sets callback
|
||||
* @param command mdfind command
|
||||
* @param options Search options
|
||||
* @returns Promise of initial search results
|
||||
* @deprecated This logic is now integrated into the main search method using spawn.
|
||||
* Process Spotlight search results with optional metadata
|
||||
*/
|
||||
// private executeLiveSearch(command: string, options: SearchOptions): Promise<FileResult[]> { ... }
|
||||
// Remove or comment out the old executeLiveSearch method
|
||||
|
||||
/**
|
||||
* Process search results from a list of file paths
|
||||
* @param filePaths Array of file path strings
|
||||
* @param options Search options
|
||||
* @returns Formatted file result list
|
||||
*/
|
||||
private async processSearchResultsFromPaths(
|
||||
private async processSpotlightResults(
|
||||
filePaths: string[],
|
||||
options: SearchOptions,
|
||||
engine?: string,
|
||||
): Promise<FileResult[]> {
|
||||
// Create a result object for each file path
|
||||
const resultPromises = filePaths.map(async (filePath) => {
|
||||
try {
|
||||
// Get file information
|
||||
const stats = await statPromise(filePath);
|
||||
const stats = await stat(filePath);
|
||||
|
||||
// Create basic result object
|
||||
const result: FileResult = {
|
||||
createdTime: stats.birthtime,
|
||||
engine,
|
||||
isDirectory: stats.isDirectory(),
|
||||
lastAccessTime: stats.atime,
|
||||
metadata: {},
|
||||
@@ -302,21 +303,19 @@ export class MacOSSearchServiceImpl extends FileSearchImpl {
|
||||
type: path.extname(filePath).toLowerCase().replace('.', ''),
|
||||
};
|
||||
|
||||
// If detailed information is needed, get additional metadata
|
||||
if (options.detailed) {
|
||||
if (options.detailed && this.spotlightAvailable) {
|
||||
result.metadata = await this.getDetailedMetadata(filePath);
|
||||
}
|
||||
|
||||
// Determine content type
|
||||
result.contentType = this.determineContentType(result.name, result.type);
|
||||
result.contentType = this.determineContentType(result.type);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.warn(`Error processing file stats for ${filePath}: ${error.message}`, error);
|
||||
// Return partial information, even if unable to get complete file stats
|
||||
logger.warn(`Error processing file stats for ${filePath}: ${(error as Error).message}`);
|
||||
return {
|
||||
contentType: 'unknown',
|
||||
createdTime: new Date(),
|
||||
engine,
|
||||
isDirectory: false,
|
||||
lastAccessTime: new Date(),
|
||||
modifiedTime: new Date(),
|
||||
@@ -328,15 +327,12 @@ export class MacOSSearchServiceImpl extends FileSearchImpl {
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for all file information processing to complete
|
||||
let results = await Promise.all(resultPromises);
|
||||
|
||||
// Sort results
|
||||
if (options.sortBy) {
|
||||
results = this.sortResults(results, options.sortBy, options.sortDirection);
|
||||
}
|
||||
|
||||
// Apply limit here as mdfind doesn't support -limit parameter
|
||||
if (options.limit && options.limit > 0 && results.length > options.limit) {
|
||||
results = results.slice(0, options.limit);
|
||||
}
|
||||
@@ -345,26 +341,12 @@ export class MacOSSearchServiceImpl extends FileSearchImpl {
|
||||
}
|
||||
|
||||
/**
|
||||
* Process search results
|
||||
* @param stdout Command output (now unused directly, processing happens line by line)
|
||||
* @param options Search options
|
||||
* @returns Formatted file result list
|
||||
* @deprecated Use processSearchResultsFromPaths instead.
|
||||
*/
|
||||
// private async processSearchResults(stdout: string, options: SearchOptions): Promise<FileResult[]> { ... }
|
||||
// Remove or comment out the old processSearchResults method
|
||||
|
||||
/**
|
||||
* Get detailed metadata for a file
|
||||
* @param filePath File path
|
||||
* @returns Metadata object
|
||||
* Get detailed metadata for a file using mdls
|
||||
*/
|
||||
private async getDetailedMetadata(filePath: string): Promise<Record<string, any>> {
|
||||
try {
|
||||
// Use mdls command to get all metadata
|
||||
const { stdout } = await execPromise(`mdls "${filePath}"`);
|
||||
const { stdout } = await execa('mdls', [filePath]);
|
||||
|
||||
// Parse mdls output
|
||||
const metadata: Record<string, any> = {};
|
||||
const lines = stdout.split('\n');
|
||||
|
||||
@@ -375,13 +357,11 @@ export class MacOSSearchServiceImpl extends FileSearchImpl {
|
||||
for (const line of lines) {
|
||||
if (isMultilineValue) {
|
||||
if (line.includes(')')) {
|
||||
// Multiline value ends
|
||||
multilineValue.push(line.trim());
|
||||
metadata[currentKey] = multilineValue.join(' ');
|
||||
isMultilineValue = false;
|
||||
multilineValue = [];
|
||||
} else {
|
||||
// Continue collecting multiline value
|
||||
multilineValue.push(line.trim());
|
||||
}
|
||||
continue;
|
||||
@@ -392,12 +372,10 @@ export class MacOSSearchServiceImpl extends FileSearchImpl {
|
||||
currentKey = match[1];
|
||||
const value = match[2].trim();
|
||||
|
||||
// Check for multiline value start
|
||||
if (value.includes('(') && !value.includes(')')) {
|
||||
isMultilineValue = true;
|
||||
multilineValue = [value];
|
||||
} else {
|
||||
// Process single line value
|
||||
metadata[currentKey] = this.parseMetadataValue(value);
|
||||
}
|
||||
}
|
||||
@@ -405,180 +383,80 @@ export class MacOSSearchServiceImpl extends FileSearchImpl {
|
||||
|
||||
return metadata;
|
||||
} catch (error) {
|
||||
logger.warn(`Error getting metadata for ${filePath}: ${error.message}`, error);
|
||||
logger.warn(`Error getting metadata for ${filePath}: ${(error as Error).message}`);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse metadata value
|
||||
* @param value Metadata raw value string
|
||||
* @returns Parsed value
|
||||
* Parse metadata value from mdls output
|
||||
*/
|
||||
private parseMetadataValue(input: string): any {
|
||||
let value = input;
|
||||
// Remove quotes from mdls output
|
||||
if (value.startsWith('"') && value.endsWith('"')) {
|
||||
// eslint-disable-next-line unicorn/prefer-string-slice
|
||||
value = value.substring(1, value.length - 1);
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
// Handle special values
|
||||
if (value === '(null)') return null;
|
||||
if (value === 'Yes' || value === 'true') return true;
|
||||
if (value === 'No' || value === 'false') return false;
|
||||
|
||||
// Try to parse date (format like "2023-05-16 14:30:45 +0000")
|
||||
const dateMatch = value.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} [+-]\d{4})$/);
|
||||
if (dateMatch) {
|
||||
try {
|
||||
return new Date(value);
|
||||
} catch {
|
||||
// If date parsing fails, return original string
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to parse number
|
||||
if (/^-?\d+(\.\d+)?$/.test(value)) {
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
// Default return string
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine file content type
|
||||
* @param fileName File name
|
||||
* @param extension File extension
|
||||
* @returns Content type description
|
||||
*/
|
||||
private determineContentType(fileName: string, extension: string): string {
|
||||
// Map common file extensions to content types
|
||||
const typeMap: Record<string, string> = {
|
||||
'7z': 'archive',
|
||||
'aac': 'audio',
|
||||
// Others
|
||||
'app': 'application',
|
||||
'avi': 'video',
|
||||
'c': 'code',
|
||||
'cpp': 'code',
|
||||
'css': 'code',
|
||||
'dmg': 'disk-image',
|
||||
'doc': 'document',
|
||||
'docx': 'document',
|
||||
'gif': 'image',
|
||||
'gz': 'archive',
|
||||
'heic': 'image',
|
||||
'html': 'code',
|
||||
'iso': 'disk-image',
|
||||
'java': 'code',
|
||||
'jpeg': 'image',
|
||||
// Images
|
||||
'jpg': 'image',
|
||||
// Code
|
||||
'js': 'code',
|
||||
'json': 'code',
|
||||
'mkv': 'video',
|
||||
'mov': 'video',
|
||||
// Audio
|
||||
'mp3': 'audio',
|
||||
// Video
|
||||
'mp4': 'video',
|
||||
'ogg': 'audio',
|
||||
// Documents
|
||||
'pdf': 'document',
|
||||
'png': 'image',
|
||||
'ppt': 'presentation',
|
||||
'pptx': 'presentation',
|
||||
'py': 'code',
|
||||
'rar': 'archive',
|
||||
'rtf': 'text',
|
||||
'svg': 'image',
|
||||
'swift': 'code',
|
||||
'tar': 'archive',
|
||||
'ts': 'code',
|
||||
'txt': 'text',
|
||||
'wav': 'audio',
|
||||
'webp': 'image',
|
||||
'xls': 'spreadsheet',
|
||||
'xlsx': 'spreadsheet',
|
||||
// Archive files
|
||||
'zip': 'archive',
|
||||
};
|
||||
|
||||
// Find matching content type
|
||||
return typeMap[extension.toLowerCase()] || 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort results
|
||||
* @param results Result list
|
||||
* @param sortBy Sort field
|
||||
* @param direction Sort direction
|
||||
* @returns Sorted result list
|
||||
*/
|
||||
private sortResults(
|
||||
results: FileResult[],
|
||||
sortBy: 'name' | 'date' | 'size',
|
||||
direction: 'asc' | 'desc' = 'asc',
|
||||
): FileResult[] {
|
||||
const sortedResults = [...results];
|
||||
|
||||
sortedResults.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
|
||||
switch (sortBy) {
|
||||
case 'name': {
|
||||
comparison = a.name.localeCompare(b.name);
|
||||
break;
|
||||
}
|
||||
case 'date': {
|
||||
comparison = a.modifiedTime.getTime() - b.modifiedTime.getTime();
|
||||
break;
|
||||
}
|
||||
case 'size': {
|
||||
comparison = a.size - b.size;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return direction === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
|
||||
return sortedResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Spotlight service status
|
||||
* @returns Promise indicating if Spotlight is available
|
||||
*/
|
||||
private async checkSpotlightStatus(): Promise<boolean> {
|
||||
if (this.spotlightAvailable !== null) {
|
||||
return this.spotlightAvailable;
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to run a simple mdfind command - macOS doesn't support -limit parameter
|
||||
await execPromise('mdfind -name test -onlyin ~ -count');
|
||||
const { stdout } = await execa(
|
||||
'mdfind',
|
||||
['-name', 'test', '-onlyin', os.homedir() || '~', '-count'],
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
|
||||
const count = parseInt(stdout.trim(), 10);
|
||||
if (Number.isNaN(count)) {
|
||||
logger.warn('Spotlight returned invalid response');
|
||||
this.spotlightAvailable = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
this.spotlightAvailable = true;
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`Spotlight is not available: ${error.message}`, error);
|
||||
logger.warn(`Spotlight is not available: ${(error as Error).message}`);
|
||||
this.spotlightAvailable = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Spotlight index
|
||||
* @param path Optional specified path
|
||||
* @returns Promise indicating operation success
|
||||
*/
|
||||
private async updateSpotlightIndex(path?: string): Promise<boolean> {
|
||||
private async updateSpotlightIndex(updatePath?: string): Promise<boolean> {
|
||||
try {
|
||||
// mdutil command is used to manage Spotlight index
|
||||
const command = path ? `mdutil -E "${path}"` : 'mdutil -E /';
|
||||
|
||||
await execPromise(command);
|
||||
await execa('mdutil', ['-E', updatePath || '/']);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update Spotlight index: ${error.message}`, error);
|
||||
logger.error(`Failed to update Spotlight index: ${(error as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
/* eslint-disable unicorn/no-array-push-push */
|
||||
import { GlobFilesParams, GlobFilesResult } from '@lobechat/electron-client-ipc';
|
||||
import { execa } from 'execa';
|
||||
import fg from 'fast-glob';
|
||||
import { Stats } from 'node:fs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import * as os from 'node:os';
|
||||
|
||||
import { ToolDetectorManager } from '@/core/infrastructure/ToolDetectorManager';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { BaseFileSearch } from '../base';
|
||||
import { FileResult, SearchOptions } from '../types';
|
||||
|
||||
const logger = createLogger('module:FileSearch:unix');
|
||||
|
||||
/**
|
||||
* Fallback tool type for Unix file search
|
||||
* Priority: fd > find > fast-glob
|
||||
*/
|
||||
export type UnixSearchTool = 'fd' | 'find' | 'fast-glob';
|
||||
|
||||
/**
|
||||
* Unix file search base class
|
||||
* Provides common search implementations for macOS and Linux
|
||||
*/
|
||||
export abstract class UnixFileSearch extends BaseFileSearch {
|
||||
/**
|
||||
* Current fallback tool being used
|
||||
*/
|
||||
protected currentTool: UnixSearchTool | null = null;
|
||||
|
||||
constructor(toolDetectorManager?: ToolDetectorManager) {
|
||||
super(toolDetectorManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool is available using 'which' command
|
||||
* @param tool Tool name to check
|
||||
* @returns Promise indicating if tool is available
|
||||
*/
|
||||
protected async checkToolAvailable(tool: string): Promise<boolean> {
|
||||
try {
|
||||
await execa('which', [tool], { timeout: 3000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the best available Unix tool based on priority
|
||||
* Priority: fd > find > fast-glob
|
||||
* @returns The best available tool
|
||||
*/
|
||||
protected async determineBestUnixTool(): Promise<UnixSearchTool> {
|
||||
if (this.toolDetectorManager) {
|
||||
const bestTool = await this.toolDetectorManager.getBestTool('file-search');
|
||||
if (bestTool && ['fd', 'find'].includes(bestTool)) {
|
||||
return bestTool as UnixSearchTool;
|
||||
}
|
||||
}
|
||||
|
||||
if (await this.checkToolAvailable('fd')) {
|
||||
return 'fd';
|
||||
}
|
||||
|
||||
if (await this.checkToolAvailable('find')) {
|
||||
return 'find';
|
||||
}
|
||||
|
||||
return 'fast-glob';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback to the next available tool
|
||||
* @param currentTool Current tool that failed
|
||||
* @returns Next tool to try
|
||||
*/
|
||||
protected async fallbackToNextTool(currentTool: UnixSearchTool): Promise<UnixSearchTool> {
|
||||
const priority: UnixSearchTool[] = ['fd', 'find', 'fast-glob'];
|
||||
const currentIndex = priority.indexOf(currentTool);
|
||||
|
||||
for (let i = currentIndex + 1; i < priority.length; i++) {
|
||||
const nextTool = priority[i];
|
||||
if (nextTool === 'fast-glob') {
|
||||
return 'fast-glob'; // Always available
|
||||
}
|
||||
if (await this.checkToolAvailable(nextTool)) {
|
||||
return nextTool;
|
||||
}
|
||||
}
|
||||
|
||||
return 'fast-glob';
|
||||
}
|
||||
|
||||
/**
|
||||
* Search using the specified Unix tool
|
||||
* @param tool Tool to use for search
|
||||
* @param options Search options
|
||||
* @returns Search results
|
||||
*/
|
||||
protected async searchWithUnixTool(
|
||||
tool: UnixSearchTool,
|
||||
options: SearchOptions,
|
||||
): Promise<FileResult[]> {
|
||||
switch (tool) {
|
||||
case 'fd': {
|
||||
return this.searchWithFd(options);
|
||||
}
|
||||
case 'find': {
|
||||
return this.searchWithFind(options);
|
||||
}
|
||||
default: {
|
||||
return this.searchWithFastGlob(options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search using fd (fast find alternative)
|
||||
* @param options Search options
|
||||
* @returns Search results
|
||||
*/
|
||||
protected async searchWithFd(options: SearchOptions): Promise<FileResult[]> {
|
||||
const searchDir = options.onlyIn || os.homedir() || '/';
|
||||
const limit = options.limit || 30;
|
||||
|
||||
logger.debug('Performing fd search', { keywords: options.keywords, searchDir });
|
||||
|
||||
try {
|
||||
const args: string[] = [];
|
||||
|
||||
// Pattern matching
|
||||
if (options.keywords) {
|
||||
args.push(options.keywords);
|
||||
} else {
|
||||
args.push('.'); // Match all files
|
||||
}
|
||||
|
||||
// Search directory and options
|
||||
args.push(searchDir, '--type', 'f', '--hidden', '--ignore-case', '--max-depth', '10');
|
||||
args.push(
|
||||
'--max-results',
|
||||
String(limit),
|
||||
'--exclude',
|
||||
'node_modules',
|
||||
'--exclude',
|
||||
'.git',
|
||||
'--exclude',
|
||||
'*cache*',
|
||||
);
|
||||
|
||||
const { stdout, exitCode } = await execa('fd', args, {
|
||||
reject: false,
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
if (exitCode !== 0 && !stdout.trim()) {
|
||||
logger.warn(`fd search failed with code ${exitCode}, falling back to next tool`);
|
||||
this.currentTool = await this.fallbackToNextTool('fd');
|
||||
return this.searchWithUnixTool(this.currentTool, options);
|
||||
}
|
||||
|
||||
const files = stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.trim());
|
||||
|
||||
logger.debug(`fd found ${files.length} files`);
|
||||
|
||||
return this.processFilePaths(files, options, 'fd');
|
||||
} catch (error) {
|
||||
logger.error('fd search failed:', error);
|
||||
this.currentTool = await this.fallbackToNextTool('fd');
|
||||
logger.warn(`fd failed, falling back to: ${this.currentTool}`);
|
||||
return this.searchWithUnixTool(this.currentTool, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search using find (Unix standard tool)
|
||||
* @param options Search options
|
||||
* @returns Search results
|
||||
*/
|
||||
protected async searchWithFind(options: SearchOptions): Promise<FileResult[]> {
|
||||
const searchDir = options.onlyIn || os.homedir() || '/';
|
||||
const limit = options.limit || 30;
|
||||
|
||||
logger.debug('Performing find search', { keywords: options.keywords, searchDir });
|
||||
|
||||
try {
|
||||
const args: string[] = [searchDir];
|
||||
|
||||
// Limit depth and exclude common directories
|
||||
args.push(
|
||||
'-maxdepth',
|
||||
'10',
|
||||
'-type',
|
||||
'f',
|
||||
'(',
|
||||
'-path',
|
||||
'*/node_modules/*',
|
||||
'-o',
|
||||
'-path',
|
||||
'*/.git/*',
|
||||
'-o',
|
||||
'-path',
|
||||
'*/*cache*/*',
|
||||
')',
|
||||
'-prune',
|
||||
'-o',
|
||||
);
|
||||
|
||||
// Pattern matching
|
||||
if (options.keywords) {
|
||||
args.push('-iname', `*${options.keywords}*`);
|
||||
}
|
||||
|
||||
args.push('-print');
|
||||
|
||||
const { stdout, exitCode } = await execa('find', args, {
|
||||
reject: false,
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
if (exitCode !== 0 && !stdout.trim()) {
|
||||
logger.warn(`find search failed with code ${exitCode}, falling back to fast-glob`);
|
||||
this.currentTool = 'fast-glob';
|
||||
return this.searchWithFastGlob(options);
|
||||
}
|
||||
|
||||
const files = stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.trim())
|
||||
.slice(0, limit);
|
||||
|
||||
logger.debug(`find found ${files.length} files`);
|
||||
|
||||
return this.processFilePaths(files, options, 'find');
|
||||
} catch (error) {
|
||||
logger.error('find search failed:', error);
|
||||
this.currentTool = 'fast-glob';
|
||||
logger.warn('find failed, falling back to fast-glob');
|
||||
return this.searchWithFastGlob(options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search using fast-glob (pure Node.js implementation)
|
||||
* @param options Search options
|
||||
* @returns Search results
|
||||
*/
|
||||
protected async searchWithFastGlob(options: SearchOptions): Promise<FileResult[]> {
|
||||
const searchDir = options.onlyIn || os.homedir() || '/';
|
||||
const limit = options.limit || 30;
|
||||
|
||||
logger.debug('Performing fast-glob search', { keywords: options.keywords, searchDir });
|
||||
|
||||
try {
|
||||
// Build glob pattern from keywords
|
||||
const pattern = options.keywords
|
||||
? `**/*${this.escapeGlobPattern(options.keywords)}*`
|
||||
: '**/*';
|
||||
|
||||
const files = await fg(pattern, {
|
||||
absolute: true,
|
||||
caseSensitiveMatch: false,
|
||||
cwd: searchDir,
|
||||
deep: 10, // Limit depth for performance
|
||||
dot: true,
|
||||
ignore: this.getDefaultIgnorePatterns(),
|
||||
onlyFiles: true,
|
||||
suppressErrors: true,
|
||||
});
|
||||
|
||||
logger.debug(`fast-glob found ${files.length} files matching pattern`);
|
||||
|
||||
const limitedFiles = files.slice(0, limit);
|
||||
return this.processFilePaths(limitedFiles, options, 'fast-glob');
|
||||
} catch (error) {
|
||||
logger.error('fast-glob search failed:', error);
|
||||
throw new Error(`File search failed: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default ignore patterns for fast-glob
|
||||
* Can be overridden by subclasses for platform-specific patterns
|
||||
* @returns Array of ignore patterns
|
||||
*/
|
||||
protected getDefaultIgnorePatterns(): string[] {
|
||||
return ['**/node_modules/**', '**/.git/**', '**/.*cache*/**'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform glob pattern matching
|
||||
* Uses fd > find > fast-glob fallback strategy
|
||||
* @param params Glob parameters
|
||||
* @returns Promise of glob result
|
||||
*/
|
||||
async glob(params: GlobFilesParams): Promise<GlobFilesResult> {
|
||||
// Determine the best available tool
|
||||
const tool = await this.determineBestUnixTool();
|
||||
logger.info(`Using glob tool: ${tool}`);
|
||||
|
||||
return this.globWithUnixTool(tool, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Glob using the specified Unix tool
|
||||
* @param tool Tool to use for glob
|
||||
* @param params Glob parameters
|
||||
* @returns Glob results
|
||||
*/
|
||||
protected async globWithUnixTool(
|
||||
tool: UnixSearchTool,
|
||||
params: GlobFilesParams,
|
||||
): Promise<GlobFilesResult> {
|
||||
switch (tool) {
|
||||
case 'fd': {
|
||||
return this.globWithFd(params);
|
||||
}
|
||||
case 'find': {
|
||||
return this.globWithFind(params);
|
||||
}
|
||||
default: {
|
||||
return this.globWithFastGlob(params);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Glob using fd
|
||||
* @param params Glob parameters
|
||||
* @returns Glob results
|
||||
*/
|
||||
protected async globWithFd(params: GlobFilesParams): Promise<GlobFilesResult> {
|
||||
const searchPath = params.path || process.cwd();
|
||||
const logPrefix = `[glob:fd: ${params.pattern}]`;
|
||||
|
||||
logger.debug(`${logPrefix} Starting fd glob`, { searchPath });
|
||||
|
||||
try {
|
||||
const args: string[] = [
|
||||
'--glob',
|
||||
params.pattern,
|
||||
searchPath,
|
||||
'--absolute-path',
|
||||
'--hidden',
|
||||
'--no-ignore',
|
||||
];
|
||||
|
||||
const { stdout, exitCode } = await execa('fd', args, {
|
||||
reject: false,
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
if (exitCode !== 0 && !stdout.trim()) {
|
||||
logger.warn(`${logPrefix} fd glob failed with code ${exitCode}, falling back to find`);
|
||||
return this.globWithFind(params);
|
||||
}
|
||||
|
||||
const files = stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.trim());
|
||||
|
||||
// Get stats for sorting by mtime
|
||||
const filesWithStats = await this.getFilesWithStats(files);
|
||||
const sortedFiles = filesWithStats.sort((a, b) => b.mtime - a.mtime).map((f) => f.path);
|
||||
|
||||
logger.info(`${logPrefix} Glob completed`, { fileCount: sortedFiles.length });
|
||||
|
||||
return {
|
||||
engine: 'fd',
|
||||
files: sortedFiles,
|
||||
success: true,
|
||||
total_files: sortedFiles.length,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} fd glob failed:`, error);
|
||||
logger.warn(`${logPrefix} Falling back to find`);
|
||||
return this.globWithFind(params);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Glob using find
|
||||
* Note: find has limited glob support, converts pattern to -name/-path
|
||||
* @param params Glob parameters
|
||||
* @returns Glob results
|
||||
*/
|
||||
protected async globWithFind(params: GlobFilesParams): Promise<GlobFilesResult> {
|
||||
const searchPath = params.path || process.cwd();
|
||||
const logPrefix = `[glob:find: ${params.pattern}]`;
|
||||
|
||||
logger.debug(`${logPrefix} Starting find glob`, { searchPath });
|
||||
|
||||
try {
|
||||
// Convert glob pattern to find -name pattern
|
||||
// find doesn't support full glob, so we do basic conversion
|
||||
const pattern = params.pattern;
|
||||
const args: string[] = [searchPath];
|
||||
|
||||
// Check if pattern contains directory separators
|
||||
if (pattern.includes('/')) {
|
||||
// Use -path for patterns with directories
|
||||
args.push('-path', pattern);
|
||||
} else {
|
||||
// Use -name for simple patterns
|
||||
args.push('-name', pattern);
|
||||
}
|
||||
|
||||
const { stdout, exitCode } = await execa('find', args, {
|
||||
reject: false,
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
if (exitCode !== 0 && !stdout.trim()) {
|
||||
logger.warn(
|
||||
`${logPrefix} find glob failed with code ${exitCode}, falling back to fast-glob`,
|
||||
);
|
||||
return this.globWithFastGlob(params);
|
||||
}
|
||||
|
||||
const files = stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.trim());
|
||||
|
||||
// Get stats for sorting by mtime
|
||||
const filesWithStats = await this.getFilesWithStats(files);
|
||||
const sortedFiles = filesWithStats.sort((a, b) => b.mtime - a.mtime).map((f) => f.path);
|
||||
|
||||
logger.info(`${logPrefix} Glob completed`, { fileCount: sortedFiles.length });
|
||||
|
||||
return {
|
||||
engine: 'find',
|
||||
files: sortedFiles,
|
||||
success: true,
|
||||
total_files: sortedFiles.length,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} find glob failed:`, error);
|
||||
logger.warn(`${logPrefix} Falling back to fast-glob`);
|
||||
return this.globWithFastGlob(params);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Glob using fast-glob (Node.js fallback)
|
||||
* @param params Glob parameters
|
||||
* @returns Glob results
|
||||
*/
|
||||
protected async globWithFastGlob(params: GlobFilesParams): Promise<GlobFilesResult> {
|
||||
const searchPath = params.path || process.cwd();
|
||||
const logPrefix = `[glob:fast-glob: ${params.pattern}]`;
|
||||
|
||||
logger.debug(`${logPrefix} Starting fast-glob`, { searchPath });
|
||||
|
||||
try {
|
||||
const files = await fg(params.pattern, {
|
||||
absolute: true,
|
||||
cwd: searchPath,
|
||||
dot: true,
|
||||
onlyFiles: false,
|
||||
stats: true,
|
||||
});
|
||||
|
||||
// Sort by modification time (most recent first)
|
||||
const sortedFiles = (files as unknown as Array<{ path: string; stats: Stats }>)
|
||||
.sort((a, b) => b.stats.mtime.getTime() - a.stats.mtime.getTime())
|
||||
.map((f) => f.path);
|
||||
|
||||
logger.info(`${logPrefix} Glob completed`, { fileCount: sortedFiles.length });
|
||||
|
||||
return {
|
||||
engine: 'fast-glob',
|
||||
files: sortedFiles,
|
||||
success: true,
|
||||
total_files: sortedFiles.length,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Glob failed:`, error);
|
||||
return {
|
||||
engine: 'fast-glob',
|
||||
error: (error as Error).message,
|
||||
files: [],
|
||||
success: false,
|
||||
total_files: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file stats for sorting
|
||||
* @param files File paths
|
||||
* @returns Files with mtime
|
||||
*/
|
||||
private async getFilesWithStats(
|
||||
files: string[],
|
||||
): Promise<Array<{ mtime: number; path: string }>> {
|
||||
const results: Array<{ mtime: number; path: string }> = [];
|
||||
|
||||
for (const filePath of files) {
|
||||
try {
|
||||
const stats = await stat(filePath);
|
||||
results.push({ mtime: stats.mtime.getTime(), path: filePath });
|
||||
} catch {
|
||||
// Skip files that can't be stat'd
|
||||
results.push({ mtime: 0, path: filePath });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
/* eslint-disable unicorn/no-array-push-push */
|
||||
import { GlobFilesParams, GlobFilesResult } from '@lobechat/electron-client-ipc';
|
||||
import { execa } from 'execa';
|
||||
import fg from 'fast-glob';
|
||||
import { Stats } from 'node:fs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import * as os from 'node:os';
|
||||
|
||||
import { ToolDetectorManager } from '@/core/infrastructure/ToolDetectorManager';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { BaseFileSearch } from '../base';
|
||||
import { FileResult, SearchOptions } from '../types';
|
||||
|
||||
const logger = createLogger('module:FileSearch:windows');
|
||||
|
||||
/**
|
||||
* Fallback tool type for Windows file search
|
||||
* Priority: fd > powershell > fast-glob
|
||||
*/
|
||||
type WindowsFallbackTool = 'fd' | 'powershell' | 'fast-glob';
|
||||
|
||||
/**
|
||||
* Windows file search implementation
|
||||
* Uses fd > PowerShell > fast-glob fallback strategy
|
||||
*/
|
||||
export class WindowsSearchServiceImpl extends BaseFileSearch {
|
||||
/**
|
||||
* Current fallback tool being used
|
||||
*/
|
||||
private currentTool: WindowsFallbackTool | null = null;
|
||||
|
||||
constructor(toolDetectorManager?: ToolDetectorManager) {
|
||||
super(toolDetectorManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform file search
|
||||
* @param options Search options
|
||||
* @returns Promise of search result list
|
||||
*/
|
||||
async search(options: SearchOptions): Promise<FileResult[]> {
|
||||
// Determine the best available tool on first search
|
||||
if (this.currentTool === null) {
|
||||
this.currentTool = await this.determineBestTool();
|
||||
logger.info(`Using file search tool: ${this.currentTool}`);
|
||||
}
|
||||
|
||||
return this.searchWithTool(this.currentTool, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the best available tool based on priority
|
||||
* Priority: fd > powershell > fast-glob
|
||||
*/
|
||||
private async determineBestTool(): Promise<WindowsFallbackTool> {
|
||||
if (this.toolDetectorManager) {
|
||||
const bestTool = await this.toolDetectorManager.getBestTool('file-search');
|
||||
if (bestTool && ['fd', 'powershell'].includes(bestTool)) {
|
||||
return bestTool as WindowsFallbackTool;
|
||||
}
|
||||
}
|
||||
|
||||
if (await this.checkToolAvailable('fd')) {
|
||||
return 'fd';
|
||||
}
|
||||
|
||||
// PowerShell is always available on Windows
|
||||
return 'powershell';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool is available using 'where' command (Windows equivalent of 'which')
|
||||
* @param tool Tool name to check
|
||||
* @returns Promise indicating if tool is available
|
||||
*/
|
||||
private async checkToolAvailable(tool: string): Promise<boolean> {
|
||||
try {
|
||||
await execa('where', [tool], { timeout: 3000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search using the specified tool
|
||||
*/
|
||||
private async searchWithTool(
|
||||
tool: WindowsFallbackTool,
|
||||
options: SearchOptions,
|
||||
): Promise<FileResult[]> {
|
||||
switch (tool) {
|
||||
case 'fd': {
|
||||
return this.searchWithFd(options);
|
||||
}
|
||||
case 'powershell': {
|
||||
return this.searchWithPowerShell(options);
|
||||
}
|
||||
default: {
|
||||
return this.searchWithFastGlob(options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback to the next available tool
|
||||
*/
|
||||
private async fallbackToNextTool(currentTool: WindowsFallbackTool): Promise<WindowsFallbackTool> {
|
||||
const priority: WindowsFallbackTool[] = ['fd', 'powershell', 'fast-glob'];
|
||||
const currentIndex = priority.indexOf(currentTool);
|
||||
|
||||
for (let i = currentIndex + 1; i < priority.length; i++) {
|
||||
const nextTool = priority[i];
|
||||
if (nextTool === 'fast-glob' || nextTool === 'powershell') {
|
||||
return nextTool; // Always available
|
||||
}
|
||||
if (await this.checkToolAvailable(nextTool)) {
|
||||
return nextTool;
|
||||
}
|
||||
}
|
||||
|
||||
return 'fast-glob';
|
||||
}
|
||||
|
||||
/**
|
||||
* Search using fd (cross-platform fast find alternative)
|
||||
* @param options Search options
|
||||
* @returns Search results
|
||||
*/
|
||||
private async searchWithFd(options: SearchOptions): Promise<FileResult[]> {
|
||||
const searchDir = options.onlyIn || os.homedir() || 'C:\\';
|
||||
const limit = options.limit || 30;
|
||||
|
||||
logger.debug('Performing fd search', { keywords: options.keywords, searchDir });
|
||||
|
||||
try {
|
||||
const args: string[] = [];
|
||||
|
||||
// Pattern matching
|
||||
if (options.keywords) {
|
||||
args.push(options.keywords);
|
||||
} else {
|
||||
args.push('.'); // Match all files
|
||||
}
|
||||
|
||||
// Search directory and options
|
||||
args.push(searchDir, '--type', 'f', '--hidden', '--ignore-case', '--max-depth', '10');
|
||||
args.push(
|
||||
'--max-results',
|
||||
String(limit),
|
||||
'--exclude',
|
||||
'node_modules',
|
||||
'--exclude',
|
||||
'.git',
|
||||
'--exclude',
|
||||
'*cache*',
|
||||
);
|
||||
|
||||
const { stdout, exitCode } = await execa('fd', args, {
|
||||
reject: false,
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
if (exitCode !== 0 && !stdout.trim()) {
|
||||
logger.warn(`fd search failed with code ${exitCode}, falling back to next tool`);
|
||||
this.currentTool = await this.fallbackToNextTool('fd');
|
||||
return this.searchWithTool(this.currentTool, options);
|
||||
}
|
||||
|
||||
const files = stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.trim());
|
||||
|
||||
logger.debug(`fd found ${files.length} files`);
|
||||
|
||||
return this.processFilePaths(files, options, 'fd');
|
||||
} catch (error) {
|
||||
logger.error('fd search failed:', error);
|
||||
this.currentTool = await this.fallbackToNextTool('fd');
|
||||
logger.warn(`fd failed, falling back to: ${this.currentTool}`);
|
||||
return this.searchWithTool(this.currentTool, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search using PowerShell Get-ChildItem
|
||||
* @param options Search options
|
||||
* @returns Search results
|
||||
*/
|
||||
private async searchWithPowerShell(options: SearchOptions): Promise<FileResult[]> {
|
||||
const searchDir = options.onlyIn || os.homedir() || 'C:\\';
|
||||
const limit = options.limit || 30;
|
||||
|
||||
logger.debug('Performing PowerShell search', { keywords: options.keywords, searchDir });
|
||||
|
||||
try {
|
||||
// Build PowerShell command
|
||||
const filter = options.keywords ? `*${options.keywords}*` : '*';
|
||||
|
||||
// PowerShell command to search files
|
||||
// -Recurse: recursive search
|
||||
// -File: only files
|
||||
// -Depth: limit search depth
|
||||
// -ErrorAction SilentlyContinue: ignore permission errors
|
||||
const psCommand = `
|
||||
Get-ChildItem -Path '${searchDir}' -Filter '${filter}' -Recurse -File -Depth 10 -ErrorAction SilentlyContinue |
|
||||
Where-Object {
|
||||
$_.FullName -notlike '*\\node_modules\\*' -and
|
||||
$_.FullName -notlike '*\\.git\\*' -and
|
||||
$_.FullName -notlike '*\\AppData\\Local\\Temp\\*' -and
|
||||
$_.FullName -notlike '*\\$Recycle.Bin\\*'
|
||||
} |
|
||||
Select-Object -First ${limit} -ExpandProperty FullName
|
||||
`;
|
||||
|
||||
const { stdout, exitCode } = await execa(
|
||||
'powershell',
|
||||
['-NoProfile', '-Command', psCommand],
|
||||
{
|
||||
reject: false,
|
||||
timeout: 30_000,
|
||||
},
|
||||
);
|
||||
|
||||
if (exitCode !== 0 && !stdout.trim()) {
|
||||
logger.warn(`PowerShell search failed with code ${exitCode}, falling back to fast-glob`);
|
||||
this.currentTool = 'fast-glob';
|
||||
return this.searchWithFastGlob(options);
|
||||
}
|
||||
|
||||
const files = stdout
|
||||
.trim()
|
||||
.split('\r\n')
|
||||
.filter((line) => line.trim());
|
||||
|
||||
logger.debug(`PowerShell found ${files.length} files`);
|
||||
|
||||
return this.processFilePaths(files, options, 'powershell');
|
||||
} catch (error) {
|
||||
logger.error('PowerShell search failed:', error);
|
||||
this.currentTool = 'fast-glob';
|
||||
logger.warn('PowerShell failed, falling back to fast-glob');
|
||||
return this.searchWithFastGlob(options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search using fast-glob (pure Node.js implementation)
|
||||
* @param options Search options
|
||||
* @returns Search results
|
||||
*/
|
||||
private async searchWithFastGlob(options: SearchOptions): Promise<FileResult[]> {
|
||||
const searchDir = options.onlyIn || os.homedir() || 'C:\\';
|
||||
const limit = options.limit || 30;
|
||||
|
||||
logger.debug('Performing fast-glob search', { keywords: options.keywords, searchDir });
|
||||
|
||||
try {
|
||||
// Build glob pattern from keywords
|
||||
const pattern = options.keywords
|
||||
? `**/*${this.escapeGlobPattern(options.keywords)}*`
|
||||
: '**/*';
|
||||
|
||||
const files = await fg(pattern, {
|
||||
absolute: true,
|
||||
caseSensitiveMatch: false,
|
||||
cwd: searchDir,
|
||||
deep: 10,
|
||||
dot: false, // Windows hidden files use attributes, not dot prefix
|
||||
ignore: [
|
||||
'**/node_modules/**',
|
||||
'**/.git/**',
|
||||
'**/AppData/Local/Temp/**',
|
||||
'**/AppData/Local/Microsoft/**',
|
||||
'**/$Recycle.Bin/**',
|
||||
'**/Windows/**',
|
||||
'**/Program Files/**',
|
||||
'**/Program Files (x86)/**',
|
||||
],
|
||||
onlyFiles: true,
|
||||
suppressErrors: true,
|
||||
});
|
||||
|
||||
logger.debug(`fast-glob found ${files.length} files matching pattern`);
|
||||
|
||||
const limitedFiles = files.slice(0, limit);
|
||||
return this.processFilePaths(limitedFiles, options, 'fast-glob');
|
||||
} catch (error) {
|
||||
logger.error('fast-glob search failed:', error);
|
||||
throw new Error(`File search failed: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check search service status
|
||||
* @returns Promise indicating if service is available (always true)
|
||||
*/
|
||||
async checkSearchServiceStatus(): Promise<boolean> {
|
||||
// At minimum, fast-glob is always available
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update search index
|
||||
* Windows Search index is managed by the OS
|
||||
* @returns Promise indicating operation result (always false)
|
||||
*/
|
||||
async updateSearchIndex(): Promise<boolean> {
|
||||
logger.warn('updateSearchIndex is not supported (using fast-glob instead of Windows Search)');
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform glob pattern matching
|
||||
* Uses fd > fast-glob fallback strategy
|
||||
* @param params Glob parameters
|
||||
* @returns Promise of glob result
|
||||
*/
|
||||
async glob(params: GlobFilesParams): Promise<GlobFilesResult> {
|
||||
// Check if fd is available
|
||||
if (await this.checkToolAvailable('fd')) {
|
||||
logger.info('Using glob tool: fd');
|
||||
return this.globWithFd(params);
|
||||
}
|
||||
|
||||
logger.info('Using glob tool: fast-glob');
|
||||
return this.globWithFastGlob(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Glob using fd
|
||||
* @param params Glob parameters
|
||||
* @returns Glob results
|
||||
*/
|
||||
private async globWithFd(params: GlobFilesParams): Promise<GlobFilesResult> {
|
||||
const searchPath = params.path || process.cwd();
|
||||
const logPrefix = `[glob:fd: ${params.pattern}]`;
|
||||
|
||||
logger.debug(`${logPrefix} Starting fd glob`, { searchPath });
|
||||
|
||||
try {
|
||||
const args: string[] = [
|
||||
'--glob',
|
||||
params.pattern,
|
||||
searchPath,
|
||||
'--absolute-path',
|
||||
'--hidden',
|
||||
'--no-ignore',
|
||||
];
|
||||
|
||||
const { stdout, exitCode } = await execa('fd', args, {
|
||||
reject: false,
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
if (exitCode !== 0 && !stdout.trim()) {
|
||||
logger.warn(`${logPrefix} fd glob failed with code ${exitCode}, falling back to fast-glob`);
|
||||
return this.globWithFastGlob(params);
|
||||
}
|
||||
|
||||
const files = stdout
|
||||
.trim()
|
||||
.split('\r\n') // Windows uses \r\n
|
||||
.filter((line) => line.trim());
|
||||
|
||||
// Get stats for sorting by mtime
|
||||
const filesWithStats = await this.getFilesWithStats(files);
|
||||
const sortedFiles = filesWithStats.sort((a, b) => b.mtime - a.mtime).map((f) => f.path);
|
||||
|
||||
logger.info(`${logPrefix} Glob completed`, { fileCount: sortedFiles.length });
|
||||
|
||||
return {
|
||||
engine: 'fd',
|
||||
files: sortedFiles,
|
||||
success: true,
|
||||
total_files: sortedFiles.length,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} fd glob failed:`, error);
|
||||
logger.warn(`${logPrefix} Falling back to fast-glob`);
|
||||
return this.globWithFastGlob(params);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Glob using fast-glob (Node.js fallback)
|
||||
* @param params Glob parameters
|
||||
* @returns Glob results
|
||||
*/
|
||||
private async globWithFastGlob(params: GlobFilesParams): Promise<GlobFilesResult> {
|
||||
const searchPath = params.path || process.cwd();
|
||||
const logPrefix = `[glob:fast-glob: ${params.pattern}]`;
|
||||
|
||||
logger.debug(`${logPrefix} Starting fast-glob`, { searchPath });
|
||||
|
||||
try {
|
||||
const files = await fg(params.pattern, {
|
||||
absolute: true,
|
||||
cwd: searchPath,
|
||||
dot: false, // Windows hidden files use attributes, not dot prefix
|
||||
onlyFiles: false,
|
||||
stats: true,
|
||||
});
|
||||
|
||||
// Sort by modification time (most recent first)
|
||||
const sortedFiles = (files as unknown as Array<{ path: string; stats: Stats }>)
|
||||
.sort((a, b) => b.stats.mtime.getTime() - a.stats.mtime.getTime())
|
||||
.map((f) => f.path);
|
||||
|
||||
logger.info(`${logPrefix} Glob completed`, { fileCount: sortedFiles.length });
|
||||
|
||||
return {
|
||||
engine: 'fast-glob',
|
||||
files: sortedFiles,
|
||||
success: true,
|
||||
total_files: sortedFiles.length,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Glob failed:`, error);
|
||||
return {
|
||||
engine: 'fast-glob',
|
||||
error: (error as Error).message,
|
||||
files: [],
|
||||
success: false,
|
||||
total_files: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file stats for sorting
|
||||
* @param files File paths
|
||||
* @returns Files with mtime
|
||||
*/
|
||||
private async getFilesWithStats(
|
||||
files: string[],
|
||||
): Promise<Array<{ mtime: number; path: string }>> {
|
||||
const results: Array<{ mtime: number; path: string }> = [];
|
||||
|
||||
for (const filePath of files) {
|
||||
try {
|
||||
const stats = await stat(filePath);
|
||||
results.push({ mtime: stats.mtime.getTime(), path: filePath });
|
||||
} catch {
|
||||
// Skip files that can't be stat'd
|
||||
results.push({ mtime: 0, path: filePath });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,31 @@
|
||||
import { platform } from 'node:os';
|
||||
|
||||
import { MacOSSearchServiceImpl } from './impl/macOS';
|
||||
import { ToolDetectorManager } from '@/core/infrastructure/ToolDetectorManager';
|
||||
|
||||
export const createFileSearchModule = () => {
|
||||
import { LinuxSearchServiceImpl } from './impl/linux';
|
||||
import { MacOSSearchServiceImpl } from './impl/macOS';
|
||||
import { WindowsSearchServiceImpl } from './impl/windows';
|
||||
|
||||
export { BaseFileSearch } from './base';
|
||||
export type { FileResult, SearchOptions } from './types';
|
||||
|
||||
export const createFileSearchModule = (toolDetectorManager?: ToolDetectorManager) => {
|
||||
const currentPlatform = platform();
|
||||
|
||||
switch (currentPlatform) {
|
||||
case 'darwin': {
|
||||
return new MacOSSearchServiceImpl();
|
||||
return new MacOSSearchServiceImpl(toolDetectorManager);
|
||||
}
|
||||
case 'win32': {
|
||||
return new WindowsSearchServiceImpl(toolDetectorManager);
|
||||
}
|
||||
case 'linux': {
|
||||
return new LinuxSearchServiceImpl(toolDetectorManager);
|
||||
}
|
||||
// case 'win32':
|
||||
// return new WindowsSearchServiceImpl();
|
||||
// case 'linux':
|
||||
// return new LinuxSearchServiceImpl();
|
||||
default: {
|
||||
return new MacOSSearchServiceImpl();
|
||||
// throw new Error(`Unsupported platform: ${currentPlatform}`);
|
||||
// Fallback to Linux implementation (uses fast-glob, no external dependencies)
|
||||
console.warn(`Unsupported platform: ${currentPlatform}, using Linux fallback`);
|
||||
return new LinuxSearchServiceImpl(toolDetectorManager);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export { FileSearchImpl } from './type';
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { FileResult, SearchOptions } from '@/types/fileSearch';
|
||||
|
||||
/**
|
||||
* File Search Service Implementation Abstract Class
|
||||
* Defines the interface that different platform file search implementations need to implement
|
||||
*/
|
||||
export abstract class FileSearchImpl {
|
||||
/**
|
||||
* Perform file search
|
||||
* @param options Search options
|
||||
* @returns Promise of search result list
|
||||
*/
|
||||
abstract search(options: SearchOptions): Promise<FileResult[]>;
|
||||
|
||||
/**
|
||||
* Check search service status
|
||||
* @returns Promise indicating if service is available
|
||||
*/
|
||||
abstract checkSearchServiceStatus(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Update search index
|
||||
* @param path Optional specified path
|
||||
* @returns Promise indicating operation success
|
||||
*/
|
||||
abstract updateSearchIndex(path?: string): Promise<boolean>;
|
||||
}
|
||||
+2
@@ -1,6 +1,8 @@
|
||||
export interface FileResult {
|
||||
contentType?: string;
|
||||
createdTime: Date;
|
||||
// Search engine used to find this file (e.g., 'mdfind', 'fd', 'find', 'fast-glob')
|
||||
engine?: string;
|
||||
isDirectory: boolean;
|
||||
lastAccessTime: Date;
|
||||
// Spotlight specific metadata
|
||||
@@ -0,0 +1,53 @@
|
||||
import { IToolDetector, createCommandDetector } from '@/core/infrastructure/ToolDetectorManager';
|
||||
|
||||
/**
|
||||
* Content search tool detectors
|
||||
*
|
||||
* Priority order: rg (1) > ag (2) > grep (3)
|
||||
* AST search: sg (ast-grep) - separate category for AST-based code search
|
||||
*/
|
||||
|
||||
/**
|
||||
* ripgrep (rg) - Fastest grep alternative
|
||||
* https://github.com/BurntSushi/ripgrep
|
||||
*/
|
||||
export const ripgrepDetector: IToolDetector = createCommandDetector('rg', {
|
||||
description: 'ripgrep - fast grep alternative',
|
||||
priority: 1,
|
||||
});
|
||||
|
||||
/**
|
||||
* ast-grep (sg) - AST-based code search tool
|
||||
* https://ast-grep.github.io/
|
||||
*/
|
||||
export const astGrepDetector: IToolDetector = createCommandDetector('sg', {
|
||||
description: 'ast-grep - AST-based code search',
|
||||
priority: 1,
|
||||
});
|
||||
|
||||
/**
|
||||
* The Silver Searcher (ag) - Fast code searching tool
|
||||
* https://github.com/ggreer/the_silver_searcher
|
||||
*/
|
||||
export const agDetector: IToolDetector = createCommandDetector('ag', {
|
||||
description: 'The Silver Searcher',
|
||||
priority: 2,
|
||||
});
|
||||
|
||||
/**
|
||||
* GNU grep - Standard text search tool
|
||||
*/
|
||||
export const grepDetector: IToolDetector = createCommandDetector('grep', {
|
||||
description: 'GNU grep',
|
||||
priority: 3,
|
||||
});
|
||||
|
||||
/**
|
||||
* All content search detectors (text-based grep tools)
|
||||
*/
|
||||
export const contentSearchDetectors: IToolDetector[] = [ripgrepDetector, agDetector, grepDetector];
|
||||
|
||||
/**
|
||||
* AST-based code search detectors
|
||||
*/
|
||||
export const astSearchDetectors: IToolDetector[] = [astGrepDetector];
|
||||
@@ -0,0 +1,84 @@
|
||||
import { exec } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import {
|
||||
IToolDetector,
|
||||
ToolStatus,
|
||||
createCommandDetector,
|
||||
} from '@/core/infrastructure/ToolDetectorManager';
|
||||
|
||||
const execPromise = promisify(exec);
|
||||
|
||||
/**
|
||||
* File search tool detectors
|
||||
*
|
||||
* Priority order: mdfind (1, macOS) > fd (2) > find (3)
|
||||
*/
|
||||
|
||||
/**
|
||||
* mdfind - macOS Spotlight search
|
||||
* Only available on macOS, uses Spotlight index for fast searching
|
||||
*/
|
||||
export const mdfindDetector: IToolDetector = {
|
||||
description: 'macOS Spotlight search',
|
||||
async detect(): Promise<ToolStatus> {
|
||||
// Only available on macOS
|
||||
if (process.platform !== 'darwin') {
|
||||
return {
|
||||
available: false,
|
||||
error: 'mdfind is only available on macOS',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if mdfind command exists and Spotlight is working
|
||||
const { stdout } = await execPromise('mdfind -name test -onlyin ~ -count', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// If mdfind returns a number (even 0), Spotlight is available
|
||||
const count = parseInt(stdout.trim(), 10);
|
||||
if (Number.isNaN(count)) {
|
||||
return {
|
||||
available: false,
|
||||
error: 'Spotlight returned invalid response',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
available: true,
|
||||
path: '/usr/bin/mdfind',
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
available: false,
|
||||
error: (error as Error).message,
|
||||
};
|
||||
}
|
||||
},
|
||||
name: 'mdfind',
|
||||
priority: 1,
|
||||
};
|
||||
|
||||
/**
|
||||
* fd - Fast alternative to find
|
||||
* https://github.com/sharkdp/fd
|
||||
*/
|
||||
export const fdDetector: IToolDetector = createCommandDetector('fd', {
|
||||
description: 'fd - fast find alternative',
|
||||
priority: 2,
|
||||
});
|
||||
|
||||
/**
|
||||
* find - Standard Unix file search
|
||||
*/
|
||||
export const findDetector: IToolDetector = createCommandDetector('find', {
|
||||
description: 'Unix find command',
|
||||
priority: 3,
|
||||
versionFlag: '--version', // GNU find supports this
|
||||
});
|
||||
|
||||
/**
|
||||
* All file search detectors
|
||||
*/
|
||||
export const fileSearchDetectors: IToolDetector[] = [mdfindDetector, fdDetector, findDetector];
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Tool Detectors Module
|
||||
*
|
||||
* This module provides built-in tool detectors for common system tools.
|
||||
* Modules can register additional custom detectors via ToolDetectorManager.
|
||||
*/
|
||||
|
||||
export { astSearchDetectors, contentSearchDetectors } from './contentSearchDetectors';
|
||||
export { fileSearchDetectors } from './fileSearchDetectors';
|
||||
|
||||
// Re-export types for convenience
|
||||
export type {
|
||||
IToolDetector,
|
||||
ToolCategory,
|
||||
ToolStatus,
|
||||
} from '@/core/infrastructure/ToolDetectorManager';
|
||||
export { createCommandDetector } from '@/core/infrastructure/ToolDetectorManager';
|
||||
@@ -1,8 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { App } from '@/core/App';
|
||||
import { FileSearchImpl } from '@/modules/fileSearch';
|
||||
import type { FileResult, SearchOptions } from '@/types/fileSearch';
|
||||
import type { FileResult, SearchOptions } from '@/modules/fileSearch';
|
||||
|
||||
import FileSearchService from '../fileSearchSrv';
|
||||
|
||||
@@ -15,7 +14,7 @@ vi.mock('@/modules/fileSearch', () => {
|
||||
}));
|
||||
|
||||
return {
|
||||
FileSearchImpl: vi.fn(),
|
||||
BaseFileSearch: vi.fn(),
|
||||
createFileSearchModule: vi.fn(() => new MockFileSearchImpl()),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { GrepContentParams, GrepContentResult } from '@lobechat/electron-client-ipc';
|
||||
|
||||
import { BaseContentSearch, createContentSearchImpl } from '@/modules/contentSearch';
|
||||
|
||||
import { ServiceModule } from './index';
|
||||
|
||||
/**
|
||||
* Content Search Service
|
||||
* Provides content search functionality using platform-specific implementations
|
||||
*/
|
||||
export default class ContentSearchService extends ServiceModule {
|
||||
private impl: BaseContentSearch = createContentSearchImpl();
|
||||
|
||||
/**
|
||||
* Perform content search (grep)
|
||||
*/
|
||||
async grep(params: GrepContentParams): Promise<GrepContentResult> {
|
||||
// Ensure toolDetectorManager is set
|
||||
if (this.app?.toolDetectorManager) {
|
||||
this.impl.setToolDetectorManager(this.app.toolDetectorManager);
|
||||
}
|
||||
return this.impl.grep(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific tool is available
|
||||
*/
|
||||
async checkToolAvailable(tool: string): Promise<boolean> {
|
||||
return this.impl.checkToolAvailable(tool);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import { FileSearchImpl, createFileSearchModule } from '@/modules/fileSearch';
|
||||
import { FileResult, SearchOptions } from '@/types/fileSearch';
|
||||
import { GlobFilesParams, GlobFilesResult } from '@lobechat/electron-client-ipc';
|
||||
|
||||
import {
|
||||
BaseFileSearch,
|
||||
FileResult,
|
||||
SearchOptions,
|
||||
createFileSearchModule,
|
||||
} from '@/modules/fileSearch';
|
||||
|
||||
import { ServiceModule } from './index';
|
||||
|
||||
@@ -8,12 +14,15 @@ import { ServiceModule } from './index';
|
||||
* Main service class that uses platform-specific implementations internally
|
||||
*/
|
||||
export default class FileSearchService extends ServiceModule {
|
||||
private impl: FileSearchImpl = createFileSearchModule();
|
||||
private impl: BaseFileSearch = createFileSearchModule();
|
||||
|
||||
/**
|
||||
* Perform file search
|
||||
*/
|
||||
async search(query: string, options: Omit<SearchOptions, 'keywords'> = {}): Promise<FileResult[]> {
|
||||
async search(
|
||||
query: string,
|
||||
options: Omit<SearchOptions, 'keywords'> = {},
|
||||
): Promise<FileResult[]> {
|
||||
return this.impl.search({ ...options, keywords: query });
|
||||
}
|
||||
|
||||
@@ -32,4 +41,13 @@ export default class FileSearchService extends ServiceModule {
|
||||
async updateSearchIndex(path?: string): Promise<boolean> {
|
||||
return this.impl.updateSearchIndex(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform glob pattern matching
|
||||
* @param params Glob parameters
|
||||
* @returns Promise of glob result
|
||||
*/
|
||||
async glob(params: GlobFilesParams): Promise<GlobFilesResult> {
|
||||
return this.impl.glob(params);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface ElectronMainStore {
|
||||
encryptedTokens: {
|
||||
accessToken?: string;
|
||||
expiresAt?: number;
|
||||
lastRefreshAt?: number;
|
||||
refreshToken?: string;
|
||||
};
|
||||
locale: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@journey @home @starter
|
||||
Feature: Home 页面 Starter 快捷创建功能
|
||||
作为用户,我希望在 Home 页面可以通过 Starter 快捷创建 Agent 或 Group,返回首页后在侧边栏中看到它
|
||||
作为用户,我希望在 Home 页面可以通过 Starter 快捷创建 Agent、Group 或文档,并跳转到对应页面
|
||||
|
||||
Background:
|
||||
Given 用户已登录系统
|
||||
@@ -32,3 +32,16 @@ Feature: Home 页面 Starter 快捷创建功能
|
||||
Then 页面应该跳转到 Group 的 profile 页面
|
||||
When 用户返回 Home 页面
|
||||
Then 新创建的 Group 应该在侧边栏中显示
|
||||
|
||||
# ============================================
|
||||
# 创建文档并跳转到写作页面
|
||||
# ============================================
|
||||
|
||||
@HOME-STARTER-WRITE-001 @P0
|
||||
Scenario: 通过 Home 页面快捷创建文档并跳转到写作页面
|
||||
Given 用户在 Home 页面
|
||||
When 用户点击写作按钮
|
||||
And 用户在输入框中输入 "帮我写一篇关于人工智能的文章"
|
||||
And 用户按 Enter 发送创建文档
|
||||
Then 页面应该跳转到文档编辑页面
|
||||
And Page Agent 应该收到用户的提示词
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
* Step definitions for Home page Starter E2E tests
|
||||
* - Create Agent from Home input
|
||||
* - Create Group from Home input
|
||||
* - Create Document (Write) from Home input
|
||||
* - Verify Agent/Group appears in sidebar after returning to Home
|
||||
* - Verify Document page navigation and Page Agent interaction
|
||||
*/
|
||||
import { Given, Then, When } from '@cucumber/cucumber';
|
||||
import { expect } from '@playwright/test';
|
||||
@@ -15,6 +17,7 @@ import { CustomWorld, WAIT_TIMEOUT } from '../../support/world';
|
||||
// Store created IDs for verification
|
||||
let createdAgentId: string | null = null;
|
||||
let createdGroupId: string | null = null;
|
||||
let createdDocumentId: string | null = null;
|
||||
|
||||
// ============================================
|
||||
// Given Steps
|
||||
@@ -22,9 +25,13 @@ let createdGroupId: string | null = null;
|
||||
|
||||
Given('用户在 Home 页面', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 设置 LLM mock...');
|
||||
// Setup LLM mock before navigation (for agent/group builder message)
|
||||
// Setup LLM mock before navigation (for agent/group/page builder message)
|
||||
llmMockManager.setResponse('E2E Test Agent', presetResponses.greeting);
|
||||
llmMockManager.setResponse('E2E Test Group', presetResponses.greeting);
|
||||
llmMockManager.setResponse(
|
||||
'帮我写一篇关于人工智能的文章',
|
||||
'好的,我来帮你写一篇关于人工智能的文章。\n\n# 人工智能:改变世界的技术\n\n人工智能(AI)是当今最具变革性的技术之一...',
|
||||
);
|
||||
await llmMockManager.setup(this.page);
|
||||
|
||||
console.log(' 📍 Step: 导航到 Home 页面...');
|
||||
@@ -35,6 +42,7 @@ Given('用户在 Home 页面', async function (this: CustomWorld) {
|
||||
// Reset IDs for each test
|
||||
createdAgentId = null;
|
||||
createdGroupId = null;
|
||||
createdDocumentId = null;
|
||||
|
||||
console.log(' ✅ 已进入 Home 页面');
|
||||
});
|
||||
@@ -73,6 +81,19 @@ When('用户点击创建 Group 按钮', async function (this: CustomWorld) {
|
||||
console.log(' ✅ 已点击创建 Group 按钮');
|
||||
});
|
||||
|
||||
When('用户点击写作按钮', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 点击写作按钮...');
|
||||
|
||||
// Find the "Write" button by text (supports both English and Chinese)
|
||||
const writeButton = this.page.getByRole('button', { name: /write|写作/i }).first();
|
||||
|
||||
await expect(writeButton).toBeVisible({ timeout: WAIT_TIMEOUT });
|
||||
await writeButton.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
console.log(' ✅ 已点击写作按钮');
|
||||
});
|
||||
|
||||
When('用户在输入框中输入 {string}', async function (this: CustomWorld, message: string) {
|
||||
console.log(` 📍 Step: 在输入框中输入 "${message}"...`);
|
||||
|
||||
@@ -135,6 +156,31 @@ When('用户按 Enter 发送', async function (this: CustomWorld) {
|
||||
console.log(' ✅ 已发送消息');
|
||||
});
|
||||
|
||||
When('用户按 Enter 发送创建文档', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 按 Enter 发送创建文档...');
|
||||
|
||||
// Listen for navigation to capture the document ID
|
||||
const navigationPromise = this.page.waitForURL(/\/page\/[^/]+/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await this.page.keyboard.press('Enter');
|
||||
|
||||
// Wait for navigation to page
|
||||
await navigationPromise;
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 15_000 });
|
||||
|
||||
// Extract document ID from URL
|
||||
const currentUrl = this.page.url();
|
||||
const pageMatch = currentUrl.match(/\/page\/([^/?]+)/);
|
||||
if (pageMatch) {
|
||||
createdDocumentId = pageMatch[1];
|
||||
console.log(` 📍 Created document ID: ${createdDocumentId}`);
|
||||
}
|
||||
|
||||
console.log(' ✅ 已发送并创建文档');
|
||||
});
|
||||
|
||||
When('用户返回 Home 页面', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 返回 Home 页面...');
|
||||
|
||||
@@ -214,3 +260,54 @@ Then('新创建的 Group 应该在侧边栏中显示', async function (this: Cus
|
||||
|
||||
console.log(' ✅ Group 已在侧边栏中显示');
|
||||
});
|
||||
|
||||
Then('页面应该跳转到文档编辑页面', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 验证页面跳转到文档编辑页面...');
|
||||
|
||||
// Check current URL matches /page/{id} pattern
|
||||
const currentUrl = this.page.url();
|
||||
expect(currentUrl).toMatch(/\/page\/[^/?]+/);
|
||||
|
||||
if (!createdDocumentId) {
|
||||
throw new Error('Document ID was not captured during creation');
|
||||
}
|
||||
|
||||
console.log(` ✅ 已跳转到文档编辑页面: /page/${createdDocumentId}`);
|
||||
});
|
||||
|
||||
Then('Page Agent 应该收到用户的提示词', async function (this: CustomWorld) {
|
||||
console.log(' 📍 Step: 验证 Page Agent 收到用户的提示词...');
|
||||
|
||||
// Wait for the page to fully load and Page Agent panel to appear
|
||||
await this.page.waitForTimeout(2000);
|
||||
|
||||
// Look for the user message in the chat panel (Page Agent Copilot)
|
||||
// The message should appear in the chat list
|
||||
const userMessage = this.page.locator('text=帮我写一篇关于人工智能的文章').first();
|
||||
|
||||
// The message might be in the chat panel on the right side
|
||||
const messageVisible = await userMessage.isVisible().catch(() => false);
|
||||
|
||||
if (messageVisible) {
|
||||
console.log(' ✅ 找到用户发送的提示词');
|
||||
} else {
|
||||
// Alternative: check if there's any chat content indicating the message was sent
|
||||
console.log(' ⚠️ 用户消息可能在聊天面板中,但未直接可见');
|
||||
}
|
||||
|
||||
// Verify that the Page Agent responded (mock response should appear)
|
||||
// Wait a bit longer for the mock LLM response
|
||||
await this.page.waitForTimeout(3000);
|
||||
|
||||
// Look for AI response content
|
||||
const aiResponse = this.page.locator('text=人工智能').first();
|
||||
const responseVisible = await aiResponse.isVisible().catch(() => false);
|
||||
|
||||
if (responseVisible) {
|
||||
console.log(' ✅ Page Agent 已响应用户的提示词');
|
||||
} else {
|
||||
console.log(' ⚠️ Page Agent 响应可能正在生成或在其他位置');
|
||||
}
|
||||
|
||||
console.log(' ✅ Page Agent 验证完成');
|
||||
});
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"betterAuth.signin.signupLink": "سجّل الآن",
|
||||
"betterAuth.signin.socialError": "فشل تسجيل الدخول الاجتماعي، يرجى المحاولة مرة أخرى",
|
||||
"betterAuth.signin.socialOnlyHint": "تم تسجيل هذا البريد الإلكتروني عبر حساب اجتماعي تابع لطرف ثالث. سجّل الدخول باستخدام ذلك المزود، أو",
|
||||
"betterAuth.signin.ssoOnlyNoProviders": "تم تعطيل التسجيل عبر البريد الإلكتروني ولم يتم إعداد أي موفري تسجيل دخول موحد (SSO). يرجى التواصل مع المسؤول.",
|
||||
"betterAuth.signin.submit": "تسجيل الدخول",
|
||||
"betterAuth.signup.confirmPasswordPlaceholder": "تأكيد كلمة المرور",
|
||||
"betterAuth.signup.emailPlaceholder": "أدخل عنوان بريدك الإلكتروني",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"codes.DELETED_ACCOUNT_EMAIL": "تم ربط هذا البريد الإلكتروني بحساب محذوف ولا يمكن استخدامه للتسجيل",
|
||||
"codes.EMAIL_CAN_NOT_BE_UPDATED": "لا يمكن تحديث البريد الإلكتروني لهذا الحساب",
|
||||
"codes.EMAIL_NOT_ALLOWED": "هذا البريد الإلكتروني غير مسموح به للتسجيل",
|
||||
"codes.EMAIL_NOT_FOUND": "لا يوجد بريد إلكتروني مرتبط بهذا الحساب. يرجى التحقق مما إذا كان حسابك مرتبطًا ببريد إلكتروني.",
|
||||
"codes.EMAIL_NOT_VERIFIED": "يرجى التحقق من بريدك الإلكتروني أولاً",
|
||||
"codes.FAILED_TO_CREATE_SESSION": "فشل في إنشاء الجلسة",
|
||||
"codes.FAILED_TO_CREATE_USER": "فشل في إنشاء المستخدم",
|
||||
|
||||
@@ -188,6 +188,7 @@
|
||||
"cmdk.submitIssue": "إرسال مشكلة",
|
||||
"cmdk.theme": "السمة",
|
||||
"cmdk.themeAuto": "تلقائي",
|
||||
"cmdk.themeCurrent": "الحالي",
|
||||
"cmdk.themeDark": "داكن",
|
||||
"cmdk.themeLight": "فاتح",
|
||||
"cmdk.toOpen": "فتح",
|
||||
|
||||
@@ -194,6 +194,9 @@
|
||||
"mcp.categories.weather.name": "الطقس",
|
||||
"mcp.categories.web-search.description": "البحث على الويب واسترجاع المعلومات",
|
||||
"mcp.categories.web-search.name": "استرجاع المعلومات",
|
||||
"mcp.details.agents.empty": "لا يوجد وكلاء يستخدمون هذه المهارة بعد",
|
||||
"mcp.details.agents.networkError": "فشل في تحميل البيانات. تحقق من الاتصال بالشبكة وحاول مرة أخرى.",
|
||||
"mcp.details.agents.title": "الوكلاء الذين يستخدمون هذه المهارة",
|
||||
"mcp.details.connectionType.hybrid.desc": "يمكن تشغيل هذه الخدمة محليًا أو عبر السحابة حسب الإعداد أو سيناريو الاستخدام، مما يوفر إمكانية التشغيل المزدوج.",
|
||||
"mcp.details.connectionType.hybrid.title": "خدمة هجينة",
|
||||
"mcp.details.connectionType.local.desc": "يمكن تشغيل هذا الخادم فقط على جهاز المستخدم المحلي، ويتطلب التثبيت ويعتمد على الموارد المحلية.",
|
||||
|
||||
@@ -16,13 +16,17 @@
|
||||
"navigation.memoryExperiences": "الذاكرة - التجارب",
|
||||
"navigation.memoryIdentities": "الذاكرة - الهويات",
|
||||
"navigation.memoryPreferences": "الذاكرة - التفضيلات",
|
||||
"navigation.noPages": "لا توجد صفحات بعد",
|
||||
"navigation.onboarding": "البدء",
|
||||
"navigation.page": "صفحة",
|
||||
"navigation.pages": "الصفحات",
|
||||
"navigation.pin": "تثبيت",
|
||||
"navigation.pinned": "مثبت",
|
||||
"navigation.provider": "المزود",
|
||||
"navigation.recentView": "المشاهدات الأخيرة",
|
||||
"navigation.resources": "الموارد",
|
||||
"navigation.settings": "الإعدادات",
|
||||
"navigation.unpin": "إلغاء التثبيت",
|
||||
"notification.finishChatGeneration": "اكتمل توليد الرسالة بواسطة الذكاء الاصطناعي",
|
||||
"proxy.auth": "يتطلب المصادقة",
|
||||
"proxy.authDesc": "إذا كان خادم البروكسي يتطلب اسم مستخدم وكلمة مرور",
|
||||
|
||||
@@ -176,6 +176,26 @@
|
||||
"providerModels.config.fetchOnClient.desc": "وضع طلب العميل سيبدأ الطلبات مباشرة من المتصفح، مما قد يحسن سرعة الاستجابة",
|
||||
"providerModels.config.fetchOnClient.title": "استخدام وضع طلب العميل",
|
||||
"providerModels.config.helpDoc": "دليل الإعداد",
|
||||
"providerModels.config.oauth.authError": "فشل التفويض. يرجى المحاولة مرة أخرى.",
|
||||
"providerModels.config.oauth.authorized": "تم التفويض",
|
||||
"providerModels.config.oauth.authorizedDesc": "لقد تم الاتصال بـ {{name}}. انقر لإلغاء الاتصال.",
|
||||
"providerModels.config.oauth.cancel": "إلغاء",
|
||||
"providerModels.config.oauth.codeExpired": "انتهت صلاحية رمز التفويض. يرجى المحاولة مرة أخرى.",
|
||||
"providerModels.config.oauth.connect": "الاتصال بـ {{name}}",
|
||||
"providerModels.config.oauth.connectDesc": "انقر للتفويض عبر المتصفح. لا حاجة لمفتاح API.",
|
||||
"providerModels.config.oauth.connected": "متصل",
|
||||
"providerModels.config.oauth.connecting": "جارٍ الاتصال...",
|
||||
"providerModels.config.oauth.copyCode": "نسخ الرمز",
|
||||
"providerModels.config.oauth.denied": "تم رفض التفويض. يرجى المحاولة مرة أخرى.",
|
||||
"providerModels.config.oauth.desc": "قم بالتفويض باستخدام حسابك على {{name}} للوصول إلى النماذج من خلال اشتراكك.",
|
||||
"providerModels.config.oauth.disconnect": "قطع الاتصال",
|
||||
"providerModels.config.oauth.disconnectConfirm": "هل أنت متأكد أنك تريد قطع الاتصال؟ ستحتاج إلى إعادة التفويض لاستخدام هذا المزود.",
|
||||
"providerModels.config.oauth.enterCode": "أدخل الرمز في الصفحة المفتوحة:",
|
||||
"providerModels.config.oauth.openBrowser": "افتح المتصفح للتفويض",
|
||||
"providerModels.config.oauth.polling": "في انتظار التفويض...",
|
||||
"providerModels.config.oauth.retry": "إعادة المحاولة",
|
||||
"providerModels.config.oauth.serviceNote": "الخدمة مقدمة من {{name}}",
|
||||
"providerModels.config.oauth.title": "تفويض OAuth",
|
||||
"providerModels.config.responsesApi.desc": "يستخدم تنسيق الطلب الجديد من OpenAI لتمكين ميزات متقدمة مثل سلسلة التفكير (مدعومة فقط من نماذج OpenAI)",
|
||||
"providerModels.config.responsesApi.title": "استخدام مواصفات Responses API",
|
||||
"providerModels.config.waitingForMore": "يتم حاليًا <1>التخطيط لإضافة المزيد من النماذج</1>، يرجى المتابعة للبقاء على اطلاع",
|
||||
|
||||
+97
-9
@@ -90,6 +90,7 @@
|
||||
"Phi-3-small-8k-instruct.description": "نموذج يحتوي على 7 مليارات معلمة بجودة أعلى من Phi-3-mini، يركز على البيانات عالية الجودة التي تتطلب استدلالًا مكثفًا.",
|
||||
"Phi-3.5-mini-instruct.description": "إصدار محدث من نموذج Phi-3-mini.",
|
||||
"Phi-3.5-vision-instrust.description": "إصدار محدث من نموذج Phi-3-vision.",
|
||||
"Pro/MiniMaxAI/MiniMax-M2.1.description": "MiniMax-M2.1 هو نموذج لغوي كبير مفتوح المصدر مُحسَّن لقدرات الوكلاء، ويتفوق في البرمجة، واستخدام الأدوات، واتباع التعليمات، والتخطيط طويل الأمد. يدعم النموذج تطوير البرمجيات متعددة اللغات وتنفيذ سير العمل المعقد متعدد الخطوات، وحقق نتيجة 74.0 على SWE-bench Verified متفوقًا على Claude Sonnet 4.5 في السيناريوهات متعددة اللغات.",
|
||||
"Pro/Qwen/Qwen2-7B-Instruct.description": "Qwen2-7B-Instruct هو نموذج لغوي كبير (LLM) موجه للتعليمات ضمن سلسلة Qwen2. يستخدم بنية Transformer مع SwiGLU، وانحياز QKV في الانتباه، وانتباه الاستعلامات المجمعة، ويعالج مدخلات كبيرة. يتميز بأداء قوي في فهم اللغة، التوليد، المهام متعددة اللغات، البرمجة، الرياضيات، والاستدلال، متفوقًا على معظم النماذج المفتوحة ومنافسًا للنماذج التجارية. يتفوق على Qwen1.5-7B-Chat في العديد من المعايير.",
|
||||
"Pro/Qwen/Qwen2.5-7B-Instruct.description": "Qwen2.5-7B-Instruct هو جزء من أحدث سلسلة نماذج لغوية كبيرة من Alibaba Cloud. يقدم هذا النموذج ذو 7 مليارات معلمة تحسينات ملحوظة في البرمجة والرياضيات، ويدعم أكثر من 29 لغة، ويعزز اتباع التعليمات، وفهم البيانات المنظمة، وإنتاج المخرجات المنظمة (خصوصًا JSON).",
|
||||
"Pro/Qwen/Qwen2.5-Coder-7B-Instruct.description": "Qwen2.5-Coder-7B-Instruct هو أحدث نموذج لغوي كبير من Alibaba Cloud يركز على البرمجة. مبني على Qwen2.5 ومدرب على 5.5 تريليون رمز، يعزز بشكل كبير توليد الشيفرة، الاستدلال، والإصلاح، مع الحفاظ على القوة في الرياضيات والقدرات العامة، مما يوفر أساسًا قويًا لوكلاء البرمجة.",
|
||||
@@ -278,7 +279,11 @@
|
||||
"claude-3-haiku-20240307.description": "Claude 3 Haiku هو أسرع وأصغر نموذج من Anthropic، مصمم لتقديم استجابات شبه فورية بأداء سريع ودقيق.",
|
||||
"claude-3-opus-20240229.description": "Claude 3 Opus هو أقوى نموذج من Anthropic للمهام المعقدة، يتميز بالأداء العالي، الذكاء، الطلاقة، والفهم.",
|
||||
"claude-3-sonnet-20240229.description": "Claude 3 Sonnet يوازن بين الذكاء والسرعة لتلبية احتياجات المؤسسات، ويوفر فائدة عالية بتكلفة أقل ونشر موثوق على نطاق واسع.",
|
||||
"claude-3.5-sonnet.description": "كلود 3.5 سونيت يتميز في البرمجة، والكتابة، والتفكير المعقد.",
|
||||
"claude-3.7-sonnet-thought.description": "كلود 3.7 سونيت مع تفكير ممتد لمهام الاستدلال المعقدة.",
|
||||
"claude-3.7-sonnet.description": "كلود 3.7 سونيت هو إصدار مطور بقدرات وسياق موسع.",
|
||||
"claude-haiku-4-5-20251001.description": "Claude Haiku 4.5 هو أسرع وأذكى نموذج Haiku من Anthropic، يتميز بسرعة فائقة وقدرة على التفكير المعمق.",
|
||||
"claude-haiku-4.5.description": "كلود هايكو 4.5 نموذج سريع وفعّال لمهام متنوعة.",
|
||||
"claude-opus-4-1-20250805-thinking.description": "Claude Opus 4.1 Thinking هو إصدار متقدم يمكنه عرض عملية تفكيره.",
|
||||
"claude-opus-4-1-20250805.description": "Claude Opus 4.1 هو أحدث وأقوى نموذج من Anthropic للمهام المعقدة للغاية، يتميز بالأداء العالي، الذكاء، الطلاقة، والفهم العميق.",
|
||||
"claude-opus-4-20250514.description": "Claude Opus 4 هو أقوى نموذج من Anthropic للمهام المعقدة للغاية، يتميز بالأداء العالي، الذكاء، الطلاقة، والفهم العميق.",
|
||||
@@ -286,6 +291,7 @@
|
||||
"claude-sonnet-4-20250514-thinking.description": "Claude Sonnet 4 Thinking يمكنه تقديم استجابات شبه فورية أو تفكير متسلسل مرئي.",
|
||||
"claude-sonnet-4-20250514.description": "Claude Sonnet 4 هو أذكى نموذج من Anthropic حتى الآن، يوفر استجابات شبه فورية أو تفكير متسلسل مع تحكم دقيق لمستخدمي واجهة البرمجة.",
|
||||
"claude-sonnet-4-5-20250929.description": "Claude Sonnet 4.5 هو أذكى نموذج من Anthropic حتى الآن.",
|
||||
"claude-sonnet-4.description": "كلود سونيت 4 هو الجيل الأحدث مع أداء محسّن في جميع المهام.",
|
||||
"codegeex-4.description": "CodeGeeX-4 هو مساعد برمجة ذكي يدعم الأسئلة والأجوبة متعددة اللغات وإكمال الشيفرة لزيادة إنتاجية المطورين.",
|
||||
"codegeex4-all-9b.description": "CodeGeeX4-ALL-9B هو نموذج توليد شيفرة متعدد اللغات يدعم الإكمال والتوليد، تفسير الشيفرة، البحث عبر الإنترنت، استدعاء الوظائف، وأسئلة وأجوبة على مستوى المستودع، ويغطي مجموعة واسعة من سيناريوهات تطوير البرمجيات. يُعد من أفضل نماذج الشيفرة تحت 10B.",
|
||||
"codegemma.description": "CodeGemma هو نموذج خفيف الوزن لمهام البرمجة المتنوعة، يتيح التكرار السريع والتكامل السلس.",
|
||||
@@ -351,7 +357,6 @@
|
||||
"deepseek-ai/DeepSeek-V3.2-Exp.description": "DeepSeek-V3.2-Exp هو إصدار تجريبي من V3.2 يربط بالهيكلية القادمة. يضيف انتباهًا متفرقًا (DSA) إلى V3.1-Terminus لتحسين كفاءة التدريب والاستدلال في السياقات الطويلة، مع تحسينات لاستخدام الأدوات، وفهم المستندات الطويلة، والتفكير متعدد الخطوات. مثالي لاستكشاف كفاءة تفكير أعلى بميزانيات سياق كبيرة.",
|
||||
"deepseek-ai/DeepSeek-V3.description": "DeepSeek-V3 هو نموذج MoE يحتوي على 671 مليار معلمة، يستخدم MLA وDeepSeekMoE مع توازن تحميل خالٍ من الفقدان لتدريب واستدلال فعال. تم تدريبه مسبقًا على 14.8 تريليون رمز عالي الجودة مع SFT وRL، ويتفوق على النماذج المفتوحة الأخرى ويقترب من النماذج المغلقة الرائدة.",
|
||||
"deepseek-ai/deepseek-llm-67b-chat.description": "DeepSeek LLM Chat (67B) هو نموذج مبتكر يوفر فهمًا عميقًا للغة وتفاعلًا ذكيًا.",
|
||||
"deepseek-ai/deepseek-r1.description": "نموذج لغة كبير فعال وحديث يتميز بقوة في التفكير، والرياضيات، والبرمجة.",
|
||||
"deepseek-ai/deepseek-v3.1-terminus.description": "DeepSeek V3.1 هو نموذج تفكير من الجيل التالي يتمتع بقدرات أقوى في التفكير المعقد وسلسلة التفكير لمهام التحليل العميق.",
|
||||
"deepseek-ai/deepseek-v3.1.description": "DeepSeek V3.1 هو نموذج تفكير من الجيل التالي يتمتع بقدرات أقوى في التفكير المعقد وسلسلة التفكير لمهام التحليل العميق.",
|
||||
"deepseek-ai/deepseek-vl2.description": "DeepSeek-VL2 هو نموذج رؤية-لغة MoE يعتمد على DeepSeekMoE-27B مع تنشيط متفرق، ويحقق أداءً قويًا باستخدام 4.5 مليار معلمة نشطة فقط. يتميز في الأسئلة البصرية، وOCR، وفهم المستندات/الجداول/المخططات، والتأريض البصري.",
|
||||
@@ -472,7 +477,6 @@
|
||||
"ernie-tiny-8k.description": "ERNIE Tiny 8K هو نموذج فائق الخفة للأسئلة البسيطة، والتصنيف، والاستدلال منخفض التكلفة.",
|
||||
"ernie-x1-turbo-32k.description": "ERNIE X1 Turbo 32K هو نموذج تفكير سريع بسياق 32K للاستدلال المعقد والدردشة متعددة الأدوار.",
|
||||
"ernie-x1.1-preview.description": "معاينة ERNIE X1.1 هو نموذج تفكير مخصص للتقييم والاختبار.",
|
||||
"fal-ai/bytedance/seedream/v4.5.description": "Seedream 4.5، من تطوير فريق Seed في ByteDance، يدعم تحرير وتركيب صور متعددة. يتميز بثبات أكبر في العناصر، ودقة في تنفيذ التعليمات، وفهم للمنطق المكاني، وتعبير جمالي، وتصميم ملصقات وشعارات بدقة عالية في تحويل النص إلى صورة.",
|
||||
"fal-ai/bytedance/seedream/v4.description": "Seedream 4.0، من تطوير ByteDance Seed، يدعم إدخال النصوص والصور لتوليد صور عالية الجودة وقابلة للتحكم بدرجة كبيرة من خلال الأوامر.",
|
||||
"fal-ai/flux-kontext/dev.description": "نموذج FLUX.1 يركز على تحرير الصور، ويدعم إدخال النصوص والصور.",
|
||||
"fal-ai/flux-pro/kontext.description": "FLUX.1 Kontext [pro] يقبل النصوص وصور مرجعية كمدخلات، مما يتيح تعديلات محلية مستهدفة وتحولات معقدة في المشهد العام.",
|
||||
@@ -514,8 +518,6 @@
|
||||
"gemini-2.0-flash-lite-001.description": "إصدار من Gemini 2.0 Flash محسن لتقليل التكلفة وتقليل التأخير.",
|
||||
"gemini-2.0-flash-lite.description": "إصدار من Gemini 2.0 Flash محسن لتقليل التكلفة وتقليل التأخير.",
|
||||
"gemini-2.0-flash.description": "Gemini 2.0 Flash يقدم ميزات الجيل التالي بما في ذلك السرعة الاستثنائية، واستخدام الأدوات الأصلية، والتوليد متعدد الوسائط، وسياق يصل إلى مليون رمز.",
|
||||
"gemini-2.5-flash-image-preview.description": "Nano Banana هو أحدث وأسرع وأكثر نماذج Google كفاءةً في التعدد الوسائطي، يتيح توليد وتحرير الصور من خلال المحادثة.",
|
||||
"gemini-2.5-flash-image-preview:image.description": "Nano Banana هو أحدث وأسرع وأكثر نماذج Google كفاءةً في التعدد الوسائطي، يتيح توليد وتحرير الصور من خلال المحادثة.",
|
||||
"gemini-2.5-flash-image.description": "Nano Banana هو أحدث وأسرع وأكثر نماذج Google متعددة الوسائط كفاءة، يتيح توليد الصور وتحريرها عبر المحادثة.",
|
||||
"gemini-2.5-flash-image:image.description": "Nano Banana هو أحدث وأسرع وأكثر نماذج Google متعددة الوسائط كفاءة، يتيح توليد الصور وتحريرها عبر المحادثة.",
|
||||
"gemini-2.5-flash-lite-preview-06-17.description": "Gemini 2.5 Flash-Lite Preview هو أصغر نموذج من Google وأفضلها من حيث القيمة، مصمم للاستخدام واسع النطاق.",
|
||||
@@ -543,7 +545,7 @@
|
||||
"generalv3.5.description": "Spark Max هو الإصدار الأكثر تكاملًا، يدعم البحث عبر الإنترنت والعديد من الإضافات المدمجة. قدراته الأساسية المحسّنة، وأدوار النظام، واستدعاء الوظائف توفر أداءً ممتازًا في سيناريوهات التطبيقات المعقدة.",
|
||||
"generalv3.description": "Spark Pro هو نموذج لغوي عالي الأداء مُحسّن للمجالات المهنية، يركز على الرياضيات، والبرمجة، والرعاية الصحية، والتعليم، مع دعم البحث عبر الإنترنت والإضافات المدمجة مثل الطقس والتاريخ. يقدم أداءً قويًا وكفاءة في أسئلة المعرفة المعقدة، وفهم اللغة، وإنشاء النصوص المتقدمة، مما يجعله خيارًا مثاليًا للاستخدام المهني.",
|
||||
"glm-4-0520.description": "GLM-4-0520 هو أحدث إصدار من النموذج، مصمم للمهام المعقدة والمتنوعة بأداء ممتاز.",
|
||||
"glm-4-32b-0414.description": "GLM-4 32B 0414 هو نموذج عام من GLM يدعم توليد النصوص وفهمها عبر مهام متعددة.",
|
||||
"glm-4-7.description": "GLM-4.7 هو النموذج الرائد الأحدث من Zhipu AI. يعزز GLM-4.7 قدرات البرمجة، وتخطيط المهام طويلة الأمد، والتعاون مع الأدوات في سيناريوهات البرمجة بالوكيل، محققًا أداءً رائدًا بين النماذج مفتوحة المصدر في العديد من المعايير العامة. تم تحسين القدرات العامة، مع ردود أكثر إيجازًا وطبيعية، وتجربة كتابة أكثر غمرًا. في المهام المعقدة للوكلاء، أصبح اتباع التعليمات أقوى أثناء استدعاء الأدوات، كما تم تعزيز جمالية الواجهات الأمامية للبرمجة بالوكيل وكفاءة إتمام المهام طويلة الأمد.\n• قدرات برمجة أقوى: تحسن كبير في البرمجة متعددة اللغات وأداء الوكلاء الطرفيين؛ يمكن لـ GLM-4.7 الآن تنفيذ آلية \"فكر أولاً، ثم تصرف\" في أطر البرمجة مثل Claude Code وKilo Code وTRAE وCline وRoo Code، مع أداء أكثر استقرارًا في المهام المعقدة.\n• تحسين جمالية الواجهة الأمامية: يظهر GLM-4.7 تقدمًا ملحوظًا في جودة توليد الواجهات، وقادر على إنشاء مواقع إلكترونية وعروض تقديمية وملصقات بجاذبية بصرية أفضل.\n• قدرات أقوى في استدعاء الأدوات: يعزز GLM-4.7 قدرات استدعاء الأدوات، محققًا 67 نقطة في تقييم مهمة التصفح BrowseComp، و84.7 نقطة في تقييم τ²-Bench لاستدعاء الأدوات التفاعلي، متفوقًا على Claude Sonnet 4.5 كأفضل نموذج مفتوح المصدر.\n• تحسين قدرات الاستدلال: تحسن كبير في القدرات الرياضية والاستدلالية، محققًا 42.8٪ في معيار HLE (\"امتحان البشرية الأخير\")، بزيادة 41٪ عن GLM-4.6، متفوقًا على GPT-5.1.\n• تعزيز القدرات العامة: محادثات GLM-4.7 أكثر إيجازًا وذكاءً وإنسانية؛ والكتابة ولعب الأدوار أكثر أدبية وغامرة.",
|
||||
"glm-4-9b-chat.description": "GLM-4-9B-Chat يتميز بقوة في الدلالات، الرياضيات، الاستدلال، البرمجة، والمعرفة. كما يدعم تصفح الويب، تنفيذ الشيفرات، استدعاء الأدوات المخصصة، والاستدلال على النصوص الطويلة، مع دعم لـ 26 لغة منها اليابانية والكورية والألمانية.",
|
||||
"glm-4-air-250414.description": "GLM-4-Air هو خيار عالي القيمة بأداء قريب من GLM-4، سرعة عالية، وتكلفة منخفضة.",
|
||||
"glm-4-air.description": "GLM-4-Air هو خيار عالي القيمة بأداء قريب من GLM-4، سرعة عالية، وتكلفة منخفضة.",
|
||||
@@ -558,11 +560,12 @@
|
||||
"glm-4.1v-thinking-flashx.description": "GLM-4.1V-Thinking هو أقوى نموذج VLM معروف بحجم ~10B، يغطي مهام متقدمة مثل فهم الفيديو، الأسئلة البصرية، حل المسائل، التعرف البصري، قراءة المستندات والمخططات، وكلاء واجهات المستخدم، برمجة الواجهات، والتأريض. يتفوق على Qwen2.5-VL-72B الأكبر بـ8 مرات في العديد من المهام. يستخدم استدلال سلسلة الأفكار لتحسين الدقة والثراء، متفوقًا على النماذج التقليدية غير المفكرة في النتائج والشرح.",
|
||||
"glm-4.5-air.description": "GLM-4.5 إصدار خفيف يوازن بين الأداء والتكلفة، مع أوضاع تفكير هجينة مرنة.",
|
||||
"glm-4.5-airx.description": "GLM-4.5-Air إصدار سريع يوفر استجابات أسرع للاستخدام واسع النطاق وعالي السرعة.",
|
||||
"glm-4.5-flash.description": "إصدار مجاني من GLM-4.5 بأداء قوي في الاستدلال، البرمجة، والمهام الوكيلة.",
|
||||
"glm-4.5-x.description": "GLM-4.5 إصدار سريع، يقدم أداءً قويًا مع سرعات توليد تصل إلى 100 رمز/ثانية.",
|
||||
"glm-4.5.description": "نموذج Zhipu الرائد مع وضع تفكير قابل للتبديل، يقدم أداءً رائدًا مفتوح المصدر وسياق يصل إلى 128K.",
|
||||
"glm-4.5v.description": "نموذج الرؤية والاستدلال من الجيل التالي من Zhipu بتقنية MoE، يحتوي على 106 مليار معلمة إجمالية و12 مليار نشطة، يحقق أداءً رائدًا بين النماذج متعددة الوسائط مفتوحة المصدر ذات الحجم المماثل في مهام الصور، الفيديو، فهم المستندات، ومهام واجهات المستخدم.",
|
||||
"glm-4.6.description": "GLM-4.6 (355B) هو النموذج الرائد الأحدث من Zhipu، يتفوق بشكل كامل على النماذج السابقة في الترميز المتقدم، ومعالجة النصوص الطويلة، والاستدلال، وقدرات الوكلاء. يتماشى بشكل خاص مع Claude Sonnet 4 في قدرات البرمجة، مما يجعله النموذج الأفضل في الصين للترميز.",
|
||||
"glm-4.7-flash.description": "GLM-4.7-Flash، كنموذج SOTA بحجم 30 مليار، يقدم خيارًا جديدًا يوازن بين الأداء والكفاءة. يعزز قدرات البرمجة، وتخطيط المهام طويلة الأمد، والتعاون مع الأدوات في سيناريوهات البرمجة بالوكيل، محققًا أداءً رائدًا بين النماذج مفتوحة المصدر من نفس الحجم في العديد من معايير الأداء الحالية. عند تنفيذ مهام الوكلاء الذكية المعقدة، يتمتع بامتثال أقوى للتعليمات أثناء استدعاء الأدوات، كما يعزز جمالية الواجهة الأمامية وكفاءة إتمام المهام طويلة الأمد للبرمجة بالوكيل والقطع البرمجية.",
|
||||
"glm-4.7-flashx.description": "GLM-4.7-Flash، كنموذج SOTA بحجم 30 مليار، يقدم خيارًا جديدًا يوازن بين الأداء والكفاءة. يعزز قدرات البرمجة، وتخطيط المهام طويلة الأمد، والتعاون مع الأدوات في سيناريوهات البرمجة بالوكيل، محققًا أداءً رائدًا بين النماذج مفتوحة المصدر من نفس الحجم في العديد من معايير الأداء الحالية. عند تنفيذ مهام الوكلاء الذكية المعقدة، يتمتع بامتثال أقوى للتعليمات أثناء استدعاء الأدوات، كما يعزز جمالية الواجهة الأمامية وكفاءة إتمام المهام طويلة الأمد للبرمجة بالوكيل والقطع البرمجية.",
|
||||
"glm-4.7.description": "GLM-4.7 هو النموذج الرائد الأحدث من Zhipu، تم تعزيزه لسيناريوهات الترميز الوكالي مع تحسينات في قدرات البرمجة، وتخطيط المهام طويلة الأمد، والتعاون مع الأدوات. يحقق أداءً رائدًا بين النماذج مفتوحة المصدر في العديد من المعايير العامة. تم تحسين القدرات العامة لتقديم ردود أكثر إيجازًا وطبيعية، وتجربة كتابة أكثر غمرًا. في المهام المعقدة للوكلاء، تم تعزيز اتباع التعليمات أثناء استدعاء الأدوات، كما تم تحسين جمالية الواجهة وكفاءة إتمام المهام طويلة الأمد في Artifacts والترميز الوكالي.",
|
||||
"glm-4.description": "GLM-4 هو النموذج الرائد الأقدم الذي تم إصداره في يناير 2024، وقد تم استبداله الآن بـ GLM-4-0520 الأقوى.",
|
||||
"glm-4v-flash.description": "GLM-4V-Flash يركز على فهم صورة واحدة بكفاءة لسيناريوهات التحليل السريع مثل المعالجة الفورية أو الدفعية للصور.",
|
||||
@@ -609,6 +612,7 @@
|
||||
"google/text-embedding-005.description": "نموذج تضمين نصي يركز على اللغة الإنجليزية، محسّن لمهام البرمجة واللغة الإنجليزية.",
|
||||
"google/text-multilingual-embedding-002.description": "نموذج تضمين نصي متعدد اللغات محسّن للمهام عبر اللغات المختلفة.",
|
||||
"gpt-3.5-turbo-0125.description": "GPT 3.5 Turbo لتوليد النصوص وفهمها؛ يشير حاليًا إلى gpt-3.5-turbo-0125.",
|
||||
"gpt-3.5-turbo-0613.description": "GPT 3.5 Turbo نموذج سريع وفعّال لمهام متعددة.",
|
||||
"gpt-3.5-turbo-1106.description": "GPT 3.5 Turbo لتوليد النصوص وفهمها؛ يشير حاليًا إلى gpt-3.5-turbo-0125.",
|
||||
"gpt-3.5-turbo-instruct.description": "GPT 3.5 Turbo لمهام توليد النصوص والفهم، محسّن لاتباع التعليمات.",
|
||||
"gpt-3.5-turbo.description": "GPT 3.5 Turbo لتوليد النصوص وفهمها؛ يشير حاليًا إلى gpt-3.5-turbo-0125.",
|
||||
@@ -619,10 +623,12 @@
|
||||
"gpt-4-1106-preview.description": "أحدث إصدار من GPT-4 Turbo يدعم الرؤية. الطلبات البصرية تدعم وضع JSON واستدعاء الوظائف. إنه نموذج متعدد الوسائط فعال من حيث التكلفة يوازن بين الدقة والكفاءة للتطبيقات في الوقت الحقيقي.",
|
||||
"gpt-4-32k-0613.description": "يوفر GPT-4 نافذة سياق أكبر للتعامل مع مدخلات أطول في السيناريوهات التي تتطلب دمج معلومات واسع وتحليل بيانات.",
|
||||
"gpt-4-32k.description": "يوفر GPT-4 نافذة سياق أكبر للتعامل مع مدخلات أطول في السيناريوهات التي تتطلب دمج معلومات واسع وتحليل بيانات.",
|
||||
"gpt-4-o-preview.description": "GPT-4o هو النموذج متعدد الوسائط الأكثر تقدماً، يدعم إدخال النصوص والصور.",
|
||||
"gpt-4-turbo-2024-04-09.description": "أحدث إصدار من GPT-4 Turbo يدعم الرؤية. الطلبات البصرية تدعم وضع JSON واستدعاء الوظائف. إنه نموذج متعدد الوسائط فعال من حيث التكلفة يوازن بين الدقة والكفاءة للتطبيقات في الوقت الحقيقي.",
|
||||
"gpt-4-turbo-preview.description": "أحدث إصدار من GPT-4 Turbo يدعم الرؤية. الطلبات البصرية تدعم وضع JSON واستدعاء الوظائف. إنه نموذج متعدد الوسائط فعال من حيث التكلفة يوازن بين الدقة والكفاءة للتطبيقات في الوقت الحقيقي.",
|
||||
"gpt-4-turbo.description": "أحدث إصدار من GPT-4 Turbo يدعم الرؤية. الطلبات البصرية تدعم وضع JSON واستدعاء الوظائف. إنه نموذج متعدد الوسائط فعال من حيث التكلفة يوازن بين الدقة والكفاءة للتطبيقات في الوقت الحقيقي.",
|
||||
"gpt-4-vision-preview.description": "معاينة GPT-4 Vision، مصمم لمهام تحليل ومعالجة الصور.",
|
||||
"gpt-4.1-2025-04-14.description": "GPT-4.1 هو النموذج الرائد للمهام المعقدة، مثالي لحل المشكلات متعددة المجالات.",
|
||||
"gpt-4.1-mini.description": "GPT-4.1 mini يوازن بين الذكاء والسرعة والتكلفة، مما يجعله جذابًا للعديد من الاستخدامات.",
|
||||
"gpt-4.1-nano.description": "GPT-4.1 nano هو الأسرع والأكثر فعالية من حيث التكلفة بين نماذج GPT-4.1.",
|
||||
"gpt-4.1.description": "GPT-4.1 هو نموذجنا الرائد للمهام المعقدة وحل المشكلات عبر المجالات.",
|
||||
@@ -632,6 +638,7 @@
|
||||
"gpt-4o-2024-08-06.description": "ChatGPT-4o هو نموذج ديناميكي يتم تحديثه في الوقت الحقيقي، يجمع بين الفهم القوي والتوليد لتطبيقات واسعة النطاق مثل دعم العملاء والتعليم والمساعدة التقنية.",
|
||||
"gpt-4o-2024-11-20.description": "ChatGPT-4o هو نموذج ديناميكي يتم تحديثه في الوقت الحقيقي، يجمع بين الفهم القوي والتوليد لتطبيقات واسعة النطاق مثل دعم العملاء والتعليم والدعم الفني.",
|
||||
"gpt-4o-audio-preview.description": "نموذج معاينة GPT-4o Audio مع إدخال وإخراج صوتي.",
|
||||
"gpt-4o-mini-2024-07-18.description": "GPT-4o mini هو حل اقتصادي لمجموعة واسعة من مهام النصوص والصور.",
|
||||
"gpt-4o-mini-audio-preview.description": "نموذج GPT-4o mini Audio مع إدخال وإخراج صوتي.",
|
||||
"gpt-4o-mini-realtime-preview.description": "إصدار GPT-4o-mini الفوري مع إدخال وإخراج صوتي ونصي في الوقت الحقيقي.",
|
||||
"gpt-4o-mini-search-preview.description": "GPT-4o mini Search Preview مدرب على فهم وتنفيذ استعلامات البحث عبر الإنترنت من خلال واجهة Chat Completions API. يتم احتساب تكلفة البحث عبر الإنترنت لكل استخدام أداة بالإضافة إلى تكلفة الرموز.",
|
||||
@@ -654,8 +661,8 @@
|
||||
"gpt-5.1-codex-mini.description": "GPT-5.1 Codex mini: إصدار أصغر وأقل تكلفة من Codex، محسّن لمهام البرمجة التلقائية.",
|
||||
"gpt-5.1-codex.description": "GPT-5.1 Codex: إصدار من GPT-5.1 محسّن لمهام البرمجة التلقائية، مناسب لتدفقات العمل المعقدة في واجهة Responses API.",
|
||||
"gpt-5.1.description": "GPT-5.1 — نموذج رائد محسّن للبرمجة والمهام التلقائية مع جهد استدلال قابل للتكوين وسياق أطول.",
|
||||
"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-chat-latest.description": "GPT-5.2 Chat هو إصدار ChatGPT (chat-latest) الذي يتضمن أحدث تحسينات المحادثة.",
|
||||
"gpt-5.2-pro.description": "GPT-5.2 Pro: إصدار أكثر ذكاءً ودقة من GPT-5.2 (لواجهة برمجة استجابات فقط)، مناسب للمشكلات الصعبة والاستدلال متعدد الأدوار الطويل.",
|
||||
"gpt-5.2.description": "GPT-5.2 هو نموذج رائد لتدفقات العمل البرمجية والتلقائية مع استدلال أقوى وأداء سياقي طويل.",
|
||||
"gpt-5.description": "أفضل نموذج لمهام البرمجة والتلقائية عبر المجالات. يحقق GPT-5 قفزات في الدقة والسرعة والاستدلال والوعي بالسياق والتفكير المنظم وحل المشكلات.",
|
||||
"gpt-audio.description": "GPT Audio هو نموذج دردشة عام يدعم الإدخال والإخراج الصوتي، مدعوم في واجهة Chat Completions API.",
|
||||
@@ -966,6 +973,9 @@
|
||||
"openai/gpt-5.1-codex-mini.description": "GPT-5.1-Codex-Mini هو إصدار أصغر وأسرع من GPT-5.1-Codex، مثالي للبرمجة في سيناريوهات حساسة للتكلفة وزمن الاستجابة.",
|
||||
"openai/gpt-5.1-codex.description": "GPT-5.1-Codex هو إصدار من GPT-5.1 محسّن لهندسة البرمجيات وتدفقات العمل البرمجية، مناسب لإعادة هيكلة واسعة، وتصحيح الأخطاء المعقدة، ومهام البرمجة الذاتية الطويلة.",
|
||||
"openai/gpt-5.1.description": "GPT-5.1 هو النموذج الرائد الأحدث في سلسلة GPT-5، مع تحسينات كبيرة في التفكير العام، اتباع التعليمات، وطبيعية المحادثة، مناسب لمجموعة واسعة من المهام.",
|
||||
"openai/gpt-5.2-chat.description": "GPT-5.2 Chat هو إصدار ChatGPT لتجربة أحدث تحسينات المحادثة.",
|
||||
"openai/gpt-5.2-pro.description": "GPT-5.2 Pro: إصدار أكثر ذكاءً ودقة من GPT-5.2 (لواجهة برمجة استجابات فقط)، مناسب للمشكلات الصعبة والاستدلال متعدد الأدوار الطويل.",
|
||||
"openai/gpt-5.2.description": "GPT-5.2 هو نموذج رائد للبرمجة وسير العمل بالوكيل مع قدرات استدلال أقوى وأداء أفضل في السياقات الطويلة.",
|
||||
"openai/gpt-5.description": "GPT-5 هو نموذج عالي الأداء من OpenAI لمجموعة واسعة من المهام الإنتاجية والبحثية.",
|
||||
"openai/gpt-oss-120b.description": "نموذج لغة كبير عام القدرات يتمتع بتفكير قوي وقابل للتحكم.",
|
||||
"openai/gpt-oss-20b.description": "نموذج لغة صغير الحجم بوزن مفتوح، محسّن لزمن استجابة منخفض وبيئات محدودة الموارد، بما في ذلك النشر المحلي وعلى الأطراف.",
|
||||
@@ -981,6 +991,8 @@
|
||||
"openai/text-embedding-3-small.description": "إصدار محسّن عالي الأداء من نموذج تضمين ada.",
|
||||
"openai/text-embedding-ada-002.description": "نموذج تضمين النصوص القديم من OpenAI.",
|
||||
"openrouter/auto.description": "استنادًا إلى طول السياق والموضوع والتعقيد، يتم توجيه طلبك إلى Llama 3 70B Instruct أو Claude 3.5 Sonnet (بمراقبة ذاتية) أو GPT-4o.",
|
||||
"oswe-vscode-prime.description": "رابتور ميني هو نموذج تجريبي محسن لمهام البرمجة.",
|
||||
"oswe-vscode-secondary.description": "رابتور ميني هو نموذج تجريبي محسن لمهام البرمجة.",
|
||||
"perplexity/sonar-pro.description": "المنتج الرائد من Perplexity مع دعم البحث، يدعم الاستفسارات المتقدمة والمتابعة.",
|
||||
"perplexity/sonar-reasoning-pro.description": "نموذج متقدم يركز على التفكير، ينتج سلسلة تفكير (CoT) مع بحث محسّن، بما في ذلك استعلامات بحث متعددة لكل طلب.",
|
||||
"perplexity/sonar-reasoning.description": "نموذج يركز على التفكير، ينتج سلسلة تفكير (CoT) مع شروحات مفصلة مدعومة بالبحث.",
|
||||
@@ -1045,6 +1057,44 @@
|
||||
"qwen/qwen3-14b.description": "Qwen3-14B هو إصدار 14B مخصص للاستدلال العام وسيناريوهات الدردشة.",
|
||||
"qwen/qwen3-14b:free.description": "Qwen3-14B هو نموذج سببي كثيف يحتوي على 14.8 مليار معلمة، مصمم للاستدلال المعقد والدردشة الفعالة. يتنقل بين وضع التفكير للرياضيات، البرمجة، والمنطق، ووضع غير التفكير للدردشة العامة. تم تحسينه لاتباع التعليمات، استخدام أدوات الوكلاء، والكتابة الإبداعية بأكثر من 100 لغة ولهجة. يدعم سياق 32K أصليًا ويتوسع إلى 131K باستخدام YaRN.",
|
||||
"qwen/qwen3-235b-a22b-2507.description": "Qwen3-235B-A22B-Instruct-2507 هو إصدار Instruct من سلسلة Qwen3، يوازن بين استخدام التعليمات متعددة اللغات وسيناريوهات السياق الطويل.",
|
||||
"qwen/qwen3-235b-a22b-thinking-2507.description": "Qwen3-235B-A22B-Thinking-2507 هو إصدار التفكير من Qwen3، معزز للمهام المعقدة في الرياضيات والاستدلال.",
|
||||
"qwen/qwen3-235b-a22b.description": "Qwen3-235B-A22B هو نموذج MoE يحتوي على 235 مليار معامل من Qwen مع 22 مليار نشطة في كل تمرير. يتحول بين وضع التفكير للمهام المعقدة في الاستدلال والرياضيات والبرمجة، ووضع غير التفكير للدردشة الفعالة. يقدم استدلالًا قويًا، ودعمًا متعدد اللغات (أكثر من 100 لغة ولهجة)، واتباعًا متقدمًا للتعليمات، واستخدام أدوات الوكلاء. يدعم سياقًا أصليًا حتى 32K ويتوسع إلى 131K باستخدام YaRN.",
|
||||
"qwen/qwen3-235b-a22b:free.description": "Qwen3-235B-A22B هو نموذج MoE يحتوي على 235 مليار معامل من Qwen مع 22 مليار نشطة في كل تمرير. يتحول بين وضع التفكير للمهام المعقدة في الاستدلال والرياضيات والبرمجة، ووضع غير التفكير للدردشة الفعالة. يقدم استدلالًا قويًا، ودعمًا متعدد اللغات (أكثر من 100 لغة ولهجة)، واتباعًا متقدمًا للتعليمات، واستخدام أدوات الوكلاء. يدعم سياقًا أصليًا حتى 32K ويتوسع إلى 131K باستخدام YaRN.",
|
||||
"qwen/qwen3-30b-a3b.description": "Qwen3 هو الجيل الأحدث من نماذج Qwen LLM بهندسات كثيفة وMoE، ويتفوق في الاستدلال، والدعم متعدد اللغات، والمهام المتقدمة للوكلاء. قدرته الفريدة على التبديل بين وضع التفكير للاستدلال المعقد ووضع غير التفكير للدردشة الفعالة تضمن أداءً متنوعًا وعالي الجودة.\n\nيتفوق Qwen3 بشكل كبير على النماذج السابقة مثل QwQ وQwen2.5، ويقدم أداءً ممتازًا في الرياضيات، والبرمجة، والاستدلال المنطقي، والكتابة الإبداعية، والدردشة التفاعلية. يحتوي إصدار Qwen3-30B-A3B على 30.5 مليار معامل (3.3 مليار نشطة)، و48 طبقة، و128 خبيرًا (8 نشطين لكل مهمة)، ويدعم سياقًا يصل إلى 131K باستخدام YaRN، مما يضع معيارًا جديدًا للنماذج المفتوحة.",
|
||||
"qwen/qwen3-30b-a3b:free.description": "Qwen3 هو الجيل الأحدث من نماذج Qwen LLM بهندسات كثيفة وMoE، ويتفوق في الاستدلال، والدعم متعدد اللغات، والمهام المتقدمة للوكلاء. قدرته الفريدة على التبديل بين وضع التفكير للاستدلال المعقد ووضع غير التفكير للدردشة الفعالة تضمن أداءً متنوعًا وعالي الجودة.\n\nيتفوق Qwen3 بشكل كبير على النماذج السابقة مثل QwQ وQwen2.5، ويقدم أداءً ممتازًا في الرياضيات، والبرمجة، والاستدلال المنطقي، والكتابة الإبداعية، والدردشة التفاعلية. يحتوي إصدار Qwen3-30B-A3B على 30.5 مليار معامل (3.3 مليار نشطة)، و48 طبقة، و128 خبيرًا (8 نشطين لكل مهمة)، ويدعم سياقًا يصل إلى 131K باستخدام YaRN، مما يضع معيارًا جديدًا للنماذج المفتوحة.",
|
||||
"qwen/qwen3-32b.description": "Qwen3-32B هو نموذج LLM كثيف يحتوي على 32.8 مليار معامل، مُحسَّن للاستدلال المعقد والدردشة الفعالة. يتحول بين وضع التفكير للرياضيات والبرمجة والمنطق، ووضع غير التفكير للدردشة العامة الأسرع. يتميز بأداء قوي في اتباع التعليمات، واستخدام أدوات الوكلاء، والكتابة الإبداعية عبر أكثر من 100 لغة ولهجة. يدعم سياقًا أصليًا حتى 32K ويتوسع إلى 131K باستخدام YaRN.",
|
||||
"qwen/qwen3-32b:free.description": "Qwen3-32B هو نموذج LLM كثيف يحتوي على 32.8 مليار معامل، مُحسَّن للاستدلال المعقد والدردشة الفعالة. يتحول بين وضع التفكير للرياضيات والبرمجة والمنطق، ووضع غير التفكير للدردشة العامة الأسرع. يتميز بأداء قوي في اتباع التعليمات، واستخدام أدوات الوكلاء، والكتابة الإبداعية عبر أكثر من 100 لغة ولهجة. يدعم سياقًا أصليًا حتى 32K ويتوسع إلى 131K باستخدام YaRN.",
|
||||
"qwen/qwen3-8b:free.description": "Qwen3-8B هو نموذج LLM كثيف يحتوي على 8.2 مليار معامل، مصمم للمهام التي تتطلب استدلالًا عاليًا ودردشة فعالة. يتحول بين وضع التفكير للرياضيات والبرمجة والمنطق، ووضع غير التفكير للدردشة العامة. تم تحسينه لاتباع التعليمات، ودمج الوكلاء، والكتابة الإبداعية عبر أكثر من 100 لغة ولهجة. يدعم سياقًا أصليًا حتى 32K ويتوسع إلى 131K باستخدام YaRN.",
|
||||
"qwen/qwen3-coder-plus.description": "Qwen3-Coder-Plus هو نموذج وكيل برمجي من سلسلة Qwen، مُحسَّن لاستخدام الأدوات المعقدة وجلسات العمل الطويلة.",
|
||||
"qwen/qwen3-coder.description": "Qwen3-Coder هو عائلة توليد الشيفرة من Qwen3، يتميز بفهم وتوليد الشيفرات في المستندات الطويلة.",
|
||||
"qwen/qwen3-max-preview.description": "Qwen3 Max (معاينة) هو إصدار Max للاستدلال المتقدم ودمج الأدوات.",
|
||||
"qwen/qwen3-max.description": "Qwen3 Max هو النموذج الأعلى في سلسلة Qwen3 للاستدلال متعدد اللغات ودمج الأدوات.",
|
||||
"qwen/qwen3-vl-plus.description": "Qwen3 VL-Plus هو إصدار Qwen3 المحسن بالرؤية، مع استدلال متعدد الوسائط ومعالجة فيديو محسنة.",
|
||||
"qwen2.5-14b-instruct-1m.description": "Qwen2.5 نموذج مفتوح المصدر بحجم 72 مليار.",
|
||||
"qwen2.5-14b-instruct.description": "Qwen2.5 نموذج مفتوح المصدر بحجم 14 مليار.",
|
||||
"qwen2.5-32b-instruct.description": "Qwen2.5 نموذج مفتوح المصدر بحجم 32 مليار.",
|
||||
"qwen2.5-72b-instruct.description": "Qwen2.5 نموذج مفتوح المصدر بحجم 72 مليار.",
|
||||
"qwen2.5-7b-instruct.description": "Qwen2.5 7B Instruct هو نموذج مفتوح المصدر ناضج للدردشة والتوليد في سيناريوهات متعددة.",
|
||||
"qwen2.5-coder-1.5b-instruct.description": "نموذج Qwen للبرمجة مفتوح المصدر.",
|
||||
"qwen2.5-coder-14b-instruct.description": "نموذج Qwen للبرمجة مفتوح المصدر.",
|
||||
"qwen2.5-coder-32b-instruct.description": "نموذج Qwen للبرمجة مفتوح المصدر.",
|
||||
"qwen2.5-coder-7b-instruct.description": "نموذج Qwen للبرمجة مفتوح المصدر.",
|
||||
"qwen2.5-coder-instruct.description": "Qwen2.5-Coder هو أحدث نموذج برمجي في عائلة Qwen (سابقًا CodeQwen).",
|
||||
"qwen2.5-instruct.description": "Qwen2.5 هو أحدث سلسلة من نماذج Qwen LLM، مع نماذج أساسية ومضبوطة على التعليمات تتراوح من 0.5 مليار إلى 72 مليار معامل.",
|
||||
"qwen2.5-math-1.5b-instruct.description": "Qwen-Math يقدم أداءً قويًا في حل المسائل الرياضية.",
|
||||
"qwen2.5-math-72b-instruct.description": "Qwen-Math يقدم أداءً قويًا في حل المسائل الرياضية.",
|
||||
"qwen2.5-math-7b-instruct.description": "Qwen-Math يقدم أداءً قويًا في حل المسائل الرياضية.",
|
||||
"qwen2.5-omni-7b.description": "نماذج Qwen-Omni تدعم المدخلات متعددة الوسائط (فيديو، صوت، صور، نص) وتنتج مخرجات صوتية ونصية.",
|
||||
"qwen2.5-vl-32b-instruct.description": "Qwen2.5 VL 32B Instruct هو نموذج متعدد الوسائط مفتوح المصدر مناسب للنشر الخاص والاستخدام في سيناريوهات متعددة.",
|
||||
"qwen2.5-vl-72b-instruct.description": "تحسين في اتباع التعليمات، والرياضيات، وحل المشكلات، والبرمجة، مع قدرة أقوى على التعرف على الكائنات العامة. يدعم تحديد المواقع الدقيقة للعناصر البصرية عبر التنسيقات، وفهم الفيديو الطويل (حتى 10 دقائق) مع توقيت الأحداث على مستوى الثانية، وترتيبها الزمني وفهم السرعة، ووكلاء يمكنهم التحكم في أنظمة التشغيل أو الهواتف المحمولة عبر التحليل والتحديد. استخراج معلومات رئيسية قوي وإخراج بصيغة JSON. هذا هو الإصدار الأقوى بحجم 72 مليار.",
|
||||
"qwen2.5-vl-7b-instruct.description": "Qwen2.5 VL 7B Instruct هو نموذج متعدد الوسائط خفيف الوزن يوازن بين تكلفة النشر وقدرة التعرف.",
|
||||
"qwen2.5-vl-instruct.description": "Qwen2.5-VL هو أحدث نموذج رؤية-لغة في عائلة Qwen.",
|
||||
"qwen2.5.description": "Qwen2.5 هو الجيل التالي من نموذج اللغة الكبير من Alibaba، بأداء قوي في استخدامات متنوعة.",
|
||||
"qwen2.5:0.5b.description": "Qwen2.5 هو الجيل التالي من نموذج اللغة الكبير من Alibaba، بأداء قوي في استخدامات متنوعة.",
|
||||
"qwen2.5:1.5b.description": "Qwen2.5 هو الجيل التالي من نموذج اللغة الكبير من Alibaba، بأداء قوي في استخدامات متنوعة.",
|
||||
"qwen2.5:72b.description": "Qwen2.5 هو الجيل التالي من نموذج اللغة الكبير من Alibaba، بأداء قوي في استخدامات متنوعة.",
|
||||
"qwen2.description": "Qwen2 هو الجيل التالي من نموذج اللغة الكبير من Alibaba، بأداء قوي في استخدامات متنوعة.",
|
||||
"qwen2:0.5b.description": "Qwen2 هو الجيل التالي من نموذج اللغة الكبير من Alibaba، بأداء قوي في استخدامات متنوعة.",
|
||||
"qwen2:1.5b.description": "Qwen2 هو نموذج اللغة الكبير من الجيل التالي من Alibaba، يتميز بأداء قوي في مجموعة متنوعة من الاستخدامات.",
|
||||
"qwen2:72b.description": "Qwen2 هو نموذج اللغة الكبير من الجيل التالي من Alibaba، يتميز بأداء قوي في مجموعة متنوعة من الاستخدامات.",
|
||||
"qwen3-0.6b.description": "Qwen3 0.6B هو نموذج مبدئي مخصص للاستدلال البسيط والبيئات المحدودة للغاية.",
|
||||
@@ -1095,6 +1145,44 @@
|
||||
"sonar-reasoning.description": "منتج بحث متقدم يعتمد على البحث الموجه لفهم الاستفسارات المعقدة والمتابعة.",
|
||||
"sonar.description": "منتج بحث خفيف الوزن يعتمد على البحث الموجه، أسرع وأقل تكلفة من Sonar Pro.",
|
||||
"spark-x.description": "تحديثات X1.5: (1) إضافة وضع التفكير الديناميكي يتم التحكم به عبر الحقل `thinking`؛ (2) طول سياق أكبر مع 64K إدخال و64K إخراج؛ (3) دعم FunctionCall.",
|
||||
"stable-diffusion-3-medium.description": "أحدث نموذج تحويل النص إلى صورة من Stability AI. هذا الإصدار يحسن جودة الصور، وفهم النصوص، وتنوع الأساليب بشكل كبير، ويفسر التعليمات المعقدة بدقة أكبر وينتج صوراً أكثر تنوعاً ودقة.",
|
||||
"stable-diffusion-3.5-large-turbo.description": "stable-diffusion-3.5-large-turbo يستخدم تقنيات التقطير التوليدي العدائي (ADD) لتسريع stable-diffusion-3.5-large.",
|
||||
"stable-diffusion-3.5-large.description": "stable-diffusion-3.5-large هو نموذج تحويل نص إلى صورة يحتوي على 800 مليون معامل، يتميز بجودة عالية وتوافق ممتاز مع التعليمات، ويدعم صور بدقة 1 ميغابيكسل ويعمل بكفاءة على الأجهزة الاستهلاكية.",
|
||||
"stable-diffusion-v1.5.description": "stable-diffusion-v1.5 تم تطويره من نقطة التحقق v1.2 وتم تحسينه لمدة 595 ألف خطوة على مجموعة بيانات \"laion-aesthetics v2 5+\" بدقة 512x512، مع تقليل تأثير النص بنسبة 10% لتحسين التوجيه بدون مصنف.",
|
||||
"stable-diffusion-xl-base-1.0.description": "نموذج مفتوح المصدر لتحويل النص إلى صورة من Stability AI يتميز بإبداع رائد في توليد الصور. يتمتع بفهم قوي للتعليمات ويدعم تعريف التعليمات العكسية لتوليد دقيق.",
|
||||
"stable-diffusion-xl.description": "stable-diffusion-xl يقدم تحسينات كبيرة مقارنة بالإصدار v1.5 ويضاهي أفضل نتائج النماذج المفتوحة. تشمل التحسينات مضاعفة حجم UNet ثلاث مرات، ووحدة تحسين جودة الصور، وتقنيات تدريب أكثر كفاءة.",
|
||||
"step-1-128k.description": "يوفر توازناً بين الأداء والتكلفة للسيناريوهات العامة.",
|
||||
"step-1-256k.description": "يدعم السياقات الطويلة جداً، مثالي لتحليل المستندات الطويلة.",
|
||||
"step-1-32k.description": "يدعم المحادثات متوسطة الطول لمجموعة واسعة من الاستخدامات.",
|
||||
"step-1-8k.description": "نموذج صغير مناسب للمهام الخفيفة.",
|
||||
"step-1-flash.description": "نموذج عالي السرعة مناسب للدردشة في الوقت الفعلي.",
|
||||
"step-1.5v-mini.description": "قدرات قوية في فهم الفيديو.",
|
||||
"step-1o-turbo-vision.description": "فهم قوي للصور، يتفوق على 1o في الرياضيات والبرمجة. أصغر حجماً وأسرع في الإخراج.",
|
||||
"step-1o-vision-32k.description": "فهم بصري قوي مع أداء مرئي أفضل من سلسلة Step-1V.",
|
||||
"step-1v-32k.description": "يدعم إدخال الصور لتفاعل متعدد الوسائط أكثر ثراءً.",
|
||||
"step-1v-8k.description": "نموذج بصري صغير للمهام الأساسية التي تجمع بين النص والصورة.",
|
||||
"step-1x-edit.description": "يركز هذا النموذج على تعديل الصور وتحسينها بناءً على صور ونصوص يقدمها المستخدم. يدعم تنسيقات إدخال متعددة، بما في ذلك الأوصاف النصية والصور النموذجية، وينتج تعديلات تتماشى مع نية المستخدم.",
|
||||
"step-1x-medium.description": "يقدم هذا النموذج توليد صور قوي بناءً على أوامر نصية. مع دعم أصلي للغة الصينية، يفهم الأوصاف الصينية بشكل أفضل ويحولها إلى ميزات بصرية لتوليد أكثر دقة. ينتج صوراً عالية الجودة وعالية الدقة ويدعم نقل الأسلوب بدرجة معينة.",
|
||||
"step-2-16k-exp.description": "إصدار تجريبي من Step-2 يحتوي على أحدث الميزات وتحديثات مستمرة. غير موصى به للإنتاج.",
|
||||
"step-2-16k.description": "يدعم التفاعلات ذات السياق الكبير للمحادثات المعقدة.",
|
||||
"step-2-mini.description": "مبني على بنية الانتباه MFA الجيل القادم، يقدم نتائج مماثلة لـ Step-1 بتكلفة أقل بكثير، مع إنتاجية أعلى وزمن استجابة أسرع. يتعامل مع المهام العامة بقدرات قوية في البرمجة.",
|
||||
"step-2x-large.description": "نموذج صور من الجيل الجديد من StepFun يركز على توليد الصور، وينتج صوراً عالية الجودة من أوامر نصية. يتميز بواقعية أكبر في الملمس وقدرة أقوى على عرض النصوص بالصينية والإنجليزية.",
|
||||
"step-3.description": "يتميز هذا النموذج بإدراك بصري قوي واستدلال معقد، ويتعامل بدقة مع فهم المعرفة متعددة المجالات، وتحليل الرياضيات والرؤية، ومجموعة واسعة من مهام التحليل البصري اليومية.",
|
||||
"step-r1-v-mini.description": "نموذج استدلال يتمتع بفهم بصري قوي، يمكنه معالجة الصور والنصوص ثم توليد نص بعد استدلال عميق. يتفوق في الاستدلال البصري ويقدم أداءً رائداً في الرياضيات والبرمجة والاستدلال النصي، مع نافذة سياق تصل إلى 100 ألف.",
|
||||
"stepfun-ai/step3.description": "Step3 هو نموذج استدلال متعدد الوسائط متطور من StepFun، مبني على بنية MoE بإجمالي 321 مليار معامل و38 مليار نشط. تصميمه الشامل يقلل من تكلفة فك التشفير مع تقديم استدلال بصري-لغوي من الدرجة الأولى. بفضل تصميم MFA وAFD، يظل فعالاً على المعالجات الرائدة والمنخفضة الأداء. تم تدريبه مسبقاً على أكثر من 20 تريليون رمز نصي و4 تريليون رمز صورة-نص بعدة لغات. يحقق أداءً رائداً بين النماذج المفتوحة في اختبارات الرياضيات والبرمجة والوسائط المتعددة.",
|
||||
"taichu_llm.description": "مدرب على بيانات ضخمة وعالية الجودة، يتمتع بفهم نصي أقوى، وقدرات على إنشاء المحتوى، وإجابات محادثة دقيقة.",
|
||||
"taichu_o1.description": "taichu_o1 هو نموذج استدلال من الجيل التالي يستخدم التفاعل متعدد الوسائط والتعلم المعزز لتحقيق تسلسل تفكير شبيه بالبشر، يدعم محاكاة قرارات معقدة، ويعرض مسارات التفكير مع الحفاظ على دقة عالية، مناسب لتحليل الاستراتيجيات والتفكير العميق.",
|
||||
"taichu_vl.description": "يجمع بين فهم الصور، ونقل المعرفة، والاستدلال المنطقي، ويتفوق في أسئلة وأجوبة الصور والنصوص.",
|
||||
"tencent/Hunyuan-A13B-Instruct.description": "Hunyuan-A13B-Instruct يستخدم 80 مليار معامل إجمالي مع 13 مليار نشط لمضاهاة النماذج الأكبر. يدعم الاستدلال الهجين السريع/البطيء، وفهم النصوص الطويلة بثبات، وقدرات رائدة في الوكلاء على BFCL-v3 وτ-Bench. تنسيقات GQA والتكميم المتعدد تتيح استدلالاً فعالاً.",
|
||||
"tencent/Hunyuan-MT-7B.description": "نموذج الترجمة Hunyuan يشمل Hunyuan-MT-7B والنموذج المجمع Hunyuan-MT-Chimera. Hunyuan-MT-7B هو نموذج ترجمة خفيف الوزن بـ7 مليارات معامل يدعم 33 لغة بالإضافة إلى 5 لغات صينية محلية. في WMT25 حصل على المركز الأول في 30 من أصل 31 زوج لغوي. يستخدم Hunyuan من Tencent سلسلة تدريب كاملة من التدريب المسبق إلى SFT إلى التعلم المعزز للترجمة والنماذج المجمعة، محققاً أداءً رائداً بحجمه وسهولة في النشر.",
|
||||
"text-embedding-3-large.description": "أقوى نموذج تضمين للنصوص بالإنجليزية واللغات الأخرى.",
|
||||
"text-embedding-3-small-inference.description": "نموذج تضمين V3 صغير (للاستدلال) لتضمين النصوص.",
|
||||
"text-embedding-3-small.description": "نموذج تضمين من الجيل التالي فعال ومنخفض التكلفة لسيناريوهات الاسترجاع وRAG.",
|
||||
"text-embedding-ada-002.description": "نموذج تضمين V2 Ada لتضمين النصوص.",
|
||||
"thudm/glm-4-32b.description": "GLM-4-32B-0414 هو نموذج ثنائي اللغة (صيني/إنجليزي) مفتوح المصدر يحتوي على 32 مليار معامل، محسن لتوليد الأكواد، واستدعاء الوظائف، ومهام الوكلاء. تم تدريبه مسبقاً على 15 تريليون من البيانات عالية الجودة والمليئة بالاستدلال، وتم تحسينه عبر مواءمة تفضيلات البشر، وأخذ العينات بالرفض، والتعلم المعزز. يتفوق في الاستدلال المعقد، وتوليد المحتوى، والإخراج المنظم، ويصل إلى مستوى أداء GPT-4o وDeepSeek-V3-0324 في العديد من الاختبارات.",
|
||||
"thudm/glm-4-32b:free.description": "GLM-4-32B-0414 هو نموذج ثنائي اللغة (صيني/إنجليزي) مفتوح المصدر يحتوي على 32 مليار معامل، محسن لتوليد الأكواد، واستدعاء الوظائف، ومهام الوكلاء. تم تدريبه مسبقاً على 15 تريليون من البيانات عالية الجودة والمليئة بالاستدلال، وتم تحسينه عبر مواءمة تفضيلات البشر، وأخذ العينات بالرفض، والتعلم المعزز. يتفوق في الاستدلال المعقد، وتوليد المحتوى، والإخراج المنظم، ويصل إلى مستوى أداء GPT-4o وDeepSeek-V3-0324 في العديد من الاختبارات.",
|
||||
"thudm/glm-4-9b-chat.description": "الإصدار مفتوح المصدر من نموذج GLM-4 الأحدث من Zhipu AI.",
|
||||
"thudm/glm-z1-32b.description": "GLM-Z1-32B-0414 هو إصدار محسن من GLM-4-32B يركز على الاستدلال العميق في الرياضيات والمنطق وحل المشكلات البرمجية. يستخدم التعلم المعزز الموسع (حسب المهمة والعامة) لتحسين المهام متعددة الخطوات المعقدة. مقارنة بـ GLM-4-32B، يقدم Z1 تحسينات كبيرة في الاستدلال المنظم والقدرات في المجالات الرسمية.\n\nيدعم فرض خطوات التفكير عبر هندسة الأوامر، وتحسين التماسك في المخرجات الطويلة، ومصمم خصيصاً لسير عمل الوكلاء مع سياق طويل (عبر YaRN)، واستدعاء أدوات JSON، وأخذ عينات دقيقة لاستدلال مستقر. مثالي للحالات التي تتطلب اشتقاقات متعددة الخطوات أو رسمية دقيقة.",
|
||||
"thudm/glm-z1-rumination-32b.description": "GLM Z1 Rumination 32B هو نموذج تفكير عميق بسعة 32 مليار في سلسلة GLM-4-Z1، مُحسّن للمهام المعقدة المفتوحة التي تتطلب تفكيرًا طويل الأمد. مبني على glm-4-32b-0414، ويضيف مراحل تعزيز التعلم (RL) إضافية ومحاذاة متعددة المراحل، مما يقدّم قدرة \"التأمل\" التي تحاكي المعالجة المعرفية الممتدة. يشمل ذلك التفكير التكراري، والتحليل متعدد الخطوات، وسير العمل المدعوم بالأدوات مثل البحث، والاسترجاع، والتوليف المدرك للاستشهادات.\n\nيتفوّق في كتابة الأبحاث، والتحليل المقارن، والأسئلة المعقدة. يدعم استدعاء الوظائف لأساسيات البحث/التنقل (`search`، `click`، `open`، `finish`) في خطوط أنابيب الوكلاء. يتم التحكم في سلوك التأمل من خلال حلقات متعددة الجولات مع تشكيل مكافآت قائم على القواعد وآليات اتخاذ القرار المؤجل، وتمت معايرته مقابل أطر البحث العميق مثل نظام المحاذاة الداخلي لـ OpenAI. هذا الإصدار يركّز على العمق بدلاً من السرعة.",
|
||||
"tngtech/deepseek-r1t-chimera:free.description": "تم إنشاء DeepSeek-R1T-Chimera من خلال دمج DeepSeek-R1 و DeepSeek-V3 (0324)، حيث يجمع بين قدرات التفكير في R1 وكفاءة الرموز في V3. يعتمد على محول DeepSeek-MoE وتم تحسينه لتوليد النصوص العامة.\n\nيُدمج الأوزان المدربة مسبقًا لتحقيق توازن بين التفكير والكفاءة واتباع التعليمات. تم إصداره بموجب ترخيص MIT للاستخدام البحثي والتجاري.",
|
||||
"togethercomputer/StripedHyena-Nous-7B.description": "يوفّر StripedHyena Nous (7B) كفاءة حوسبة محسّنة من خلال بنيته واستراتيجيته.",
|
||||
@@ -1148,7 +1236,7 @@
|
||||
"z-ai/glm-4.5-air.description": "GLM 4.5 Air هو إصدار خفيف الوزن من GLM 4.5 مخصص للسيناريوهات الحساسة للتكلفة مع الحفاظ على قدرات استدلال قوية.",
|
||||
"z-ai/glm-4.5.description": "GLM 4.5 هو النموذج الرائد من Z.AI باستدلال هجين مُحسّن للهندسة والمهام طويلة السياق.",
|
||||
"z-ai/glm-4.6.description": "GLM 4.6 هو النموذج الرائد من Z.AI مع طول سياق ممتد وقدرات برمجية متقدمة.",
|
||||
"zai-glm-4.6.description": "يؤدي أداءً جيدًا في مهام البرمجة والاستدلال، ويدعم البث واستدعاء الأدوات، ومناسب للبرمجة الذاتية والاستدلال المعقد.",
|
||||
"z-ai/glm-4.7.description": "GLM-4.7 هو النموذج الرائد الأحدث من Zhipu، يقدم قدرات عامة محسّنة، وردوداً أبسط وأكثر طبيعية، وتجربة كتابة أكثر تفاعلية.",
|
||||
"zai-org/GLM-4.5-Air.description": "GLM-4.5-Air هو نموذج أساسي لتطبيقات الوكلاء يستخدم بنية Mixture-of-Experts. مُحسّن لاستخدام الأدوات، وتصفح الويب، والهندسة البرمجية، وبرمجة الواجهات، ويتكامل مع وكلاء البرمجة مثل Claude Code وRoo Code. يستخدم استدلالًا هجينًا للتعامل مع السيناريوهات المعقدة واليومية.",
|
||||
"zai-org/GLM-4.5.description": "GLM-4.5 هو نموذج أساسي لتطبيقات الوكلاء يستخدم بنية Mixture-of-Experts. مُحسّن بعمق لاستخدام الأدوات، وتصفح الويب، والهندسة البرمجية، وبرمجة الواجهات، ويتكامل مع وكلاء البرمجة مثل Claude Code وRoo Code. يستخدم استدلالًا هجينًا للتعامل مع السيناريوهات المعقدة واليومية.",
|
||||
"zai-org/GLM-4.5V.description": "GLM-4.5V هو أحدث نموذج رؤية من Zhipu AI، مبني على نموذج النص الرائد GLM-4.5-Air (إجمالي 106 مليار، 12 مليار نشط) باستخدام بنية MoE لأداء قوي بتكلفة أقل. يتبع مسار GLM-4.1V-Thinking ويضيف 3D-RoPE لتحسين الاستدلال المكاني ثلاثي الأبعاد. مُحسّن من خلال التدريب المسبق، والتعلم الخاضع للإشراف، والتعلم المعزز، ويتعامل مع الصور، والفيديو، والمستندات الطويلة، ويتصدر النماذج المفتوحة في 41 معيارًا متعدد الوسائط. يتيح وضع التفكير للمستخدمين التوازن بين السرعة والعمق.",
|
||||
|
||||
+10
-5
@@ -347,11 +347,6 @@
|
||||
"inspector.delete": "حذف الاستدعاء",
|
||||
"inspector.orphanedToolCall": "تم اكتشاف استدعاء مهارة يتيم، قد يؤثر على تنفيذ الوكيل. قم بإزالته.",
|
||||
"inspector.pluginRender": "عرض واجهة المهارة",
|
||||
"integrationDetail.author": "المؤلف",
|
||||
"integrationDetail.details": "التفاصيل",
|
||||
"integrationDetail.developedBy": "تم التطوير بواسطة",
|
||||
"integrationDetail.tools": "الأدوات",
|
||||
"integrationDetail.trustWarning": "استخدم الموصلات فقط من المطورين الذين تثق بهم. لا تتحكم LobeHub في الأدوات التي يتيحها المطورون ولا يمكنها التحقق من أنها ستعمل كما هو متوقع أو أنها لن تتغير.",
|
||||
"list.item.deprecated.title": "تم الحذف",
|
||||
"list.item.local.config": "الإعداد",
|
||||
"list.item.local.title": "مخصص",
|
||||
@@ -491,6 +486,16 @@
|
||||
"settings.saveSettings": "حفظ",
|
||||
"settings.title": "إعدادات مجتمع المهارات",
|
||||
"showInPortal": "عرض التفاصيل في مساحة العمل",
|
||||
"skillDetail.author": "المؤلف",
|
||||
"skillDetail.details": "التفاصيل",
|
||||
"skillDetail.developedBy": "تم التطوير بواسطة",
|
||||
"skillDetail.networkError": "فشل في تحميل البيانات. تحقق من الاتصال بالشبكة وحاول مرة أخرى.",
|
||||
"skillDetail.noAgents": "لا يوجد وكلاء يستخدمون هذه المهارة حتى الآن",
|
||||
"skillDetail.tabs.agents": "الوكلاء الذين يستخدمون هذه المهارة",
|
||||
"skillDetail.tabs.overview": "نظرة عامة",
|
||||
"skillDetail.tabs.tools": "القدرات",
|
||||
"skillDetail.tools": "الأدوات",
|
||||
"skillDetail.trustWarning": "استخدم الموصلات فقط من المطورين الذين تثق بهم. لا تتحكم LobeHub في الأدوات التي يتيحها المطورون ولا يمكنها التحقق من أنها ستعمل كما هو متوقع أو أنها لن تتغير.",
|
||||
"skillInstallBanner.title": "أضف المهارات إلى Lobe AI",
|
||||
"store.actions.cancel": "إلغاء",
|
||||
"store.actions.configure": "تهيئة",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"fireworksai.description": "توفر Fireworks AI خدمات نماذج لغوية متقدمة مع دعم استدعاء الوظائف والمعالجة متعددة الوسائط. تم تحسين Firefunction V2 (المبني على Llama-3) لاستدعاء الوظائف والدردشة وتنفيذ التعليمات، بينما يدعم FireLLaVA-13B إدخال الصور والنصوص معًا. تشمل النماذج الأخرى Llama وMixtral.",
|
||||
"giteeai.description": "توفر Gitee AI واجهات برمجة تطبيقات بدون خوادم لخدمات استدلال النماذج اللغوية الكبيرة، جاهزة للاستخدام من قبل المطورين.",
|
||||
"github.description": "مع نماذج GitHub، يمكن للمطورين العمل كمهندسي ذكاء اصطناعي باستخدام نماذج رائدة في الصناعة.",
|
||||
"githubcopilot.description": "يمكنك الوصول إلى نماذج Claude وGPT وGemini من خلال اشتراكك في GitHub Copilot.",
|
||||
"google.description": "عائلة Gemini من Google هي أكثر نماذج الذكاء الاصطناعي تطورًا للأغراض العامة، تم تطويرها بواسطة Google DeepMind للاستخدام متعدد الوسائط عبر النصوص والرموز والصور والصوت والفيديو. يمكن تشغيلها من مراكز البيانات إلى الأجهزة المحمولة بكفاءة عالية وانتشار واسع.",
|
||||
"groq.description": "توفر محركات الاستدلال LPU من Groq أداءً متميزًا في المعايير مع سرعة وكفاءة استثنائية، مما يضع معيارًا عاليًا للاستدلال منخفض الكمون في السحابة.",
|
||||
"higress.description": "Higress هو بوابة API سحابية أصلية تم تطويرها داخل Alibaba لمعالجة تأثير إعادة تحميل Tengine على الاتصالات طويلة الأمد وسد الفجوات في موازنة تحميل gRPC/Dubbo.",
|
||||
@@ -29,7 +30,6 @@
|
||||
"internlm.description": "منظمة مفتوحة المصدر تركز على أبحاث النماذج الكبيرة والأدوات، وتوفر منصة فعالة وسهلة الاستخدام تتيح الوصول إلى أحدث النماذج والخوارزميات.",
|
||||
"jina.description": "تأسست Jina AI في عام 2020، وهي شركة رائدة في مجال البحث الذكي. تشمل تقنياتها نماذج المتجهات، ومعيدو الترتيب، ونماذج لغوية صغيرة لبناء تطبيقات بحث توليدية ومتعددة الوسائط عالية الجودة.",
|
||||
"lmstudio.description": "LM Studio هو تطبيق سطح مكتب لتطوير وتجربة النماذج اللغوية الكبيرة على جهازك.",
|
||||
"lobehub.description": "تستخدم LobeHub Cloud واجهات برمجة التطبيقات الرسمية للوصول إلى نماذج الذكاء الاصطناعي، وتقيس الاستخدام من خلال الأرصدة المرتبطة برموز النماذج.",
|
||||
"minimax.description": "تأسست MiniMax في عام 2021، وتبني نماذج ذكاء اصطناعي متعددة الوسائط للأغراض العامة، بما في ذلك نماذج نصية بمليارات المعلمات، ونماذج صوتية وبصرية، بالإضافة إلى تطبيقات مثل Hailuo AI.",
|
||||
"mistral.description": "تقدم Mistral نماذج متقدمة عامة ومتخصصة وبحثية للتفكير المعقد، والمهام متعددة اللغات، وتوليد الأكواد، مع دعم استدعاء الوظائف للتكامل المخصص.",
|
||||
"modelscope.description": "ModelScope هي منصة نماذج كخدمة من Alibaba Cloud، تقدم مجموعة واسعة من النماذج وخدمات الاستدلال.",
|
||||
|
||||
@@ -34,11 +34,20 @@
|
||||
"agentCronJobs.empty.description": "أنشئ أول مهمة مجدولة لأتمتة وكيلك",
|
||||
"agentCronJobs.empty.title": "لا توجد مهام مجدولة بعد",
|
||||
"agentCronJobs.enable": "تمكين",
|
||||
"agentCronJobs.form.at": "في",
|
||||
"agentCronJobs.form.content.placeholder": "أدخل التعليمات أو الأمر للوكيل",
|
||||
"agentCronJobs.form.every": "كل",
|
||||
"agentCronJobs.form.frequency": "التكرار",
|
||||
"agentCronJobs.form.hours": "ساعة/ساعات",
|
||||
"agentCronJobs.form.maxExecutions": "توقف بعد",
|
||||
"agentCronJobs.form.maxExecutions.placeholder": "اتركه فارغًا لعدد غير محدود",
|
||||
"agentCronJobs.form.name.placeholder": "أدخل اسم المهمة",
|
||||
"agentCronJobs.form.time": "الوقت",
|
||||
"agentCronJobs.form.timeRange.end": "وقت الانتهاء",
|
||||
"agentCronJobs.form.timeRange.start": "وقت البدء",
|
||||
"agentCronJobs.form.times": "مرات",
|
||||
"agentCronJobs.form.timezone": "المنطقة الزمنية",
|
||||
"agentCronJobs.form.unlimited": "تشغيل مستمر",
|
||||
"agentCronJobs.form.validation.contentRequired": "محتوى المهمة مطلوب",
|
||||
"agentCronJobs.form.validation.invalidTimeRange": "يجب أن يكون وقت البدء قبل وقت الانتهاء",
|
||||
"agentCronJobs.form.validation.nameRequired": "اسم المهمة مطلوب",
|
||||
@@ -83,6 +92,13 @@
|
||||
"agentCronJobs.weekday.tuesday": "الثلاثاء",
|
||||
"agentCronJobs.weekday.wednesday": "الأربعاء",
|
||||
"agentCronJobs.weekdays": "أيام الأسبوع",
|
||||
"agentCronJobs.weekdays.fri": "الجمعة",
|
||||
"agentCronJobs.weekdays.mon": "الاثنين",
|
||||
"agentCronJobs.weekdays.sat": "السبت",
|
||||
"agentCronJobs.weekdays.sun": "الأحد",
|
||||
"agentCronJobs.weekdays.thu": "الخميس",
|
||||
"agentCronJobs.weekdays.tue": "الثلاثاء",
|
||||
"agentCronJobs.weekdays.wed": "الأربعاء",
|
||||
"agentInfoDescription.basic.avatar": "الصورة الرمزية",
|
||||
"agentInfoDescription.basic.description": "الوصف",
|
||||
"agentInfoDescription.basic.name": "الاسم",
|
||||
@@ -519,6 +535,10 @@
|
||||
"skillStore.tabs.custom": "مخصص",
|
||||
"skillStore.tabs.lobehub": "LobeHub",
|
||||
"skillStore.title": "متجر المهارات",
|
||||
"skillStore.wantMore.action": "إرسال طلب →",
|
||||
"skillStore.wantMore.feedback.message": "## اسم المهارة\n[يرجى التعبئة]\n\n## حالة الاستخدام\nعندما أكون ___، أحتاج إلى ___\n\n## الميزات المتوقعة\n1.\n2.\n3.\n\n## أمثلة مرجعية\n(اختياري) هل هناك أدوات أو ميزات مشابهة يمكن الرجوع إليها؟\n\n---\n💡 نصيحة: كلما كانت وصفك أكثر تحديدًا، تمكنا من تلبية احتياجاتك بشكل أفضل",
|
||||
"skillStore.wantMore.feedback.title": "[طلب مهارة] لخّص المهارة التي تحتاجها في جملة واحدة",
|
||||
"skillStore.wantMore.reachedEnd": "لقد وصلت إلى النهاية. لم تجد ما تبحث عنه؟",
|
||||
"startConversation": "ابدأ المحادثة",
|
||||
"storage.actions.export.button": "تصدير",
|
||||
"storage.actions.export.exportType.agent": "تصدير إعدادات الوكيل",
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"betterAuth.signin.signupLink": "Регистрирайте се сега",
|
||||
"betterAuth.signin.socialError": "Неуспешен вход чрез социална мрежа, опитайте отново",
|
||||
"betterAuth.signin.socialOnlyHint": "Този имейл е регистриран чрез акаунт в социална мрежа. Влезте чрез този доставчик или",
|
||||
"betterAuth.signin.ssoOnlyNoProviders": "Регистрация с имейл е деактивирана и няма конфигурирани SSO доставчици. Моля, свържете се с вашия администратор.",
|
||||
"betterAuth.signin.submit": "Вход",
|
||||
"betterAuth.signup.confirmPasswordPlaceholder": "Потвърдете паролата си",
|
||||
"betterAuth.signup.emailPlaceholder": "Въведете имейл адрес",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"codes.DELETED_ACCOUNT_EMAIL": "Този имейл е свързан с изтрит акаунт и не може да бъде използван за регистрация",
|
||||
"codes.EMAIL_CAN_NOT_BE_UPDATED": "Имейлът не може да бъде променен за този акаунт",
|
||||
"codes.EMAIL_NOT_ALLOWED": "Имейл адресът не е разрешен за регистрация",
|
||||
"codes.EMAIL_NOT_FOUND": "Няма имейл, свързан с този акаунт. Моля, проверете дали акаунтът ви има свързан имейл.",
|
||||
"codes.EMAIL_NOT_VERIFIED": "Моля, първо потвърдете имейла си",
|
||||
"codes.FAILED_TO_CREATE_SESSION": "Неуспешно създаване на сесия",
|
||||
"codes.FAILED_TO_CREATE_USER": "Неуспешно създаване на потребител",
|
||||
|
||||
@@ -188,6 +188,7 @@
|
||||
"cmdk.submitIssue": "Изпрати проблем",
|
||||
"cmdk.theme": "Тема",
|
||||
"cmdk.themeAuto": "Автоматично",
|
||||
"cmdk.themeCurrent": "Текуща",
|
||||
"cmdk.themeDark": "Тъмна",
|
||||
"cmdk.themeLight": "Светла",
|
||||
"cmdk.toOpen": "Отвори",
|
||||
|
||||
@@ -194,6 +194,9 @@
|
||||
"mcp.categories.weather.name": "Времето",
|
||||
"mcp.categories.web-search.description": "Уеб търсене и извличане на информация",
|
||||
"mcp.categories.web-search.name": "Извличане на информация",
|
||||
"mcp.details.agents.empty": "Все още няма агенти, които използват този умение",
|
||||
"mcp.details.agents.networkError": "Неуспешно зареждане на данни. Проверете мрежовата връзка и опитайте отново.",
|
||||
"mcp.details.agents.title": "Агенти, използващи този умение",
|
||||
"mcp.details.connectionType.hybrid.desc": "Тази услуга може да работи локално или в облака в зависимост от конфигурацията или сценария на използване, предлагайки възможност за двойна работа.",
|
||||
"mcp.details.connectionType.hybrid.title": "Хибридна услуга",
|
||||
"mcp.details.connectionType.local.desc": "Този сървър може да работи само на локалното устройство на клиента, изисква инсталация и разчита на локални ресурси.",
|
||||
|
||||
@@ -16,13 +16,17 @@
|
||||
"navigation.memoryExperiences": "Памят - Преживявания",
|
||||
"navigation.memoryIdentities": "Памят - Идентичности",
|
||||
"navigation.memoryPreferences": "Памят - Предпочитания",
|
||||
"navigation.noPages": "Все още няма страници",
|
||||
"navigation.onboarding": "Въведение",
|
||||
"navigation.page": "Страница",
|
||||
"navigation.pages": "Страници",
|
||||
"navigation.pin": "Закачи",
|
||||
"navigation.pinned": "Закачено",
|
||||
"navigation.provider": "Доставчик",
|
||||
"navigation.recentView": "Последни преглеждания",
|
||||
"navigation.resources": "Ресурси",
|
||||
"navigation.settings": "Настройки",
|
||||
"navigation.unpin": "Откачи",
|
||||
"notification.finishChatGeneration": "Генерирането на съобщение от ИИ е завършено",
|
||||
"proxy.auth": "Изисква се удостоверяване",
|
||||
"proxy.authDesc": "Ако прокси сървърът изисква потребителско име и парола",
|
||||
|
||||
@@ -176,6 +176,26 @@
|
||||
"providerModels.config.fetchOnClient.desc": "Режимът на клиентска заявка ще стартира сесии директно от браузъра, което може да подобри скоростта на отговор",
|
||||
"providerModels.config.fetchOnClient.title": "Използвай клиентски режим на заявка",
|
||||
"providerModels.config.helpDoc": "Ръководство за конфигурация",
|
||||
"providerModels.config.oauth.authError": "Удостоверяването не бе успешно. Моля, опитайте отново.",
|
||||
"providerModels.config.oauth.authorized": "Удостоверено",
|
||||
"providerModels.config.oauth.authorizedDesc": "Свързахте се с {{name}}. Щракнете, за да прекъснете връзката.",
|
||||
"providerModels.config.oauth.cancel": "Отказ",
|
||||
"providerModels.config.oauth.codeExpired": "Кодът за удостоверяване е изтекъл. Моля, опитайте отново.",
|
||||
"providerModels.config.oauth.connect": "Свържете се с {{name}}",
|
||||
"providerModels.config.oauth.connectDesc": "Щракнете, за да се удостоверите чрез браузър. Не се изисква API ключ.",
|
||||
"providerModels.config.oauth.connected": "Свързано",
|
||||
"providerModels.config.oauth.connecting": "Свързване...",
|
||||
"providerModels.config.oauth.copyCode": "Копирай кода",
|
||||
"providerModels.config.oauth.denied": "Удостоверяването беше отказано. Моля, опитайте отново.",
|
||||
"providerModels.config.oauth.desc": "Удостоверете се с вашия акаунт в {{name}}, за да получите достъп до моделите чрез вашия абонамент.",
|
||||
"providerModels.config.oauth.disconnect": "Прекъсни връзката",
|
||||
"providerModels.config.oauth.disconnectConfirm": "Сигурни ли сте, че искате да прекъснете връзката? Ще трябва да се удостоверите отново, за да използвате този доставчик.",
|
||||
"providerModels.config.oauth.enterCode": "Въведете кода на отворената страница:",
|
||||
"providerModels.config.oauth.openBrowser": "Отворете браузър за удостоверяване",
|
||||
"providerModels.config.oauth.polling": "Изчакване на удостоверяване...",
|
||||
"providerModels.config.oauth.retry": "Опитай отново",
|
||||
"providerModels.config.oauth.serviceNote": "Услугата се предоставя от {{name}}",
|
||||
"providerModels.config.oauth.title": "OAuth удостоверяване",
|
||||
"providerModels.config.responsesApi.desc": "Използва новия формат на заявки на OpenAI за отключване на разширени функции като верига на мисълта (поддържа се само от OpenAI модели)",
|
||||
"providerModels.config.responsesApi.title": "Използвай Responses API спецификация",
|
||||
"providerModels.config.waitingForMore": "В момента се <1>планира интеграция</1> на още модели, следете за новини",
|
||||
|
||||
+103
-14
@@ -90,6 +90,7 @@
|
||||
"Phi-3-small-8k-instruct.description": "Модел с 7B параметри с по-високо качество от Phi-3-mini, фокусиран върху данни с високо качество и интензивно разсъждение.",
|
||||
"Phi-3.5-mini-instruct.description": "Актуализирана версия на модела Phi-3-mini.",
|
||||
"Phi-3.5-vision-instrust.description": "Актуализирана версия на модела Phi-3-vision.",
|
||||
"Pro/MiniMaxAI/MiniMax-M2.1.description": "MiniMax-M2.1 е отворен модел с голям езиков капацитет, оптимизиран за агентни способности, с изключителни резултати в програмиране, използване на инструменти, следване на инструкции и дългосрочно планиране. Моделът поддържа многоезична разработка на софтуер и изпълнение на сложни многoетапни работни процеси, постигайки резултат от 74.0 в SWE-bench Verified и надминава Claude Sonnet 4.5 в многоезични сценарии.",
|
||||
"Pro/Qwen/Qwen2-7B-Instruct.description": "Qwen2-7B-Instruct е 7B модел с инструкции от серията Qwen2. Използва трансформерна архитектура със SwiGLU, QKV bias и групирано внимание, и обработва големи входове. Постига отлични резултати в езиково разбиране, генериране, многоезични задачи, програмиране, математика и разсъждение, надминавайки повечето отворени модели и конкурирайки се със затворени.",
|
||||
"Pro/Qwen/Qwen2.5-7B-Instruct.description": "Qwen2.5-7B-Instruct е част от най-новата серия LLM на Alibaba Cloud. Моделът с 7B параметри носи значителни подобрения в програмирането и математиката, поддържа над 29 езика и подобрява следването на инструкции, разбирането на структурирани данни и генерирането на структурирани изходи (особено JSON).",
|
||||
"Pro/Qwen/Qwen2.5-Coder-7B-Instruct.description": "Qwen2.5-Coder-7B-Instruct е най-новият LLM на Alibaba Cloud, фокусиран върху програмиране. Изграден върху Qwen2.5 и обучен с 5.5T токена, значително подобрява генерирането на код, разсъждението и поправката, като същевременно запазва силни математически и общи способности, осигурявайки стабилна основа за кодови агенти.",
|
||||
@@ -271,21 +272,26 @@
|
||||
"chatgpt-4o-latest.description": "ChatGPT-4o е динамичен модел, актуализиран в реално време, комбиниращ силно разбиране и генериране за мащабни приложения като клиентска поддръжка, образование и техническа помощ.",
|
||||
"claude-2.0.description": "Claude 2 предлага ключови подобрения за предприятия, включително водещ контекст от 200 000 токена, намалени халюцинации, системни подканвания и нова тестова функция: използване на инструменти.",
|
||||
"claude-2.1.description": "Claude 2 предлага ключови подобрения за предприятия, включително водещ контекст от 200 000 токена, намалени халюцинации, системни подканвания и нова тестова функция: използване на инструменти.",
|
||||
"claude-3-5-haiku-20241022.description": "Claude 3.5 Haiku е най-бързият модел от ново поколение на Anthropic, с подобрени умения и превъзхождащ предишния водещ модел Claude 3 Opus в много бенчмаркове.",
|
||||
"claude-3-5-haiku-20241022.description": "Claude 3.5 Haiku е най-бързият модел от ново поколение на Anthropic. В сравнение с Claude 3 Haiku, той показва подобрения в различни умения и надминава предишния най-голям модел Claude 3 Opus в много интелигентностни тестове.",
|
||||
"claude-3-5-haiku-latest.description": "Claude 3.5 Haiku осигурява бързи отговори за леки задачи.",
|
||||
"claude-3-7-sonnet-20250219.description": "Claude Sonnet 3.7 е най-интелигентният модел на Anthropic и първият хибриден модел за разсъждение на пазара, предлагащ почти мигновени отговори или разширено мислене с прецизен контрол.",
|
||||
"claude-3-7-sonnet-20250219.description": "Claude 3.7 Sonnet е най-интелигентният модел на Anthropic и първият хибриден модел за разсъждение на пазара. Той може да генерира почти мигновени отговори или разширено поетапно разсъждение, което потребителите могат да проследят. Sonnet е особено силен в програмиране, анализ на данни, визуални задачи и агентни приложения.",
|
||||
"claude-3-7-sonnet-latest.description": "Claude 3.7 Sonnet е най-новият и най-способен модел на Anthropic за силно сложни задачи, отличаващ се с производителност, интелигентност, плавност и разбиране.",
|
||||
"claude-3-haiku-20240307.description": "Claude 3 Haiku е най-бързият и най-компактен модел на Anthropic, проектиран за почти мигновени отговори с бърза и точна производителност.",
|
||||
"claude-3-opus-20240229.description": "Claude 3 Opus е най-мощният модел на Anthropic за силно сложни задачи, отличаващ се с производителност, интелигентност, плавност и разбиране.",
|
||||
"claude-3-sonnet-20240229.description": "Claude 3 Sonnet балансира интелигентност и скорост за корпоративни натоварвания, осигурявайки висока полезност на по-ниска цена и надеждно мащабно внедряване.",
|
||||
"claude-haiku-4-5-20251001.description": "Claude Haiku 4.5 е най-бързият и най-интелигентен Haiku модел на Anthropic, с мълниеносна скорост и разширено мислене.",
|
||||
"claude-3.5-sonnet.description": "Claude 3.5 Sonnet се отличава в програмиране, писане и сложни логически разсъждения.",
|
||||
"claude-3.7-sonnet-thought.description": "Claude 3.7 Sonnet с разширено мислене за задачи, изискващи сложни разсъждения.",
|
||||
"claude-3.7-sonnet.description": "Claude 3.7 Sonnet е подобрена версия с разширен контекст и възможности.",
|
||||
"claude-haiku-4-5-20251001.description": "Claude Haiku 4.5 е най-бързият и най-интелигентен Haiku модел на Anthropic, с мълниеносна скорост и разширено разсъждение.",
|
||||
"claude-haiku-4.5.description": "Claude Haiku 4.5 е бърз и ефективен модел за различни задачи.",
|
||||
"claude-opus-4-1-20250805-thinking.description": "Claude Opus 4.1 Thinking е усъвършенстван вариант, който може да разкрие процеса си на разсъждение.",
|
||||
"claude-opus-4-1-20250805.description": "Claude Opus 4.1 е най-новият и най-способен модел на Anthropic за силно комплексни задачи, отличаващ се с висока производителност, интелигентност, плавност и разбиране.",
|
||||
"claude-opus-4-20250514.description": "Claude Opus 4 е най-мощният модел на Anthropic за силно комплексни задачи, отличаващ се с висока производителност, интелигентност, плавност и разбиране.",
|
||||
"claude-opus-4-5-20251101.description": "Claude Opus 4.5 е флагманският модел на Anthropic, комбиниращ изключителна интелигентност с мащабируема производителност, идеален за сложни задачи, изискващи най-висококачествени отговори и разсъждение.",
|
||||
"claude-sonnet-4-20250514-thinking.description": "Claude Sonnet 4 Thinking може да генерира почти мигновени отговори или разширено стъпково мислене с видим процес.",
|
||||
"claude-sonnet-4-20250514.description": "Claude Sonnet 4 е най-интелигентният модел на Anthropic досега, предлагащ почти мигновени отговори или разширено поетапно мислене с прецизен контрол за потребителите на API.",
|
||||
"claude-sonnet-4-5-20250929.description": "Claude Sonnet 4.5 е най-интелигентният модел на Anthropic досега.",
|
||||
"claude-sonnet-4-20250514.description": "Claude Sonnet 4 може да генерира почти мигновени отговори или разширено поетапно мислене с видим процес.",
|
||||
"claude-sonnet-4-5-20250929.description": "Claude Sonnet 4.5 е най-интелигентният модел на Anthropic до момента.",
|
||||
"claude-sonnet-4.description": "Claude Sonnet 4 е най-новото поколение с подобрена производителност във всички задачи.",
|
||||
"codegeex-4.description": "CodeGeeX-4 е мощен AI асистент за програмиране, който поддържа многоезични въпроси и допълване на код, повишавайки продуктивността на разработчиците.",
|
||||
"codegeex4-all-9b.description": "CodeGeeX4-ALL-9B е многоезичен модел за генериране на код, който поддържа допълване и създаване на код, интерпретиране, уеб търсене, извикване на функции и въпроси на ниво хранилище. Подходящ е за широк спектър от софтуерни сценарии и е водещ модел под 10 милиарда параметри.",
|
||||
"codegemma.description": "CodeGemma е лек модел за разнообразни програмни задачи, позволяващ бърза итерация и интеграция.",
|
||||
@@ -351,7 +357,6 @@
|
||||
"deepseek-ai/DeepSeek-V3.2-Exp.description": "DeepSeek-V3.2-Exp е експериментална версия V3.2, която служи като мост към следващата архитектура. Добавя DeepSeek Sparse Attention (DSA) върху V3.1-Terminus за подобряване на ефективността при обучение и извеждане с дълъг контекст, с оптимизации за използване на инструменти, разбиране на дълги документи и многoетапно разсъждение. Идеален е за изследване на по-висока ефективност при разсъждение с големи контекстуални бюджети.",
|
||||
"deepseek-ai/DeepSeek-V3.description": "DeepSeek-V3 е MoE модел с 671 милиарда параметъра, използващ MLA и DeepSeekMoE с балансирано натоварване без загуби за ефективно обучение и извеждане. Предварително обучен върху 14.8 трилиона висококачествени токени със SFT и RL, той превъзхожда други отворени модели и се доближава до водещите затворени модели.",
|
||||
"deepseek-ai/deepseek-llm-67b-chat.description": "DeepSeek LLM Chat (67B) е иновативен модел, предлагащ дълбоко езиково разбиране и интеракция.",
|
||||
"deepseek-ai/deepseek-r1.description": "Модел от ново поколение с висока ефективност, силен в разсъждение, математика и програмиране.",
|
||||
"deepseek-ai/deepseek-v3.1-terminus.description": "DeepSeek V3.1 е модел за разсъждение от ново поколение с по-силни способности за сложни разсъждения и верига от мисли за задълбочени аналитични задачи.",
|
||||
"deepseek-ai/deepseek-v3.1.description": "DeepSeek V3.1 е модел за разсъждение от ново поколение с по-силни способности за сложни разсъждения и верига от мисли за задълбочени аналитични задачи.",
|
||||
"deepseek-ai/deepseek-vl2.description": "DeepSeek-VL2 е MoE модел за визия и език, базиран на DeepSeekMoE-27B със слаба активация, постигайки висока производителност с едва 4.5 милиарда активни параметъра. Отличава се в визуални въпроси и отговори, OCR, разбиране на документи/таблици/графики и визуално привързване.",
|
||||
@@ -472,7 +477,6 @@
|
||||
"ernie-tiny-8k.description": "ERNIE Tiny 8K е ултралек модел за прости QA, класификация и нискоразходно извеждане.",
|
||||
"ernie-x1-turbo-32k.description": "ERNIE X1 Turbo 32K е бърз мислещ модел с 32K контекст за сложни разсъждения и многозавойни разговори.",
|
||||
"ernie-x1.1-preview.description": "ERNIE X1.1 Preview е предварителен модел за мислене, предназначен за оценка и тестване.",
|
||||
"fal-ai/bytedance/seedream/v4.5.description": "Seedream 4.5, разработен от екипа Seed на ByteDance, поддържа редактиране и композиране на множество изображения. Отличава се с подобрена консистентност на обектите, точно следване на инструкции, разбиране на пространствена логика, естетическо изразяване, оформление на плакати и дизайн на лога с високопрецизно рендиране на текст и изображения.",
|
||||
"fal-ai/bytedance/seedream/v4.description": "Seedream 4.0, създаден от ByteDance Seed, поддържа вход от текст и изображения за висококачествено и контролируемо генериране на изображения по подадени подсказки.",
|
||||
"fal-ai/flux-kontext/dev.description": "FLUX.1 модел, фокусиран върху редактиране на изображения, поддържащ вход от текст и изображения.",
|
||||
"fal-ai/flux-pro/kontext.description": "FLUX.1 Kontext [pro] приема текст и референтни изображения като вход, позволявайки целенасочени локални редакции и сложни глобални трансформации на сцени.",
|
||||
@@ -514,8 +518,6 @@
|
||||
"gemini-2.0-flash-lite-001.description": "Вариант на Gemini 2.0 Flash, оптимизиран за ниска цена и ниска латентност.",
|
||||
"gemini-2.0-flash-lite.description": "Вариант на Gemini 2.0 Flash, оптимизиран за ниска цена и ниска латентност.",
|
||||
"gemini-2.0-flash.description": "Gemini 2.0 Flash предлага функции от ново поколение, включително изключителна скорост, вградена употреба на инструменти, мултимодално генериране и контекстен прозорец от 1 милион токена.",
|
||||
"gemini-2.5-flash-image-preview.description": "Nano Banana е най-новият, най-бърз и най-ефективен роден мултимодален модел на Google, позволяващ разговорно генериране и редактиране на изображения.",
|
||||
"gemini-2.5-flash-image-preview:image.description": "Nano Banana е най-новият, най-бърз и най-ефективен роден мултимодален модел на Google, позволяващ разговорно генериране и редактиране на изображения.",
|
||||
"gemini-2.5-flash-image.description": "Nano Banana е най-новият, най-бърз и най-ефективен роден мултимодален модел на Google, позволяващ разговорно генериране и редактиране на изображения.",
|
||||
"gemini-2.5-flash-image:image.description": "Nano Banana е най-новият, най-бърз и най-ефективен роден мултимодален модел на Google, позволяващ разговорно генериране и редактиране на изображения.",
|
||||
"gemini-2.5-flash-lite-preview-06-17.description": "Gemini 2.5 Flash-Lite Preview е най-малкият и най-изгоден модел на Google, проектиран за мащабна употреба.",
|
||||
@@ -543,7 +545,7 @@
|
||||
"generalv3.5.description": "Spark Max е най-пълнофункционалната версия, поддържаща уеб търсене и множество вградени плъгини. Напълно оптимизираните основни възможности, системни роли и извикване на функции осигуряват отлична производителност в сложни приложения.",
|
||||
"generalv3.description": "Spark Pro е високопроизводителен LLM, оптимизиран за професионални области като математика, програмиране, здравеопазване и образование. Поддържа уеб търсене и вградени плъгини като прогноза за времето и дата. Осигурява силна производителност и ефективност при сложни въпроси, езиково разбиране и напреднало текстово създаване, което го прави идеален за професионални приложения.",
|
||||
"glm-4-0520.description": "GLM-4-0520 е най-новата версия на модела, проектирана за изключително сложни и разнообразни задачи с отлична производителност.",
|
||||
"glm-4-32b-0414.description": "GLM-4 32B 0414 е универсален GLM модел, поддържащ многозадачно генериране и разбиране на текст.",
|
||||
"glm-4-7.description": "GLM-4.7 е най-новият флагмански модел на Zhipu AI. Той подобрява способностите за програмиране, дългосрочно планиране на задачи и сътрудничество с инструменти в сценарии с агентно кодиране, постигайки водещи резултати сред отворените модели в множество публични бенчмаркове. Общите възможности са подобрени, с по-кратки и естествени отговори и по-завладяващ стил на писане. При сложни агентни задачи следването на инструкции е по-силно при извикване на инструменти, а естетиката на артефактите и интерфейса за агентно кодиране, както и ефективността при изпълнение на дългосрочни задачи, са допълнително усъвършенствани. • По-силни програмистки способности: Значително подобрено кодиране на множество езици и представяне на терминални агенти; GLM-4.7 вече може да прилага механизми „първо мисли, после действай“ в рамки като Claude Code, Kilo Code, TRAE, Cline и Roo Code, с по-стабилна работа при сложни задачи. • Подобрена естетика на интерфейса: GLM-4.7 показва значителен напредък в качеството на генериране на интерфейс, способен да създава уебсайтове, презентации и постери с по-добра визуална привлекателност. • По-силни възможности за извикване на инструменти: GLM-4.7 подобрява способностите за използване на инструменти, с резултат 67 в оценката BrowseComp за уеб задачи; постига 84.7 в τ²-Bench за интерактивно извикване на инструменти, надминавайки Claude Sonnet 4.5 като отворен SOTA модел. • Подобрени способности за разсъждение: Значително подобрени математически и логически умения, с резултат 42.8% в бенчмарка HLE („Последният изпит на човечеството“), 41% подобрение спрямо GLM-4.6, надминавайки GPT-5.1. • Общи подобрения: Разговорите с GLM-4.7 са по-кратки, интелигентни и човечни; писането и ролевите игри са по-литературни и завладяващи.",
|
||||
"glm-4-9b-chat.description": "GLM-4-9B-Chat се представя силно в семантика, математика, логика, програмиране и знания. Поддържа също така уеб браузване, изпълнение на код, извикване на персонализирани инструменти и логическо мислене върху дълги текстове, с поддръжка на 26 езика, включително японски, корейски и немски.",
|
||||
"glm-4-air-250414.description": "GLM-4-Air е високостойностен вариант с производителност, близка до GLM-4, висока скорост и по-ниска цена.",
|
||||
"glm-4-air.description": "GLM-4-Air е високостойностен вариант с производителност, близка до GLM-4, висока скорост и по-ниска цена.",
|
||||
@@ -558,11 +560,12 @@
|
||||
"glm-4.1v-thinking-flashx.description": "GLM-4.1V-Thinking е най-силният известен ~10B VLM, покриващ SOTA задачи като разбиране на видео, QA по изображения, решаване на задачи, OCR, четене на документи и графики, GUI агенти, фронтенд програмиране и обоснованост. Надминава дори 8 пъти по-големия Qwen2.5-VL-72B в много задачи. С усъвършенствано RL, използва верижно мислене за подобрена точност и богатство, превъзхождайки традиционните модели без мислене както по резултати, така и по обяснимост.",
|
||||
"glm-4.5-air.description": "GLM-4.5 лек вариант, който балансира производителност и цена, с гъвкави хибридни режими на мислене.",
|
||||
"glm-4.5-airx.description": "GLM-4.5-Air бърз вариант с по-бързи отговори за мащабни и високоскоростни приложения.",
|
||||
"glm-4.5-flash.description": "Безплатен GLM-4.5 слой с висока производителност при логическо мислене, програмиране и агентни задачи.",
|
||||
"glm-4.5-x.description": "GLM-4.5 бърз вариант, осигуряващ силна производителност със скорост на генериране до 100 токена/секунда.",
|
||||
"glm-4.5.description": "Флагманският модел на Zhipu с превключваем режим на мислене, осигуряващ SOTA производителност с отворен код и до 128K контекст.",
|
||||
"glm-4.5v.description": "Следващото поколение MoE визуален логически модел на Zhipu с 106 милиарда общи параметъра и 12 милиарда активни, постигащ SOTA сред отворените мултимодални модели със сходен размер в задачи с изображения, видео, документи и GUI.",
|
||||
"glm-4.6.description": "Най-новият флагмански модел на Zhipu GLM-4.6 (355B) напълно надминава предшествениците си в напреднало програмиране, обработка на дълги текстове, логическо мислене и агентни способности. Особено се доближава до Claude Sonnet 4 по програмиране, превръщайки се в най-добрия кодиращ модел в Китай.",
|
||||
"glm-4.7-flash.description": "GLM-4.7-Flash, като SOTA модел от ниво 30B, предлага нов избор, който балансира между производителност и ефективност. Подобрява способностите за програмиране, дългосрочно планиране на задачи и сътрудничество с инструменти в сценарии с агентно кодиране, постигайки водещи резултати сред отворените модели със същия размер в множество актуални бенчмаркове. При изпълнение на сложни интелигентни агентни задачи, моделът показва по-силно следване на инструкции при извикване на инструменти и допълнително подобрява естетиката на интерфейса и ефективността при изпълнение на дългосрочни задачи за артефакти и агентно кодиране.",
|
||||
"glm-4.7-flashx.description": "GLM-4.7-Flash, като SOTA модел от ниво 30B, предлага нов избор, който балансира между производителност и ефективност. Подобрява способностите за програмиране, дългосрочно планиране на задачи и сътрудничество с инструменти в сценарии с агентно кодиране, постигайки водещи резултати сред отворените модели със същия размер в множество актуални бенчмаркове. При изпълнение на сложни интелигентни агентни задачи, моделът показва по-силно следване на инструкции при извикване на инструменти и допълнително подобрява естетиката на интерфейса и ефективността при изпълнение на дългосрочни задачи за артефакти и агентно кодиране.",
|
||||
"glm-4.7.description": "GLM-4.7 е най-новият флагмански модел на Zhipu, подобрен за сценарии с агентно програмиране с по-добри способности за кодиране, дългосрочно планиране на задачи и сътрудничество с инструменти. Постига водеща производителност сред отворените модели в множество публични бенчмаркове. Общите способности са подобрени с по-кратки и естествени отговори и по-завладяващо писане. При сложни агентни задачи следването на инструкции при извикване на инструменти е по-силно, а естетиката на интерфейса и ефективността при изпълнение на дългосрочни задачи в Artifacts и Agentic Coding са допълнително подобрени.",
|
||||
"glm-4.description": "GLM-4 е по-старият флагман, пуснат през януари 2024 г., сега заменен от по-силния GLM-4-0520.",
|
||||
"glm-4v-flash.description": "GLM-4V-Flash се фокусира върху ефективно разбиране на единични изображения за бързи анализи като обработка в реално време или на партиди.",
|
||||
@@ -609,6 +612,7 @@
|
||||
"google/text-embedding-005.description": "Модел за вграждане на текст, фокусиран върху английски език, оптимизиран за задачи с код и английски език.",
|
||||
"google/text-multilingual-embedding-002.description": "Многоезичен модел за вграждане на текст, оптимизиран за задачи с кръстосан езиков обхват на много езици.",
|
||||
"gpt-3.5-turbo-0125.description": "GPT 3.5 Turbo за генериране и разбиране на текст; в момента сочи към gpt-3.5-turbo-0125.",
|
||||
"gpt-3.5-turbo-0613.description": "GPT 3.5 Turbo е бърз и ефективен модел за различни задачи.",
|
||||
"gpt-3.5-turbo-1106.description": "GPT 3.5 Turbo за генериране и разбиране на текст; в момента сочи към gpt-3.5-turbo-0125.",
|
||||
"gpt-3.5-turbo-instruct.description": "GPT 3.5 Turbo за задачи с генериране и разбиране на текст, оптимизиран за следване на инструкции.",
|
||||
"gpt-3.5-turbo.description": "GPT 3.5 Turbo за генериране и разбиране на текст; в момента сочи към gpt-3.5-turbo-0125.",
|
||||
@@ -619,10 +623,12 @@
|
||||
"gpt-4-1106-preview.description": "Най-новият GPT-4 Turbo добавя възможности за визуално разпознаване. Визуалните заявки поддържат JSON режим и извикване на функции. Това е рентабилен мултимодален модел, който балансира точността и ефективността за приложения в реално време.",
|
||||
"gpt-4-32k-0613.description": "GPT-4 предлага по-голям контекстов прозорец за обработка на по-дълги входове в сценарии, изискващи интеграция на широка информация и анализ на данни.",
|
||||
"gpt-4-32k.description": "GPT-4 предлага по-голям контекстов прозорец за обработка на по-дълги входове в сценарии, изискващи интеграция на широка информация и анализ на данни.",
|
||||
"gpt-4-o-preview.description": "GPT-4o е най-усъвършенстваният мултимодален модел, който обработва текстови и визуални входове.",
|
||||
"gpt-4-turbo-2024-04-09.description": "Най-новият GPT-4 Turbo добавя възможности за визуално разпознаване. Визуалните заявки поддържат JSON режим и извикване на функции. Това е рентабилен мултимодален модел, който балансира точността и ефективността за приложения в реално време.",
|
||||
"gpt-4-turbo-preview.description": "Най-новият GPT-4 Turbo добавя възможности за визуално разпознаване. Визуалните заявки поддържат JSON режим и извикване на функции. Това е рентабилен мултимодален модел, който балансира точността и ефективността за приложения в реално време.",
|
||||
"gpt-4-turbo.description": "Най-новият GPT-4 Turbo добавя възможности за визуално разпознаване. Визуалните заявки поддържат JSON режим и извикване на функции. Това е рентабилен мултимодален модел, който балансира точността и ефективността за приложения в реално време.",
|
||||
"gpt-4-vision-preview.description": "Предварителен преглед на GPT-4 Vision, създаден за задачи по анализ и обработка на изображения.",
|
||||
"gpt-4.1-2025-04-14.description": "GPT-4.1 е водещият модел за сложни задачи, идеален за решаване на междудисциплинарни проблеми.",
|
||||
"gpt-4.1-mini.description": "GPT-4.1 mini балансира интелигентност, скорост и цена, което го прави привлекателен за множество приложения.",
|
||||
"gpt-4.1-nano.description": "GPT-4.1 nano е най-бързият и най-рентабилен модел от серията GPT-4.1.",
|
||||
"gpt-4.1.description": "GPT-4.1 е водещият ни модел за сложни задачи и решаване на проблеми в различни области.",
|
||||
@@ -632,6 +638,7 @@
|
||||
"gpt-4o-2024-08-06.description": "ChatGPT-4o е динамичен модел, актуализиран в реално време. Съчетава силно езиково разбиране и генериране за мащабни приложения като клиентска поддръжка, образование и техническа помощ.",
|
||||
"gpt-4o-2024-11-20.description": "ChatGPT-4o е динамичен модел, актуализиран в реално време, който съчетава силно разбиране и генериране за мащабни приложения като клиентска поддръжка, образование и техническа помощ.",
|
||||
"gpt-4o-audio-preview.description": "Предварителен преглед на GPT-4o Audio модел с аудио вход и изход.",
|
||||
"gpt-4o-mini-2024-07-18.description": "GPT-4o mini е икономично решение за широк спектър от текстови и визуални задачи.",
|
||||
"gpt-4o-mini-audio-preview.description": "GPT-4o mini Audio модел с аудио вход и изход.",
|
||||
"gpt-4o-mini-realtime-preview.description": "GPT-4o-mini вариант в реално време с аудио и текстов вход/изход в реално време.",
|
||||
"gpt-4o-mini-search-preview.description": "GPT-4o mini Search Preview е обучен да разбира и изпълнява заявки за уеб търсене чрез Chat Completions API. Уеб търсенето се таксува на извикване на инструмент в допълнение към разходите за токени.",
|
||||
@@ -654,8 +661,8 @@
|
||||
"gpt-5.1-codex-mini.description": "GPT-5.1 Codex mini: по-малък и по-евтин вариант на Codex, оптимизиран за агентски задачи по програмиране.",
|
||||
"gpt-5.1-codex.description": "GPT-5.1 Codex: вариант на GPT-5.1, оптимизиран за агентски задачи по програмиране, за сложни кодови/агентски работни потоци в Responses API.",
|
||||
"gpt-5.1.description": "GPT-5.1 — водещ модел, оптимизиран за програмиране и агентски задачи с конфигурируемо усилие за разсъждение и по-дълъг контекст.",
|
||||
"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-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.description": "Най-добрият модел за междудисциплинарно програмиране и агентски задачи. GPT-5 прави скок в точността, скоростта, разсъждението, осъзнаването на контекста, структурираното мислене и решаването на проблеми.",
|
||||
"gpt-audio.description": "GPT Audio е универсален чат модел за вход/изход на аудио, поддържан в Chat Completions API.",
|
||||
@@ -734,6 +741,89 @@
|
||||
"inclusionai/ring-1t.description": "Ring-1T е MoE модел на inclusionAI с трилион параметри, предназначен за мащабни задачи по разсъждение и научни изследвания.",
|
||||
"inclusionai/ring-flash-2.0.description": "Ring-flash-2.0 е вариант на модела Ring от inclusionAI за сценарии с висока пропускателна способност, с акцент върху скоростта и ефективността на разходите.",
|
||||
"inclusionai/ring-mini-2.0.description": "Ring-mini-2.0 е лек MoE модел на inclusionAI с висока пропускателна способност, създаден за едновременна работа.",
|
||||
"internlm/internlm2_5-7b-chat.description": "InternLM2.5-7B-Chat е модел с отворен код, базиран на архитектурата InternLM2. Моделът с 7B параметъра е фокусиран върху генериране на диалог с поддръжка на китайски и английски език, използвайки съвременно обучение за плавен и интелигентен разговор. Подходящ е за различни сценарии като клиентска поддръжка и лични асистенти.",
|
||||
"internlm2.5-latest.description": "Наследени модели, които все още се поддържат с отлична и стабилна производителност след множество итерации. Предлагат се във варианти с 7B и 20B параметъра, поддържат 1M контекст и по-силно следване на инструкции и използване на инструменти. По подразбиране се използва най-новата серия InternLM2.5 (в момента internlm2.5-20b-chat).",
|
||||
"internlm3-latest.description": "Нашата най-нова серия модели с отлична логическа производителност, водеща сред отворените модели в своя клас. По подразбиране се използва най-новата серия InternLM3 (в момента internlm3-8b-instruct).",
|
||||
"internvl2.5-38b-mpo.description": "InternVL2.5 38B MPO е мултимодален предварително обучен модел за сложни задачи с изображения и текст.",
|
||||
"internvl2.5-latest.description": "InternVL2.5 все още се поддържа с висока и стабилна производителност. По подразбиране се използва най-новата серия InternVL2.5 (в момента internvl2.5-78b).",
|
||||
"internvl3-14b.description": "InternVL3 14B е среден по размер мултимодален модел, който балансира между производителност и разходи.",
|
||||
"internvl3-1b.description": "InternVL3 1B е лек мултимодален модел, подходящ за внедряване при ограничени ресурси.",
|
||||
"internvl3-38b.description": "InternVL3 38B е голям мултимодален модел с отворен код за високоточна интерпретация на изображения и текст.",
|
||||
"internvl3-latest.description": "Нашият най-нов мултимодален модел с по-силно разбиране на изображения и текст и възможност за обработка на дълги последователности от изображения, сравним с водещите затворени модели. По подразбиране се използва най-новата серия InternVL (в момента internvl3-78b).",
|
||||
"irag-1.0.description": "ERNIE iRAG е модел за генериране с добавено извличане на изображения, подходящ за търсене на изображения, извличане на информация от изображения и генериране на съдържание.",
|
||||
"jamba-large.description": "Нашият най-мощен и усъвършенстван модел, създаден за сложни корпоративни задачи с изключителна производителност.",
|
||||
"jamba-mini.description": "Най-ефективният модел в своя клас, който балансира между скорост и качество с малък ресурсен отпечатък.",
|
||||
"jina-deepsearch-v1.description": "DeepSearch комбинира уеб търсене, четене и разсъждение за задълбочени изследвания. Представете си го като агент, който поема вашата изследователска задача, извършва широки търсения с множество итерации и едва след това предоставя отговор. Процесът включва непрекъснато проучване, разсъждение и многопосочно решаване на проблеми, което е фундаментално различно от стандартните LLM модели, които отговарят въз основа на предварително обучение или традиционни RAG системи, разчитащи на еднократно повърхностно търсене.",
|
||||
"kimi-k2-0711-preview.description": "kimi-k2 е базов MoE модел с мощни възможности за програмиране и агентни задачи (1T общо параметри, 32B активни), надминаващ други основни отворени модели в логика, програмиране, математика и агентни тестове.",
|
||||
"kimi-k2-0905-preview.description": "kimi-k2-0905-preview предлага прозорец на контекста от 256k, по-силно агентно програмиране, по-добро качество на front-end кода и подобрено разбиране на контекста.",
|
||||
"kimi-k2-instruct.description": "Kimi K2 Instruct е официалният логически модел на Kimi с дълъг контекст за код, въпроси и отговори и други.",
|
||||
"kimi-k2-thinking-turbo.description": "Високоскоростен вариант на K2 с дълбоко мислене, 256k контекст, силни логически способности и изход от 60–100 токена/секунда.",
|
||||
"kimi-k2-thinking.description": "kimi-k2-thinking е логически модел на Moonshot AI с общи агентни и логически способности. Отличава се с дълбоко разсъждение и може да решава трудни задачи чрез многостъпково използване на инструменти.",
|
||||
"kimi-k2-turbo-preview.description": "kimi-k2 е базов MoE модел с мощни възможности за програмиране и агентни задачи (1T общо параметри, 32B активни), надминаващ други основни отворени модели в логика, програмиране, математика и агентни тестове.",
|
||||
"kimi-k2.description": "Kimi-K2 е базов MoE модел от Moonshot AI с мощни възможности за програмиране и агентни задачи, с общо 1T параметри и 32B активни. В тестове за обща логика, програмиране, математика и агентни задачи надминава други основни отворени модели.",
|
||||
"kimi-k2:1t.description": "Kimi K2 е голям MoE LLM от Moonshot AI с 1T общо параметри и 32B активни на всяко преминаване. Оптимизиран е за агентни способности, включително напреднало използване на инструменти, логика и синтез на код.",
|
||||
"kimi-latest.description": "Kimi Latest използва най-новия модел на Kimi и може да включва експериментални функции. Поддържа разбиране на изображения и автоматично избира модели за таксуване 8k/32k/128k според дължината на контекста.",
|
||||
"kuaishou/kat-coder-pro-v1.description": "KAT-Coder-Pro-V1 (ограничено безплатен) е фокусиран върху разбиране на код и автоматизация за ефективни кодиращи агенти.",
|
||||
"learnlm-1.5-pro-experimental.description": "LearnLM е експериментален, специфичен за задачи модел, обучен на принципи от науката за учене, за да следва системни инструкции в образователни сценарии, действайки като експертен преподавател.",
|
||||
"learnlm-2.0-flash-experimental.description": "LearnLM е експериментален, специфичен за задачи модел, обучен на принципи от науката за учене, за да следва системни инструкции в образователни сценарии, действайки като експертен преподавател.",
|
||||
"lite.description": "Spark Lite е лек LLM с ултраниска латентност и ефективна обработка. Напълно безплатен, поддържа търсене в реално време в уеб. Бързите му отговори се представят добре на устройства с ниска изчислителна мощност и при фина настройка на модели, осигурявайки висока ефективност и интелигентно изживяване, особено за въпроси и отговори, генериране на съдържание и търсене.",
|
||||
"llama-3.1-70b-versatile.description": "Llama 3.1 70B предлага по-силни логически способности за сложни приложения, поддържайки тежки изчисления с висока ефективност и точност.",
|
||||
"llama-3.1-8b-instant.description": "Llama 3.1 8B е високоефективен модел с бързо генериране на текст, идеален за мащабни и икономични приложения.",
|
||||
"llama-3.1-instruct.description": "Llama 3.1 instruction-tuned модел е оптимизиран за чат и надминава много отворени чат модели в индустриалните бенчмаркове.",
|
||||
"llama-3.2-11b-vision-instruct.description": "Силно визуално разсъждение върху изображения с висока резолюция, подходящо за приложения за визуално разбиране.",
|
||||
"llama-3.2-11b-vision-preview.description": "Llama 3.2 е създаден за задачи, съчетаващи визия и текст, като се отличава в генериране на описания на изображения и визуални въпроси и отговори, свързвайки езиковото генериране с визуалното разсъждение.",
|
||||
"llama-3.2-90b-vision-instruct.description": "Разширено визуално разсъждение за приложения с агенти за визуално разбиране.",
|
||||
"llama-3.2-90b-vision-preview.description": "Llama 3.2 е създаден за задачи, съчетаващи визия и текст, като се отличава в генериране на описания на изображения и визуални въпроси и отговори, свързвайки езиковото генериране с визуалното разсъждение.",
|
||||
"llama-3.2-vision-instruct.description": "Инструкционно настроеният модел Llama 3.2-Vision е оптимизиран за визуално разпознаване, разсъждение върху изображения, генериране на описания и общи въпроси и отговори върху изображения.",
|
||||
"llama-3.3-70b-versatile.description": "Meta Llama 3.3 е многоезичен LLM с 70 милиарда параметъра (текст вход/текст изход), предлагащ предварително обучени и инструкционно настроени варианти. Инструкционно настроеният текстов модел е оптимизиран за многоезичен диалог и превъзхожда много от наличните отворени и затворени чат модели по общи индустриални показатели.",
|
||||
"llama-3.3-70b.description": "Llama 3.3 70B: среден към голям модел от серията Llama, балансиращ между разсъждение и производителност.",
|
||||
"llama-3.3-instruct.description": "Инструкционно настроеният модел Llama 3.3 е оптимизиран за чат и превъзхожда много отворени чат модели по общи индустриални показатели.",
|
||||
"llama3-70b-8192.description": "Meta Llama 3 70B предлага изключителна способност за справяне със сложност за взискателни проекти.",
|
||||
"llama3-8b-8192.description": "Meta Llama 3 8B осигурява силна производителност при разсъждение в разнообразни сценарии.",
|
||||
"llama3-groq-70b-8192-tool-use-preview.description": "Llama 3 Groq 70B Tool Use предлага силно извикване на инструменти за ефективно справяне със сложни задачи.",
|
||||
"llama3-groq-8b-8192-tool-use-preview.description": "Llama 3 Groq 8B Tool Use е оптимизиран за ефективно използване на инструменти с бърза паралелна обработка.",
|
||||
"llama3.1-8b.description": "Llama 3.1 8B: малък, с ниска латентност вариант на Llama за лека онлайн инференция и чат.",
|
||||
"llama3.1.description": "Llama 3.1 е водещият модел на Meta, мащабиран до 405 милиарда параметъра за сложен диалог, многоезичен превод и анализ на данни.",
|
||||
"llama3.1:405b.description": "Llama 3.1 е водещият модел на Meta, мащабиран до 405 милиарда параметъра за сложен диалог, многоезичен превод и анализ на данни.",
|
||||
"llama3.1:70b.description": "Llama 3.1 е водещият модел на Meta, мащабиран до 405 милиарда параметъра за сложен диалог, многоезичен превод и анализ на данни.",
|
||||
"llava-v1.5-7b-4096-preview.description": "LLaVA 1.5 7B съчетава визуална обработка за генериране на сложни изходи от визуални входове.",
|
||||
"llava.description": "LLaVA е мултимодален модел, комбиниращ визуален енкодер и Vicuna за силно разбиране на визия и език.",
|
||||
"llava:13b.description": "LLaVA е мултимодален модел, комбиниращ визуален енкодер и Vicuna за силно разбиране на визия и език.",
|
||||
"llava:34b.description": "LLaVA е мултимодален модел, комбиниращ визуален енкодер и Vicuna за силно разбиране на визия и език.",
|
||||
"magistral-medium-latest.description": "Magistral Medium 1.2 е авангарден модел за разсъждение от Mistral AI (септември 2025) с поддръжка на визия.",
|
||||
"magistral-small-2509.description": "Magistral Small 1.2 е малък, с отворен код модел за разсъждение от Mistral AI (септември 2025) с поддръжка на визия.",
|
||||
"mathstral.description": "MathΣtral е създаден за научни изследвания и математическо разсъждение, с мощни изчислителни и обяснителни възможности.",
|
||||
"max-32k.description": "Spark Max 32K предлага обработка на голям контекст с по-добро разбиране и логическо разсъждение, поддържайки входове до 32K токена за четене на дълги документи и въпроси и отговори върху частни знания.",
|
||||
"megrez-3b-instruct.description": "Megrez 3B Instruct е малък, ефективен модел от Wuwen Xinqiong.",
|
||||
"meituan/longcat-flash-chat.description": "Модел с отворен код от Meituan, оптимизиран за диалог и агентски задачи, силен в използване на инструменти и сложни многозавойни взаимодействия.",
|
||||
"meta-llama-3-70b-instruct.description": "Мощен модел с 70 милиарда параметъра, който се отличава в разсъждение, програмиране и широк спектър езикови задачи.",
|
||||
"meta-llama-3-8b-instruct.description": "Универсален модел с 8 милиарда параметъра, оптимизиран за чат и генериране на текст.",
|
||||
"meta-llama-3.1-405b-instruct.description": "Инструкционно настроен текстов модел Llama 3.1, оптимизиран за многоезичен чат, с висока производителност по общи индустриални показатели сред отворени и затворени чат модели.",
|
||||
"meta-llama-3.1-70b-instruct.description": "Инструкционно настроен текстов модел Llama 3.1, оптимизиран за многоезичен чат, с висока производителност по общи индустриални показатели сред отворени и затворени чат модели.",
|
||||
"meta-llama-3.1-8b-instruct.description": "Инструкционно настроен текстов модел Llama 3.1, оптимизиран за многоезичен чат, с висока производителност по общи индустриални показатели сред отворени и затворени чат модели.",
|
||||
"meta-llama/Llama-2-13b-chat-hf.description": "LLaMA-2 Chat (13B) предлага силна езикова обработка и стабилно чат изживяване.",
|
||||
"meta-llama/Llama-2-70b-hf.description": "LLaMA-2 предлага силна езикова обработка и стабилно взаимодействие.",
|
||||
"meta-llama/Llama-3-70b-chat-hf.description": "Llama 3 70B Instruct Reference е мощен чат модел за сложни диалози.",
|
||||
"meta-llama/Llama-3-8b-chat-hf.description": "Llama 3 8B Instruct Reference предлага многоезична поддръжка и обширни познания по различни теми.",
|
||||
"meta-llama/Llama-3.2-11B-Vision-Instruct-Turbo.description": "LLaMA 3.2 е създаден за задачи, съчетаващи визия и текст. Отличава се в генериране на описания на изображения и визуални въпроси и отговори, свързвайки езиковото генериране с визуалното разсъждение.",
|
||||
"meta-llama/Llama-3.2-3B-Instruct-Turbo.description": "LLaMA 3.2 е създаден за задачи, съчетаващи визия и текст. Отличава се в генериране на описания на изображения и визуални въпроси и отговори, свързвайки езиковото генериране с визуалното разсъждение.",
|
||||
"meta-llama/Llama-3.2-90B-Vision-Instruct-Turbo.description": "LLaMA 3.2 е създаден за задачи, съчетаващи визия и текст. Отличава се в генериране на описания на изображения и визуални въпроси и отговори, свързвайки езиковото генериране с визуалното разсъждение.",
|
||||
"meta-llama/Llama-3.3-70B-Instruct-Turbo.description": "Meta Llama 3.3 е многоезичен LLM с 70 милиарда параметъра (текст вход/текст изход), предварително обучен и инструкционно настроен. Текстовата версия е оптимизирана за многоезичен чат и превъзхожда много отворени и затворени чат модели по общи индустриални показатели.",
|
||||
"meta-llama/Llama-Vision-Free.description": "LLaMA 3.2 е създаден за задачи, съчетаващи визия и текст. Отличава се в генериране на описания на изображения и визуални въпроси и отговори, свързвайки езиковото генериране с визуалното разсъждение.",
|
||||
"meta-llama/Meta-Llama-3-70B-Instruct-Lite.description": "Llama 3 70B Instruct Lite е създаден за висока производителност с по-ниска латентност.",
|
||||
"meta-llama/Meta-Llama-3-70B-Instruct-Turbo.description": "Llama 3 70B Instruct Turbo осигурява силно разбиране и генериране за най-взискателните натоварвания.",
|
||||
"meta-llama/Meta-Llama-3-8B-Instruct-Lite.description": "Llama 3 8B Instruct Lite балансира производителността за среди с ограничени ресурси.",
|
||||
"meta-llama/Meta-Llama-3-8B-Instruct-Turbo.description": "Llama 3 8B Instruct Turbo е високопроизводителен LLM за широк спектър от приложения.",
|
||||
"meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo.description": "Моделът Llama 3.1 Turbo с 405 милиарда параметъра предлага огромен контекстов капацитет за обработка на големи данни и се отличава в мащабни AI приложения.",
|
||||
"meta-llama/Meta-Llama-3.1-405B-Instruct.description": "Llama 3.1 е водещото семейство модели на Meta, мащабирано до 405 милиарда параметъра за сложен диалог, многоезичен превод и анализ на данни.",
|
||||
"meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo.description": "Llama 3.1 70B е фино настроен за приложения с високо натоварване; FP8 квантизацията осигурява ефективна обработка и точност при сложни сценарии.",
|
||||
"meta-llama/Meta-Llama-3.1-70B.description": "Llama 3.1 е водещото семейство модели на Meta, мащабирано до 405 милиарда параметъра за сложен диалог, многоезичен превод и анализ на данни.",
|
||||
"meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo.description": "Llama 3.1 8B използва FP8 квантизация, поддържа до 131 072 токена контекст и е сред водещите отворени модели за сложни задачи по множество показатели.",
|
||||
"meta-llama/llama-3-70b-instruct.description": "Llama 3 70B Instruct е оптимизиран за висококачествен диалог и показва силна производителност в човешки оценки.",
|
||||
"meta-llama/llama-3-8b-instruct.description": "Llama 3 8B Instruct е оптимизиран за висококачествен диалог, превъзхождайки много затворени модели.",
|
||||
"meta-llama/llama-3.1-70b-instruct.description": "Най-новата серия Llama 3.1 на Meta, 70B инструкционно настроен вариант, оптимизиран за висококачествен диалог. В индустриални оценки показва силна производителност спрямо водещи затворени модели. (Достъпен само за проверени корпоративни клиенти.)",
|
||||
"meta-llama/llama-3.1-8b-instruct.description": "Най-новата серия Llama 3.1 на Meta, 8B инструкционно настроен вариант, особено бърз и ефективен. В индустриални оценки показва силна производителност, надминавайки много водещи затворени модели. (Достъпен само за проверени корпоративни клиенти.)",
|
||||
"meta-llama/llama-3.1-8b-instruct:free.description": "LLaMA 3.1 предлага многоезична поддръжка и е сред водещите генеративни модели.",
|
||||
"meta.llama3-8b-instruct-v1:0.description": "Meta Llama 3 е отворен LLM, предназначен за разработчици, изследователи и предприятия, създаден да им помага да изграждат, експериментират и отговорно мащабират идеи за генеративен ИИ. Като част от основата за глобални иновации в общността, той е подходящ за среди с ограничени изчислителни ресурси, крайни устройства и по-бързо обучение.",
|
||||
"meta/Llama-3.2-11B-Vision-Instruct.description": "Силен визуален анализ на изображения с висока резолюция, подходящ за приложения за визуално разбиране.",
|
||||
"meta/Llama-3.2-90B-Vision-Instruct.description": "Разширен визуален анализ за приложения с агенти за визуално разбиране.",
|
||||
@@ -957,7 +1047,6 @@
|
||||
"z-ai/glm-4.5-air.description": "GLM 4.5 Air е олекотен вариант на GLM 4.5 за сценарии, чувствителни към разходи, като същевременно запазва силни способности за разсъждение.",
|
||||
"z-ai/glm-4.5.description": "GLM 4.5 е флагманският модел на Z.AI с хибридно разсъждение, оптимизиран за инженерни и задачи с дълъг контекст.",
|
||||
"z-ai/glm-4.6.description": "GLM 4.6 е флагманският модел на Z.AI с разширен контекст и подобрени възможности за програмиране.",
|
||||
"zai-glm-4.6.description": "Показва отлични резултати при задачи с програмиране и разсъждение, поддържа стрийминг и извикване на инструменти, подходящ за агентно програмиране и сложни логически задачи.",
|
||||
"zai-org/GLM-4.5-Air.description": "GLM-4.5-Air е базов модел за агентни приложения с архитектура Mixture-of-Experts. Оптимизиран е за използване на инструменти, уеб браузване, софтуерно инженерство и фронтенд програмиране, и се интегрира с кодови агенти като Claude Code и Roo Code. Използва хибридно разсъждение за справяне както със сложни, така и с ежедневни задачи.",
|
||||
"zai-org/GLM-4.5.description": "GLM-4.5 е базов модел, създаден за агентни приложения с архитектура Mixture-of-Experts. Дълбоко оптимизиран за използване на инструменти, уеб браузване, софтуерно инженерство и фронтенд програмиране, и се интегрира с кодови агенти като Claude Code и Roo Code. Използва хибридно разсъждение за справяне както със сложни, така и с ежедневни задачи.",
|
||||
"zai-org/GLM-4.5V.description": "GLM-4.5V е най-новият визуален езиков модел (VLM) на Zhipu AI, изграден върху флагманския текстов модел GLM-4.5-Air (106B общо, 12B активни) с MoE архитектура за висока производителност при по-ниска цена. Следва пътя на GLM-4.1V-Thinking и добавя 3D-RoPE за подобрено пространствено разсъждение в 3D. Оптимизиран чрез предварително обучение, SFT и RL, обработва изображения, видео и дълги документи и е сред водещите отворени модели в 41 публични мултимодални бенчмарка. Режимът Thinking позволява на потребителите да балансират между скорост и дълбочина.",
|
||||
|
||||
@@ -347,11 +347,6 @@
|
||||
"inspector.delete": "Изтриване на извикване",
|
||||
"inspector.orphanedToolCall": "Открито е изолирано извикване на умение, което може да повлияе на изпълнението на агента. Премахнете го.",
|
||||
"inspector.pluginRender": "Преглед на интерфейса на умението",
|
||||
"integrationDetail.author": "Автор",
|
||||
"integrationDetail.details": "Детайли",
|
||||
"integrationDetail.developedBy": "Разработено от",
|
||||
"integrationDetail.tools": "Инструменти",
|
||||
"integrationDetail.trustWarning": "Използвайте само конектори от разработчици, на които имате доверие. LobeHub не контролира кои инструменти се предоставят от разработчиците и не може да гарантира, че ще работят според очакванията или че няма да бъдат променени.",
|
||||
"list.item.deprecated.title": "Изтрито",
|
||||
"list.item.local.config": "Конфигурация",
|
||||
"list.item.local.title": "Потребителско",
|
||||
@@ -491,6 +486,16 @@
|
||||
"settings.saveSettings": "Запази",
|
||||
"settings.title": "Настройки на общността за умения",
|
||||
"showInPortal": "Преглед на подробности в Работното пространство",
|
||||
"skillDetail.author": "Автор",
|
||||
"skillDetail.details": "Подробности",
|
||||
"skillDetail.developedBy": "Разработено от",
|
||||
"skillDetail.networkError": "Неуспешно зареждане на данни. Проверете мрежовата връзка и опитайте отново.",
|
||||
"skillDetail.noAgents": "Все още няма агенти, които използват този умение",
|
||||
"skillDetail.tabs.agents": "Агенти, използващи това умение",
|
||||
"skillDetail.tabs.overview": "Преглед",
|
||||
"skillDetail.tabs.tools": "Възможности",
|
||||
"skillDetail.tools": "Инструменти",
|
||||
"skillDetail.trustWarning": "Използвайте само конектори от разработчици, на които имате доверие. LobeHub не контролира кои инструменти са предоставени от разработчиците и не може да гарантира, че ще работят според очакванията или че няма да бъдат променени.",
|
||||
"skillInstallBanner.title": "Добавете умения към Lobe AI",
|
||||
"store.actions.cancel": "Отказ",
|
||||
"store.actions.configure": "Конфигурирай",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"fireworksai.description": "Fireworks AI предлага усъвършенствани езикови модели с поддръжка на извикване на функции и мултимодална обработка. Firefunction V2 (базиран на Llama-3) е оптимизиран за функции, чат и следване на инструкции, докато FireLLaVA-13B поддържа смесени входове от изображения и текст. Други модели включват семействата Llama и Mixtral.",
|
||||
"giteeai.description": "Gitee AI Serverless API предоставят готови за използване услуги за LLM инференция за разработчици.",
|
||||
"github.description": "С GitHub Models разработчиците могат да работят като AI инженери, използвайки водещи в индустрията модели.",
|
||||
"githubcopilot.description": "Достъпвайте моделите Claude, GPT и Gemini чрез вашия абонамент за GitHub Copilot.",
|
||||
"google.description": "Семейството Gemini на Google е най-усъвършенстваният му универсален AI, създаден от Google DeepMind за мултимодална употреба с текст, код, изображения, аудио и видео. Работи както в центрове за данни, така и на мобилни устройства с висока ефективност и обхват.",
|
||||
"groq.description": "Инференционният енджин LPU на Groq осигурява изключителна производителност с висока скорост и ефективност, поставяйки нов стандарт за нисколатентна облачна LLM инференция.",
|
||||
"higress.description": "Higress е облачно-нативен API gateway, създаден в Alibaba за справяне с проблемите при презареждане на Tengine и липсите в балансирането на натоварването при gRPC/Dubbo.",
|
||||
@@ -29,7 +30,6 @@
|
||||
"internlm.description": "Open-source организация, фокусирана върху изследвания и инструменти за големи модели, предоставяща ефективна и лесна за използване платформа за достъп до водещи модели и алгоритми.",
|
||||
"jina.description": "Основана през 2020 г., Jina AI е водеща компания в областта на търсещия AI. Технологичният ѝ стек включва векторни модели, преоценители и малки езикови модели за създаване на надеждни генеративни и мултимодални търсещи приложения.",
|
||||
"lmstudio.description": "LM Studio е десктоп приложение за разработка и експериментиране с LLM на вашия компютър.",
|
||||
"lobehub.description": "LobeHub Cloud използва официални API интерфейси за достъп до AI модели и измерва използването чрез Кредити, обвързани с токени на модела.",
|
||||
"minimax.description": "Основана през 2021 г., MiniMax създава универсален AI с мултимодални базови модели, включително текстови модели с трилиони параметри, речеви и визуални модели, както и приложения като Hailuo AI.",
|
||||
"mistral.description": "Mistral предлага усъвършенствани универсални, специализирани и изследователски модели за сложни разсъждения, многоезични задачи и генериране на код, с извикване на функции за персонализирани интеграции.",
|
||||
"modelscope.description": "ModelScope е платформа на Alibaba Cloud за модели като услуга, предлагаща широка гама от AI модели и услуги за инференция.",
|
||||
|
||||
@@ -34,11 +34,20 @@
|
||||
"agentCronJobs.empty.description": "Създайте първата си планирана задача, за да автоматизирате вашия агент",
|
||||
"agentCronJobs.empty.title": "Все още няма планирани задачи",
|
||||
"agentCronJobs.enable": "Активирай",
|
||||
"agentCronJobs.form.at": "в",
|
||||
"agentCronJobs.form.content.placeholder": "Въведете подкана или инструкция за агента",
|
||||
"agentCronJobs.form.every": "Всеки",
|
||||
"agentCronJobs.form.frequency": "Честота",
|
||||
"agentCronJobs.form.hours": "час(а)",
|
||||
"agentCronJobs.form.maxExecutions": "Спри след",
|
||||
"agentCronJobs.form.maxExecutions.placeholder": "Оставете празно за неограничено",
|
||||
"agentCronJobs.form.name.placeholder": "Въведете име на задачата",
|
||||
"agentCronJobs.form.time": "Час",
|
||||
"agentCronJobs.form.timeRange.end": "Крайно време",
|
||||
"agentCronJobs.form.timeRange.start": "Начално време",
|
||||
"agentCronJobs.form.times": "пъти",
|
||||
"agentCronJobs.form.timezone": "Часова зона",
|
||||
"agentCronJobs.form.unlimited": "Изпълнявай непрекъснато",
|
||||
"agentCronJobs.form.validation.contentRequired": "Съдържанието на задачата е задължително",
|
||||
"agentCronJobs.form.validation.invalidTimeRange": "Началното време трябва да е преди крайното",
|
||||
"agentCronJobs.form.validation.nameRequired": "Името на задачата е задължително",
|
||||
@@ -83,6 +92,13 @@
|
||||
"agentCronJobs.weekday.tuesday": "Вторник",
|
||||
"agentCronJobs.weekday.wednesday": "Сряда",
|
||||
"agentCronJobs.weekdays": "Дни от седмицата",
|
||||
"agentCronJobs.weekdays.fri": "Пет",
|
||||
"agentCronJobs.weekdays.mon": "Пон",
|
||||
"agentCronJobs.weekdays.sat": "Съб",
|
||||
"agentCronJobs.weekdays.sun": "Нед",
|
||||
"agentCronJobs.weekdays.thu": "Чет",
|
||||
"agentCronJobs.weekdays.tue": "Вто",
|
||||
"agentCronJobs.weekdays.wed": "Сря",
|
||||
"agentInfoDescription.basic.avatar": "Аватар",
|
||||
"agentInfoDescription.basic.description": "Описание",
|
||||
"agentInfoDescription.basic.name": "Име",
|
||||
@@ -519,6 +535,10 @@
|
||||
"skillStore.tabs.custom": "Персонализирано",
|
||||
"skillStore.tabs.lobehub": "LobeHub",
|
||||
"skillStore.title": "Магазин за умения",
|
||||
"skillStore.wantMore.action": "Изпрати заявка →",
|
||||
"skillStore.wantMore.feedback.message": "## Име на умението\n[Моля, попълнете]\n\n## Сценарий на използване\nКогато съм ___, имам нужда от ___\n\n## Очаквани функции\n1.\n2.\n3.\n\n## Примерни референции\n(По избор) Има ли подобни инструменти или функции за справка?\n\n---\n💡 Съвет: Колкото по-конкретно е описанието ви, толкова по-добре можем да отговорим на нуждите ви",
|
||||
"skillStore.wantMore.feedback.title": "[Заявка за умение] Обобщете умението, от което се нуждаете, в едно изречение",
|
||||
"skillStore.wantMore.reachedEnd": "Стигнахте до края. Не намирате това, което търсите?",
|
||||
"startConversation": "Започни разговор",
|
||||
"storage.actions.export.button": "Експортиране",
|
||||
"storage.actions.export.exportType.agent": "Експортиране на настройки на агент",
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"betterAuth.signin.signupLink": "Jetzt registrieren",
|
||||
"betterAuth.signin.socialError": "Soziale Anmeldung fehlgeschlagen, bitte versuchen Sie es erneut",
|
||||
"betterAuth.signin.socialOnlyHint": "Diese E-Mail wurde über ein Drittanbieter-Konto registriert. Melden Sie sich mit diesem Anbieter an oder",
|
||||
"betterAuth.signin.ssoOnlyNoProviders": "Die Registrierung per E-Mail ist deaktiviert und es sind keine SSO-Anbieter konfiguriert. Bitte wenden Sie sich an Ihre Administratorin oder Ihren Administrator.",
|
||||
"betterAuth.signin.submit": "Anmelden",
|
||||
"betterAuth.signup.confirmPasswordPlaceholder": "Passwort bestätigen",
|
||||
"betterAuth.signup.emailPlaceholder": "E-Mail-Adresse eingeben",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"codes.DELETED_ACCOUNT_EMAIL": "Diese E-Mail-Adresse ist mit einem gelöschten Konto verknüpft und kann nicht für die Registrierung verwendet werden",
|
||||
"codes.EMAIL_CAN_NOT_BE_UPDATED": "E-Mail-Adresse kann für dieses Konto nicht aktualisiert werden",
|
||||
"codes.EMAIL_NOT_ALLOWED": "Diese E-Mail-Adresse ist für die Registrierung nicht zugelassen",
|
||||
"codes.EMAIL_NOT_FOUND": "Keine E-Mail mit diesem Konto verknüpft. Bitte überprüfen Sie, ob Ihrem Konto eine E-Mail-Adresse zugeordnet ist.",
|
||||
"codes.EMAIL_NOT_VERIFIED": "Bitte verifiziere zuerst deine E-Mail-Adresse",
|
||||
"codes.FAILED_TO_CREATE_SESSION": "Sitzung konnte nicht erstellt werden",
|
||||
"codes.FAILED_TO_CREATE_USER": "Benutzer konnte nicht erstellt werden",
|
||||
|
||||
@@ -188,6 +188,7 @@
|
||||
"cmdk.submitIssue": "Problem melden",
|
||||
"cmdk.theme": "Design",
|
||||
"cmdk.themeAuto": "Automatisch",
|
||||
"cmdk.themeCurrent": "Aktuell",
|
||||
"cmdk.themeDark": "Dunkel",
|
||||
"cmdk.themeLight": "Hell",
|
||||
"cmdk.toOpen": "Öffnen",
|
||||
|
||||
@@ -194,6 +194,9 @@
|
||||
"mcp.categories.weather.name": "Wetter",
|
||||
"mcp.categories.web-search.description": "Websuche und Informationsabruf",
|
||||
"mcp.categories.web-search.name": "Informationsabruf",
|
||||
"mcp.details.agents.empty": "Noch keine Agenten verwenden diese Fähigkeit",
|
||||
"mcp.details.agents.networkError": "Daten konnten nicht geladen werden. Bitte Netzwerkverbindung prüfen und erneut versuchen.",
|
||||
"mcp.details.agents.title": "Agenten, die diese Fähigkeit nutzen",
|
||||
"mcp.details.connectionType.hybrid.desc": "Dieser Dienst kann je nach Konfiguration oder Nutzungsszenario lokal oder in der Cloud ausgeführt werden und bietet eine duale Betriebsfähigkeit.",
|
||||
"mcp.details.connectionType.hybrid.title": "Hybrider Dienst",
|
||||
"mcp.details.connectionType.local.desc": "Dieser Server kann nur auf dem lokalen Gerät des Clients ausgeführt werden, erfordert eine Installation und nutzt lokale Ressourcen.",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user