mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-18 13:25:45 +00:00
Compare commits
58 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 | |||
| f3210a3f57 | |||
| 8b8159eb01 | |||
| 5086a126a7 | |||
| a82a4bda34 | |||
| 71b2ecd94b | |||
| ffd9fff091 | |||
| 67c4bafd3f | |||
| 7496511917 | |||
| 15e89f2eee | |||
| 1421e991d8 | |||
| f17acd7f7e | |||
| e46df98907 | |||
| 2c791d749d | |||
| 4e982cf89f | |||
| 104a19a8a4 | |||
| c5a1791e32 | |||
| 9a1a81680f | |||
| 4bd82c397a | |||
| 891837b792 | |||
| a4c1d4b687 | |||
| 0bda4d9845 | |||
| 7abc5142e0 | |||
| 1b9caa92a5 | |||
| b8ef02e647 |
@@ -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
|
||||
+4
-3
@@ -1,6 +1,3 @@
|
||||
# add a access code to lock your lobe-chat application, you can set a long password to avoid leaking. If this value contains a comma, it is a password array.
|
||||
# ACCESS_CODE=lobe66
|
||||
|
||||
# Specify your API Key selection method, currently supporting `random` and `turn`.
|
||||
# API_KEY_SELECT_MODE=random
|
||||
|
||||
@@ -295,6 +292,10 @@ OPENAI_API_KEY=sk-xxxxxxxxx
|
||||
# Leave empty to allow all emails
|
||||
# AUTH_ALLOWED_EMAILS=example.com,admin@other.com
|
||||
|
||||
# Disable email/password authentication (SSO-only mode)
|
||||
# Set to '1' to disable email/password sign-in and registration, only allowing SSO login
|
||||
# AUTH_DISABLE_EMAIL_PASSWORD=0
|
||||
|
||||
# Google OAuth Configuration (for Better-Auth)
|
||||
# Get credentials from: https://console.cloud.google.com/apis/credentials
|
||||
# Authorized redirect URIs:
|
||||
|
||||
@@ -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,10 +145,10 @@ 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-14", "name": "macos-arm64"}'
|
||||
arm_entry='{"os": "macos-15", "name": "macos-arm64"}'
|
||||
static_matrix=$(echo "$static_matrix" | jq -c --argjson entry "$arm_entry" '. + [$entry]')
|
||||
fi
|
||||
|
||||
@@ -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
|
||||
+150
@@ -2,6 +2,156 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
### [Version 2.1.2](https://github.com/lobehub/lobe-chat/compare/v2.1.1...v2.1.2)
|
||||
|
||||
<sup>Released on **2026-01-30**</sup>
|
||||
|
||||
#### 🐛 Bug Fixes
|
||||
|
||||
- **misc**: Fix feishu sso provider.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's fixed
|
||||
|
||||
- **misc**: Fix feishu sso provider, closes [#11970](https://github.com/lobehub/lobe-chat/issues/11970) ([ffd9fff](https://github.com/lobehub/lobe-chat/commit/ffd9fff))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 2.1.1](https://github.com/lobehub/lobe-chat/compare/v2.1.0...v2.1.1)
|
||||
|
||||
<sup>Released on **2026-01-30**</sup>
|
||||
|
||||
#### 🐛 Bug Fixes
|
||||
|
||||
- **misc**: Correct desktop download URL path.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's fixed
|
||||
|
||||
- **misc**: Correct desktop download URL path, closes [#11990](https://github.com/lobehub/lobe-chat/issues/11990) ([e46df98](https://github.com/lobehub/lobe-chat/commit/e46df98))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## [Version 2.1.0](https://github.com/lobehub/lobe-chat/compare/v2.0.13...v2.1.0)
|
||||
|
||||
<sup>Released on **2026-01-30**</sup>
|
||||
|
||||
#### ✨ Features
|
||||
|
||||
- **misc**: Refactor cron job UI and use runtime enableBusinessFeatures flag.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's improved
|
||||
|
||||
- **misc**: Refactor cron job UI and use runtime enableBusinessFeatures flag, closes [#11975](https://github.com/lobehub/lobe-chat/issues/11975) ([104a19a](https://github.com/lobehub/lobe-chat/commit/104a19a))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 2.0.13](https://github.com/lobehub/lobe-chat/compare/v2.0.12...v2.0.13)
|
||||
|
||||
<sup>Released on **2026-01-29**</sup>
|
||||
|
||||
#### 💄 Styles
|
||||
|
||||
- **misc**: Fix usage table display issues.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### Styles
|
||||
|
||||
- **misc**: Fix usage table display issues, closes [#10108](https://github.com/lobehub/lobe-chat/issues/10108) ([4bd82c3](https://github.com/lobehub/lobe-chat/commit/4bd82c3))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 2.0.12](https://github.com/lobehub/lobe-chat/compare/v2.0.11...v2.0.12)
|
||||
|
||||
<sup>Released on **2026-01-29**</sup>
|
||||
|
||||
#### 🐛 Bug Fixes
|
||||
|
||||
- **misc**: Group publish to market should set local group market identifer.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's fixed
|
||||
|
||||
- **misc**: Group publish to market should set local group market identifer, closes [#11965](https://github.com/lobehub/lobe-chat/issues/11965) ([0bda4d9](https://github.com/lobehub/lobe-chat/commit/0bda4d9))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 2.0.11](https://github.com/lobehub/lobe-chat/compare/v2.0.10...v2.0.11)
|
||||
|
||||
<sup>Released on **2026-01-29**</sup>
|
||||
|
||||
#### 💄 Styles
|
||||
|
||||
- **misc**: Fix group task render.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### Styles
|
||||
|
||||
- **misc**: Fix group task render, closes [#11952](https://github.com/lobehub/lobe-chat/issues/11952) ([b8ef02e](https://github.com/lobehub/lobe-chat/commit/b8ef02e))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
### [Version 2.0.10](https://github.com/lobehub/lobe-chat/compare/v2.0.9...v2.0.10)
|
||||
|
||||
<sup>Released on **2026-01-29**</sup>
|
||||
|
||||
+50
-56
@@ -8,24 +8,22 @@ ARG USE_CN_MIRROR
|
||||
|
||||
ENV DEBIAN_FRONTEND="noninteractive"
|
||||
|
||||
RUN <<'EOF'
|
||||
set -e
|
||||
if [ "${USE_CN_MIRROR:-false}" = "true" ]; then
|
||||
sed -i "s/deb.debian.org/mirrors.ustc.edu.cn/g" "/etc/apt/sources.list.d/debian.sources"
|
||||
fi
|
||||
apt update
|
||||
apt install ca-certificates proxychains-ng -qy
|
||||
mkdir -p /distroless/bin /distroless/etc /distroless/etc/ssl/certs /distroless/lib
|
||||
cp /usr/lib/$(arch)-linux-gnu/libproxychains.so.4 /distroless/lib/libproxychains.so.4
|
||||
cp /usr/lib/$(arch)-linux-gnu/libdl.so.2 /distroless/lib/libdl.so.2
|
||||
cp /usr/bin/proxychains4 /distroless/bin/proxychains
|
||||
cp /etc/proxychains4.conf /distroless/etc/proxychains4.conf
|
||||
cp /usr/lib/$(arch)-linux-gnu/libstdc++.so.6 /distroless/lib/libstdc++.so.6
|
||||
cp /usr/lib/$(arch)-linux-gnu/libgcc_s.so.1 /distroless/lib/libgcc_s.so.1
|
||||
cp /usr/local/bin/node /distroless/bin/node
|
||||
cp /etc/ssl/certs/ca-certificates.crt /distroless/etc/ssl/certs/ca-certificates.crt
|
||||
rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/*
|
||||
EOF
|
||||
RUN set -e && \
|
||||
if [ "${USE_CN_MIRROR:-false}" = "true" ]; then \
|
||||
sed -i "s/deb.debian.org/mirrors.ustc.edu.cn/g" "/etc/apt/sources.list.d/debian.sources"; \
|
||||
fi && \
|
||||
apt update && \
|
||||
apt install ca-certificates proxychains-ng -qy && \
|
||||
mkdir -p /distroless/bin /distroless/etc /distroless/etc/ssl/certs /distroless/lib && \
|
||||
cp /usr/lib/$(arch)-linux-gnu/libproxychains.so.4 /distroless/lib/libproxychains.so.4 && \
|
||||
cp /usr/lib/$(arch)-linux-gnu/libdl.so.2 /distroless/lib/libdl.so.2 && \
|
||||
cp /usr/bin/proxychains4 /distroless/bin/proxychains && \
|
||||
cp /etc/proxychains4.conf /distroless/etc/proxychains4.conf && \
|
||||
cp /usr/lib/$(arch)-linux-gnu/libstdc++.so.6 /distroless/lib/libstdc++.so.6 && \
|
||||
cp /usr/lib/$(arch)-linux-gnu/libgcc_s.so.1 /distroless/lib/libgcc_s.so.1 && \
|
||||
cp /usr/local/bin/node /distroless/bin/node && \
|
||||
cp /etc/ssl/certs/ca-certificates.crt /distroless/etc/ssl/certs/ca-certificates.crt && \
|
||||
rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/*
|
||||
|
||||
## Builder image, install all the dependencies and build the app
|
||||
FROM base AS builder
|
||||
@@ -77,23 +75,21 @@ COPY patches ./patches
|
||||
# bring in desktop workspace manifest so pnpm can resolve it
|
||||
COPY apps/desktop/src/main/package.json ./apps/desktop/src/main/package.json
|
||||
|
||||
RUN <<'EOF'
|
||||
set -e
|
||||
if [ "${USE_CN_MIRROR:-false}" = "true" ]; then
|
||||
export SENTRYCLI_CDNURL="https://npmmirror.com/mirrors/sentry-cli"
|
||||
npm config set registry "https://registry.npmmirror.com/"
|
||||
echo 'canvas_binary_host_mirror=https://npmmirror.com/mirrors/canvas' >> .npmrc
|
||||
fi
|
||||
export COREPACK_NPM_REGISTRY=$(npm config get registry | sed 's/\/$//')
|
||||
npm i -g corepack@latest
|
||||
corepack enable
|
||||
corepack use $(sed -n 's/.*"packageManager": "\(.*\)".*/\1/p' package.json)
|
||||
pnpm i
|
||||
mkdir -p /deps
|
||||
cd /deps
|
||||
pnpm init
|
||||
pnpm add pg drizzle-orm
|
||||
EOF
|
||||
RUN set -e && \
|
||||
if [ "${USE_CN_MIRROR:-false}" = "true" ]; then \
|
||||
export SENTRYCLI_CDNURL="https://npmmirror.com/mirrors/sentry-cli"; \
|
||||
npm config set registry "https://registry.npmmirror.com/"; \
|
||||
echo 'canvas_binary_host_mirror=https://npmmirror.com/mirrors/canvas' >> .npmrc; \
|
||||
fi && \
|
||||
export COREPACK_NPM_REGISTRY=$(npm config get registry | sed 's/\/$//') && \
|
||||
npm i -g corepack@latest && \
|
||||
corepack enable && \
|
||||
corepack use $(sed -n 's/.*"packageManager": "\(.*\)".*/\1/p' package.json) && \
|
||||
pnpm i && \
|
||||
mkdir -p /deps && \
|
||||
cd /deps && \
|
||||
pnpm init && \
|
||||
pnpm add pg drizzle-orm
|
||||
|
||||
COPY . .
|
||||
|
||||
@@ -101,17 +97,15 @@ COPY . .
|
||||
RUN npm run build:docker
|
||||
|
||||
# Prepare desktop export assets for Electron packaging (if generated)
|
||||
RUN <<'EOF'
|
||||
set -e
|
||||
if [ -d "/app/out" ]; then
|
||||
mkdir -p /app/apps/desktop/dist/next
|
||||
cp -a /app/out/. /app/apps/desktop/dist/next/
|
||||
echo "✅ Copied Next export output into /app/apps/desktop/dist/next"
|
||||
else
|
||||
echo "ℹ️ No Next export output found at /app/out, creating empty directory"
|
||||
mkdir -p /app/apps/desktop/dist/next
|
||||
fi
|
||||
EOF
|
||||
RUN set -e && \
|
||||
if [ -d "/app/out" ]; then \
|
||||
mkdir -p /app/apps/desktop/dist/next && \
|
||||
cp -a /app/out/. /app/apps/desktop/dist/next/ && \
|
||||
echo "Copied Next export output into /app/apps/desktop/dist/next"; \
|
||||
else \
|
||||
echo "No Next export output found at /app/out, creating empty directory" && \
|
||||
mkdir -p /app/apps/desktop/dist/next; \
|
||||
fi
|
||||
|
||||
## Application image, copy all the files for production
|
||||
FROM busybox:latest AS app
|
||||
@@ -138,12 +132,10 @@ COPY --from=builder /deps/node_modules/drizzle-orm /app/node_modules/drizzle-orm
|
||||
COPY --from=builder /app/scripts/serverLauncher/startServer.js /app/startServer.js
|
||||
COPY --from=builder /app/scripts/_shared /app/scripts/_shared
|
||||
|
||||
RUN <<'EOF'
|
||||
set -e
|
||||
addgroup -S -g 1001 nodejs
|
||||
adduser -D -G nodejs -H -S -h /app -u 1001 nextjs
|
||||
chown -R nextjs:nodejs /app /etc/proxychains4.conf
|
||||
EOF
|
||||
RUN set -e && \
|
||||
addgroup -S -g 1001 nodejs && \
|
||||
adduser -D -G nodejs -H -S -h /app -u 1001 nextjs && \
|
||||
chown -R nextjs:nodejs /app /etc/proxychains4.conf
|
||||
|
||||
## Production image, copy all the files and run next
|
||||
FROM scratch
|
||||
@@ -166,14 +158,12 @@ ENV HOSTNAME="0.0.0.0" \
|
||||
PORT="3210"
|
||||
|
||||
# General Variables
|
||||
ENV ACCESS_CODE="" \
|
||||
APP_URL="" \
|
||||
ENV APP_URL="" \
|
||||
API_KEY_SELECT_MODE="" \
|
||||
DEFAULT_AGENT_CONFIG="" \
|
||||
SYSTEM_AGENT="" \
|
||||
FEATURE_FLAGS="" \
|
||||
PROXY_URL="" \
|
||||
ENABLE_AUTH_PROTECTION=""
|
||||
PROXY_URL=""
|
||||
|
||||
# Database
|
||||
ENV KEY_VAULTS_SECRET="" \
|
||||
@@ -184,6 +174,10 @@ ENV KEY_VAULTS_SECRET="" \
|
||||
ENV AUTH_SECRET="" \
|
||||
AUTH_SSO_PROVIDERS="" \
|
||||
AUTH_ALLOWED_EMAILS="" \
|
||||
AUTH_TRUSTED_ORIGINS="" \
|
||||
AUTH_DISABLE_EMAIL_PASSWORD="" \
|
||||
AUTH_EMAIL_VERIFICATION="" \
|
||||
AUTH_ENABLE_MAGIC_LINK="" \
|
||||
# Google
|
||||
AUTH_GOOGLE_ID="" \
|
||||
AUTH_GOOGLE_SECRET="" \
|
||||
|
||||
@@ -37,7 +37,7 @@ We’re building the world’s largest human–agent co-evolving network.
|
||||
[![][share-mastodon-shield]][share-mastodon-link]
|
||||
[![][share-linkedin-shield]][share-linkedin-link]
|
||||
|
||||
<sup>Agent teams that grow with you</sup>
|
||||
<sup>Agent teammates that grow with you</sup>
|
||||
|
||||
[![][github-trending-shield]][github-trending-url]
|
||||
|
||||
@@ -581,7 +581,7 @@ LobeHub provides Self-Hosted Version with Vercel, Alibaba Cloud, and [Docker Ima
|
||||
"If you want to deploy this service yourself on Vercel, Zeabur or Alibaba Cloud, you can follow these steps:
|
||||
|
||||
- Prepare your [OpenAI API Key](https://platform.openai.com/account/api-keys).
|
||||
- Click the button below to start deployment: Log in directly with your GitHub account, and remember to fill in the `OPENAI_API_KEY`(required) and `ACCESS_CODE` (recommended) on the environment variable section.
|
||||
- Click the button below to start deployment: Log in directly with your GitHub account, and remember to fill in the `OPENAI_API_KEY`(required) on the environment variable section.
|
||||
- After deployment, you can start using it.
|
||||
- Bind a custom domain (optional): The DNS of the domain assigned by Vercel is polluted in some areas; binding a custom domain can connect directly.
|
||||
|
||||
@@ -647,7 +647,6 @@ This project provides some additional configuration items set with environment v
|
||||
| -------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| `OPENAI_API_KEY` | Yes | This is the API key you apply on the OpenAI account page | `sk-xxxxxx...xxxxxx` |
|
||||
| `OPENAI_PROXY_URL` | No | If you manually configure the OpenAI interface proxy, you can use this configuration item to override the default OpenAI API request base URL | `https://api.chatanywhere.cn` or `https://aihubmix.com/v1` <br/>The default value is<br/>`https://api.openai.com/v1` |
|
||||
| `ACCESS_CODE` | No | Add a password to access this service; you can set a long password to avoid leaking. If this value contains a comma, it is a password array. | `awCTe)re_r74` or `rtrt_ewee3@09!` or `code1,code2,code3` |
|
||||
| `OPENAI_MODEL_LIST` | No | Used to control the model list. Use `+` to add a model, `-` to hide a model, and `model_name=display_name` to customize the display name of a model, separated by commas. | `qwen-7b-chat,+glm-6b,-gpt-3.5-turbo` |
|
||||
|
||||
> \[!NOTE]
|
||||
@@ -829,7 +828,7 @@ This project is [LobeHub Community License](./LICENSE) licensed.
|
||||
[codespaces-link]: https://codespaces.new/lobehub/lobe-chat
|
||||
[codespaces-shield]: https://github.com/codespaces/badge.svg
|
||||
[deploy-button-image]: https://vercel.com/button
|
||||
[deploy-link]: https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Flobehub%2Flobe-chat&env=OPENAI_API_KEY,ACCESS_CODE&envDescription=Find%20your%20OpenAI%20API%20Key%20by%20click%20the%20right%20Learn%20More%20button.%20%7C%20Access%20Code%20can%20protect%20your%20website&envLink=https%3A%2F%2Fplatform.openai.com%2Faccount%2Fapi-keys&project-name=lobe-chat&repository-name=lobe-chat
|
||||
[deploy-link]: https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Flobehub%2Flobe-chat&env=OPENAI_API_KEY&envDescription=Find%20your%20OpenAI%20API%20Key%20by%20click%20the%20right%20Learn%20More%20button.&envLink=https%3A%2F%2Fplatform.openai.com%2Faccount%2Fapi-keys&project-name=lobe-chat&repository-name=lobe-chat
|
||||
[deploy-on-alibaba-cloud-button-image]: https://service-info-public.oss-cn-hangzhou.aliyuncs.com/computenest-en.svg
|
||||
[deploy-on-alibaba-cloud-link]: https://computenest.console.aliyun.com/service/instance/create/default?type=user&ServiceName=LobeHub%E7%A4%BE%E5%8C%BA%E7%89%88
|
||||
[deploy-on-repocloud-button-image]: https://d16t0pc4846x52.cloudfront.net/deploylobe.svg
|
||||
|
||||
+3
-4
@@ -35,7 +35,7 @@ LobeHub 是一个工作与生活空间,用于发现、构建并与会随着您
|
||||
[![][share-weibo-shield]][share-weibo-link]
|
||||
[![][share-mastodon-shield]][share-mastodon-link]
|
||||
|
||||
<sup>Agent teams that grow with you</sup>
|
||||
<sup>Agent teammates that grow with you</sup>
|
||||
|
||||
[![][github-trending-shield]][github-trending-url]
|
||||
[![][github-hello-shield]][github-hello-url]
|
||||
@@ -555,7 +555,7 @@ LobeHub 提供了 Vercel 的 自托管版本 和 [Docker 镜像][docker-release-
|
||||
如果想在 Vercel 、 Zeabur 或 阿里云 上部署该服务,可以按照以下步骤进行操作:
|
||||
|
||||
- 准备好你的 [OpenAI API Key](https://platform.openai.com/account/api-keys) 。
|
||||
- 点击下方按钮开始部署: 直接使用 GitHub 账号登录即可,记得在环境变量页填入 `OPENAI_API_KEY` (必填) and `ACCESS_CODE`(推荐);
|
||||
- 点击下方按钮开始部署: 直接使用 GitHub 账号登录即可,记得在环境变量页填入 `OPENAI_API_KEY` (必填);
|
||||
- 部署完毕后,即可开始使用;
|
||||
- 绑定自定义域名(可选):Vercel 分配的域名 DNS 在某些区域被污染了,绑定自定义域名即可直连。目前 Zeabur 提供的域名还未被污染,大多数地区都可以直连。
|
||||
|
||||
@@ -621,7 +621,6 @@ docker compose up -d
|
||||
| ------------------- | ---- | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| `OPENAI_API_KEY` | 必选 | 这是你在 OpenAI 账户页面申请的 API 密钥 | `sk-xxxxxx...xxxxxx` |
|
||||
| `OPENAI_PROXY_URL` | 可选 | 如果你手动配置了 OpenAI 接口代理,可以使用此配置项来覆盖默认的 OpenAI API 请求基础 URL | `https://api.chatanywhere.cn` 或 `https://aihubmix.com/v1`<br/>默认值:<br/>`https://api.openai.com/v1` |
|
||||
| `ACCESS_CODE` | 可选 | 添加访问此服务的密码,你可以设置一个长密码以防被爆破,该值用逗号分隔时为密码数组 | `awCTe)re_r74` or `rtrt_ewee3@09!` or `code1,code2,code3` |
|
||||
| `OPENAI_MODEL_LIST` | 可选 | 用来控制模型列表,使用 `+` 增加一个模型,使用 `-` 来隐藏一个模型,使用 `模型名=展示名` 来自定义模型的展示名,用英文逗号隔开。 | `qwen-7b-chat,+glm-6b,-gpt-3.5-turbo` |
|
||||
|
||||
> \[!NOTE]
|
||||
@@ -843,7 +842,7 @@ This project is [LobeHub Community License](./LICENSE) licensed.
|
||||
[codespaces-link]: https://codespaces.new/lobehub/lobe-chat
|
||||
[codespaces-shield]: https://github.com/codespaces/badge.svg
|
||||
[deploy-button-image]: https://vercel.com/button
|
||||
[deploy-link]: https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Flobehub%2Flobe-chat&env=OPENAI_API_KEY,ACCESS_CODE&envDescription=Find%20your%20OpenAI%20API%20Key%20by%20click%20the%20right%20Learn%20More%20button.%20%7C%20Access%20Code%20can%20protect%20your%20website&envLink=https%3A%2F%2Fplatform.openai.com%2Faccount%2Fapi-keys&project-name=lobe-chat&repository-name=lobe-chat
|
||||
[deploy-link]: https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Flobehub%2Flobe-chat&env=OPENAI_API_KEY&envDescription=Find%20your%20OpenAI%20API%20Key%20by%20click%20the%20right%20Learn%20More%20button.&envLink=https%3A%2F%2Fplatform.openai.com%2Faccount%2Fapi-keys&project-name=lobe-chat&repository-name=lobe-chat
|
||||
[deploy-on-alibaba-cloud-button-image]: https://service-info-public.oss-cn-hangzhou.aliyuncs.com/computenest-en.svg
|
||||
[deploy-on-alibaba-cloud-link]: https://computenest.console.aliyun.com/service/instance/create/default?type=user&ServiceName=LobeHub%E7%A4%BE%E5%8C%BA%E7%89%88
|
||||
[deploy-on-sealos-button-image]: https://raw.githubusercontent.com/labring-actions/templates/main/Deploy-on-Sealos.svg
|
||||
|
||||
+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 ====================
|
||||
|
||||
@@ -59,14 +59,14 @@ interface McpInstallParams {
|
||||
*/
|
||||
export default class McpInstallController extends ControllerModule {
|
||||
/**
|
||||
* 处理 MCP 插件安装请求
|
||||
* @param parsedData 解析后的协议数据
|
||||
* @returns 是否处理成功
|
||||
* Handle MCP plugin installation request
|
||||
* @param parsedData Parsed protocol data
|
||||
* @returns Whether processing succeeded
|
||||
*/
|
||||
@protocolHandler('install')
|
||||
public async handleInstallRequest(parsedData: McpInstallParams): Promise<boolean> {
|
||||
try {
|
||||
// 从参数中提取必需字段
|
||||
// Extract required fields from parameters
|
||||
const { id, schema: schemaParam, marketId } = parsedData;
|
||||
|
||||
if (!id) {
|
||||
@@ -76,11 +76,11 @@ export default class McpInstallController extends ControllerModule {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 映射协议来源
|
||||
// Map protocol source
|
||||
|
||||
const isOfficialMarket = marketId === 'lobehub';
|
||||
|
||||
// 对于官方市场,schema 是可选的;对于第三方市场,schema 是必需的
|
||||
// For official marketplace, schema is optional; for third-party marketplace, schema is required
|
||||
if (!isOfficialMarket && !schemaParam) {
|
||||
logger.warn(`🔧 [McpInstall] Schema is required for third-party marketplace:`, {
|
||||
marketId,
|
||||
@@ -90,7 +90,7 @@ export default class McpInstallController extends ControllerModule {
|
||||
|
||||
let mcpSchema: McpSchema | undefined;
|
||||
|
||||
// 如果提供了 schema 参数,则解析和验证
|
||||
// If schema parameter is provided, parse and validate
|
||||
if (schemaParam) {
|
||||
try {
|
||||
mcpSchema = JSON.parse(schemaParam);
|
||||
@@ -104,7 +104,7 @@ export default class McpInstallController extends ControllerModule {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证 identifier 与 id 参数匹配
|
||||
// Verify identifier matches id parameter
|
||||
if (mcpSchema.identifier !== id) {
|
||||
logger.error(`🔧 [McpInstall] Schema identifier does not match URL id parameter:`, {
|
||||
schemaId: mcpSchema.identifier,
|
||||
@@ -122,7 +122,7 @@ export default class McpInstallController extends ControllerModule {
|
||||
pluginVersion: mcpSchema?.version || 'Unknown',
|
||||
});
|
||||
|
||||
// 广播安装请求到前端
|
||||
// Broadcast installation request to frontend
|
||||
const installRequest = {
|
||||
marketId,
|
||||
pluginId: id,
|
||||
@@ -136,7 +136,7 @@ export default class McpInstallController extends ControllerModule {
|
||||
pluginName: installRequest.schema?.name || 'Unknown',
|
||||
});
|
||||
|
||||
// 通过应用实例广播到前端
|
||||
// Broadcast to frontend via app instance
|
||||
if (this.app?.browserManager) {
|
||||
this.app.browserManager.broadcastToWindow('app', 'mcpInstallRequest', installRequest);
|
||||
logger.debug(`🔧 [McpInstall] Install request broadcasted successfully`);
|
||||
|
||||
@@ -88,7 +88,7 @@ export default class NetworkProxyCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试代理连接
|
||||
* Test proxy connection
|
||||
*/
|
||||
@IpcMethod()
|
||||
async testProxyConnection(url: string): Promise<{ message?: string; success: boolean }> {
|
||||
@@ -108,7 +108,7 @@ export default class NetworkProxyCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试指定代理配置
|
||||
* Test specified proxy configuration
|
||||
*/
|
||||
@IpcMethod()
|
||||
async testProxyConfig({
|
||||
@@ -131,17 +131,17 @@ export default class NetworkProxyCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用初始代理设置
|
||||
* Apply initial proxy settings
|
||||
*/
|
||||
async beforeAppReady(): Promise<void> {
|
||||
try {
|
||||
// 获取存储的代理设置
|
||||
// Get stored proxy settings
|
||||
const networkProxy = this.app.storeManager.get(
|
||||
'networkProxy',
|
||||
defaultProxySettings,
|
||||
) as NetworkProxySettings;
|
||||
|
||||
// 验证配置
|
||||
// Validate configuration
|
||||
const validation = ProxyConfigValidator.validate(networkProxy);
|
||||
if (!validation.isValid) {
|
||||
logger.warn('Invalid stored proxy configuration, using defaults:', validation.errors);
|
||||
@@ -158,7 +158,7 @@ export default class NetworkProxyCtr extends ControllerModule {
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to apply initial proxy settings:', error);
|
||||
// 出错时使用默认设置
|
||||
// Use default settings on error
|
||||
try {
|
||||
await ProxyDispatcherManager.applyProxySettings(defaultProxySettings);
|
||||
logger.info('Fallback to default proxy settings');
|
||||
|
||||
@@ -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}`,
|
||||
@@ -162,7 +161,7 @@ export default class RemoteServerSyncCtr extends ControllerModule {
|
||||
});
|
||||
});
|
||||
|
||||
// 5. 监听请求本身的错误(如 DNS 解析失败)
|
||||
// 5. Listen for request errors (e.g., DNS resolution failure)
|
||||
clientReq.on('error', (error) => {
|
||||
logger.error(`${logPrefix} Error forwarding request:`, error);
|
||||
if (sender.isDestroyed()) return;
|
||||
@@ -196,7 +195,7 @@ export default class RemoteServerSyncCtr extends ControllerModule {
|
||||
delete requestHeaders['connection']; // Often causes issues
|
||||
// delete requestHeaders['content-length']; // Let node handle it based on body
|
||||
|
||||
// 读取代理配置
|
||||
// Read proxy configuration
|
||||
const proxyConfig = this.app.storeManager.get('networkProxy', defaultProxySettings);
|
||||
|
||||
let agent;
|
||||
|
||||
@@ -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,4 +1,46 @@
|
||||
[
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Fix feishu sso provider."]
|
||||
},
|
||||
"date": "2026-01-30",
|
||||
"version": "2.1.2"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Correct desktop download URL path."]
|
||||
},
|
||||
"date": "2026-01-30",
|
||||
"version": "2.1.1"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": ["Refactor cron job UI and use runtime enableBusinessFeatures flag."]
|
||||
},
|
||||
"date": "2026-01-30",
|
||||
"version": "2.1.0"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Fix usage table display issues."]
|
||||
},
|
||||
"date": "2026-01-29",
|
||||
"version": "2.0.13"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Group publish to market should set local group market identifer."]
|
||||
},
|
||||
"date": "2026-01-29",
|
||||
"version": "2.0.12"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"improvements": ["Fix group task render."]
|
||||
},
|
||||
"date": "2026-01-29",
|
||||
"version": "2.0.11"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"fixes": ["Add ExtendParamsTypeSchema for enhanced model settings."]
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Proxy, if you need it
|
||||
# HTTP_PROXY=http://localhost:7890
|
||||
# HTTPS_PROXY=http://localhost:7890
|
||||
|
||||
# Allowed email addresses for login, separated by commas
|
||||
# When set, only emails in the list can log in, other users cannot log in
|
||||
# Leave empty to allow all users to register
|
||||
# AUTH_ALLOWED_EMAILS=user1@example.com,user2@example.com
|
||||
|
||||
|
||||
# ===========================
|
||||
# ====== Preset config ======
|
||||
# ===========================
|
||||
# if no special requirements, no need to change
|
||||
LOBE_PORT=3210
|
||||
RUSTFS_PORT=9000
|
||||
APP_URL=http://localhost:3210
|
||||
# INTERNAL_APP_URL is optional, used for server-to-server calls
|
||||
# to bypass CDN/proxy. If not set, defaults to APP_URL.
|
||||
# Example: INTERNAL_APP_URL=http://localhost:3210
|
||||
|
||||
# Postgres related, which are the necessary environment variables for DB
|
||||
LOBE_DB_NAME=lobechat
|
||||
POSTGRES_PASSWORD=uWNZugjBqixf8dxC
|
||||
|
||||
# RUSTFS S3 configuration
|
||||
RUSTFS_ACCESS_KEY=admin
|
||||
RUSTFS_SECRET_KEY=YOUR_RUSTFS_PASSWORD
|
||||
|
||||
# Configure the bucket information of RUSTFS
|
||||
S3_PUBLIC_DOMAIN=http://localhost:9000
|
||||
S3_ENDPOINT=http://localhost:9000
|
||||
RUSTFS_LOBE_BUCKET=lobe
|
||||
|
||||
JWKS_KEY={"keys":[{"d":"PVoFyqyrGstB8wU52S7gqqQQdZLtin_thcEM0nrNtqp9U-NlKLlhgEcWp5t89ycgvhsAzmrRbezGj4JBTr3jn7eWdwQpPJNYiipnsgeJn0pwsB0H2dMqtavxinoPVXkMTOuGHMTFhhyguFBw2JbIL0PTQUcUlXjv40OoJpYHZeggSxgfV-TuxjwW8Ll4-n84M5IOi6A53RvioE-Hm1iyIc2XLBCfyOu-SbAQYi8HzrA64kCxobAB0peLQMiAzfZmwPKiGOhnhKrAlYmG02qFnbUYiJu_-AXwsAyGv9S9i6dwK7QXaGGWYyis8LlPpd_JmPrBnrWomwDlI045NUMWZQ","dp":"OSXI2NBBZl2r0Dpf4-1z44A_jC5lOyXtJhXQYnSXy5eIuxTJcEtkUYagGEwnREO4Q3t-4J-lT_6Y71M1ZlgKG1upwfw1O4aE3vGpHOik9iZYYCjA8fe5uBfOpX1ELmOtHNoHRhMtyjuPxSFXLlSp3bgcF1f3F40ClukdvXCx0Mc","dq":"m6hNdfj-F8E_7nUlX2nG95OffkFrhHTo67ML9aPgpvFwBlzg-hk5LwtxMfUzngqWF78TMl0JDm7vS1bz0xlWqXqu8pFPoTUnUoWgYfvuyHLBwR5TgccQkfoKbkSMzYNy8VJPXZeyIjVXsW98tZvj-NZF-M9Pke_EWJm-jjXCu_8","e":"AQAB","kty":"RSA","n":"piffosMS0HOSgsSr_zQkXYaQt1kOCD73VR0b2XJD6UdQCKPbnBOzTIuA_xowX61QVsl5pCZLTw8ERC3r2Nlxj5Rp_H6RuOT7ioUqlbnxSGnfuAn8dFupY3A-sf9HVDOvtJdlS-nO9yA4wWU-A50zZ1Mf0pPZlUZE6dUQfsJFi5yXaNAybyk3U4VpMO_SXAilWEHVhiO0F0ccpJMCkT47AeXmYH9MlWwIGcay0UiAsdrs8J-q1arZ7Mbq0oxHmUXJG0vwRvAL8KnCEi8cJ3e2kKCRcr-BQCujsHUyUl6f_ATwSVuTHdAR1IzIcW37v27h3WQK_v0ffQM1NstamDX5vQ","p":"4myVm2M5cZGvVXsOmWUTUG87VC1GlQcL5tmMNSGSpQCL8yWZ1vANkmCxSMptrKB4dU9DAB3On6_oMhW1pJ3uYNGSW49BcmJoLkiWKeg5zWFnKPQNuThQmY1sCCubtKhBQgaYUr7TVzN9smrDV3zCu9MlRl-XPwnEmWaDII3g-f8","q":"u9v4IOEsb4l2Y3eWKE2bwJh5fJRR4vivaYA7U-1-OpvDwB3A48Rey9IL1ucXqE5G1Du8BtijPm5oSAar5uzrjtg1bZ9gevif6DnBGaIRE7LnSrUsTPfZwzntJ1rTaGiVe_pAdnTKXXaH6DxygXxH4wvGgA44V3TTfBXQUcjzdEM","qi":"lDBnSPKkRnYqQvbqVD1LxzqBPEeqEA3GyCqMj6fIZNgoEaBSLi0TSsUyGZ5mahX3KO35vKAZa5jvGjhvUGUiXycq8KvRZdeGK45vJdwZT2TiXiDwo9IQgJcbFMpxaB9DhjX2x0yqxgUY5ca75jLqbMuKBKBN0PVqIr9jlHkR8_s","use":"sig","kid":"6823046760c5d460","alg":"RS256"}]}
|
||||
@@ -0,0 +1,31 @@
|
||||
# Proxy,如果你需要的话(比如你使用 GitHub 作为鉴权服务提供商)
|
||||
# HTTP_PROXY=http://localhost:7890
|
||||
# HTTPS_PROXY=http://localhost:7890
|
||||
|
||||
# 允许登录的邮箱地址,用英文逗号分隔
|
||||
# 设置后只有列表中的邮箱可以登录,其他用户可以注册但无法登录
|
||||
# 留空则允许所有用户注册登录
|
||||
# AUTH_ALLOWED_EMAILS=user1@example.com,user2@example.com
|
||||
|
||||
# ===================
|
||||
# ===== 预设配置 =====
|
||||
# ===================
|
||||
# 如没有特殊需要不用更改
|
||||
LOBE_PORT=3210
|
||||
RUSTFS_PORT=9000
|
||||
APP_URL=http://localhost:3210
|
||||
|
||||
# Postgres 相关,也即 DB 必须的环境变量
|
||||
LOBE_DB_NAME=lobehub
|
||||
POSTGRES_PASSWORD=uWNZugjBqixf8dxC
|
||||
|
||||
# RustFS S3 配置
|
||||
RUSTFS_ACCESS_KEY=admin
|
||||
RUSTFS_SECRET_KEY=YOUR_RUSTFS_PASSWORD
|
||||
|
||||
# 在下方配置 rustfs 中添加的桶
|
||||
S3_PUBLIC_DOMAIN=http://localhost:9000
|
||||
S3_ENDPOINT=http://localhost:9000
|
||||
RUSTFS_LOBE_BUCKET=lobe
|
||||
|
||||
JWKS_KEY={"keys":[{"d":"PVoFyqyrGstB8wU52S7gqqQQdZLtin_thcEM0nrNtqp9U-NlKLlhgEcWp5t89ycgvhsAzmrRbezGj4JBTr3jn7eWdwQpPJNYiipnsgeJn0pwsB0H2dMqtavxinoPVXkMTOuGHMTFhhyguFBw2JbIL0PTQUcUlXjv40OoJpYHZeggSxgfV-TuxjwW8Ll4-n84M5IOi6A53RvioE-Hm1iyIc2XLBCfyOu-SbAQYi8HzrA64kCxobAB0peLQMiAzfZmwPKiGOhnhKrAlYmG02qFnbUYiJu_-AXwsAyGv9S9i6dwK7QXaGGWYyis8LlPpd_JmPrBnrWomwDlI045NUMWZQ","dp":"OSXI2NBBZl2r0Dpf4-1z44A_jC5lOyXtJhXQYnSXy5eIuxTJcEtkUYagGEwnREO4Q3t-4J-lT_6Y71M1ZlgKG1upwfw1O4aE3vGpHOik9iZYYCjA8fe5uBfOpX1ELmOtHNoHRhMtyjuPxSFXLlSp3bgcF1f3F40ClukdvXCx0Mc","dq":"m6hNdfj-F8E_7nUlX2nG95OffkFrhHTo67ML9aPgpvFwBlzg-hk5LwtxMfUzngqWF78TMl0JDm7vS1bz0xlWqXqu8pFPoTUnUoWgYfvuyHLBwR5TgccQkfoKbkSMzYNy8VJPXZeyIjVXsW98tZvj-NZF-M9Pke_EWJm-jjXCu_8","e":"AQAB","kty":"RSA","n":"piffosMS0HOSgsSr_zQkXYaQt1kOCD73VR0b2XJD6UdQCKPbnBOzTIuA_xowX61QVsl5pCZLTw8ERC3r2Nlxj5Rp_H6RuOT7ioUqlbnxSGnfuAn8dFupY3A-sf9HVDOvtJdlS-nO9yA4wWU-A50zZ1Mf0pPZlUZE6dUQfsJFi5yXaNAybyk3U4VpMO_SXAilWEHVhiO0F0ccpJMCkT47AeXmYH9MlWwIGcay0UiAsdrs8J-q1arZ7Mbq0oxHmUXJG0vwRvAL8KnCEi8cJ3e2kKCRcr-BQCujsHUyUl6f_ATwSVuTHdAR1IzIcW37v27h3WQK_v0ffQM1NstamDX5vQ","p":"4myVm2M5cZGvVXsOmWUTUG87VC1GlQcL5tmMNSGSpQCL8yWZ1vANkmCxSMptrKB4dU9DAB3On6_oMhW1pJ3uYNGSW49BcmJoLkiWKeg5zWFnKPQNuThQmY1sCCubtKhBQgaYUr7TVzN9smrDV3zCu9MlRl-XPwnEmWaDII3g-f8","q":"u9v4IOEsb4l2Y3eWKE2bwJh5fJRR4vivaYA7U-1-OpvDwB3A48Rey9IL1ucXqE5G1Du8BtijPm5oSAar5uzrjtg1bZ9gevif6DnBGaIRE7LnSrUsTPfZwzntJ1rTaGiVe_pAdnTKXXaH6DxygXxH4wvGgA44V3TTfBXQUcjzdEM","qi":"lDBnSPKkRnYqQvbqVD1LxzqBPEeqEA3GyCqMj6fIZNgoEaBSLi0TSsUyGZ5mahX3KO35vKAZa5jvGjhvUGUiXycq8KvRZdeGK45vJdwZT2TiXiDwo9IQgJcbFMpxaB9DhjX2x0yqxgUY5ca75jLqbMuKBKBN0PVqIr9jlHkR8_s","use":"sig","kid":"6823046760c5d460","alg":"RS256"}]}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"ID": "",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "",
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"AWS": ["*"]
|
||||
},
|
||||
"Action": ["s3:GetObject"],
|
||||
"NotAction": [],
|
||||
"Resource": ["arn:aws:s3:::lobe/*"],
|
||||
"NotResource": [],
|
||||
"Condition": {}
|
||||
}
|
||||
],
|
||||
"Version": "2012-10-17"
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
name: lobehub
|
||||
services:
|
||||
network-service:
|
||||
image: alpine
|
||||
container_name: lobe-network
|
||||
restart: always
|
||||
ports:
|
||||
- '${RUSTFS_PORT}:9000' # RustFS API
|
||||
- '9001:9001' # RustFS Console
|
||||
- '${LOBE_PORT}:3210' # LobeChat
|
||||
command: tail -f /dev/null
|
||||
networks:
|
||||
- lobe-network
|
||||
|
||||
postgresql:
|
||||
image: paradedb/paradedb:latest-pg17
|
||||
container_name: lobe-postgres
|
||||
ports:
|
||||
- '5432:5432'
|
||||
volumes:
|
||||
- './data:/var/lib/postgresql/data'
|
||||
environment:
|
||||
- 'POSTGRES_DB=${LOBE_DB_NAME}'
|
||||
- 'POSTGRES_PASSWORD=${POSTGRES_PASSWORD}'
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U postgres']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: always
|
||||
networks:
|
||||
- lobe-network
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: lobe-redis
|
||||
ports:
|
||||
- '6379:6379'
|
||||
command: redis-server --save 60 1000 --appendonly yes
|
||||
volumes:
|
||||
- 'redis_data:/data'
|
||||
healthcheck:
|
||||
test: ['CMD', 'redis-cli', 'ping']
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
restart: always
|
||||
networks:
|
||||
- lobe-network
|
||||
|
||||
rustfs:
|
||||
image: rustfs/rustfs:latest
|
||||
container_name: lobe-rustfs
|
||||
network_mode: 'service:network-service'
|
||||
environment:
|
||||
- RUSTFS_CONSOLE_ENABLE=true
|
||||
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY}
|
||||
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY}
|
||||
volumes:
|
||||
- rustfs-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:9000/health >/dev/null 2>&1 || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
command: ["--access-key","${RUSTFS_ACCESS_KEY}","--secret-key","${RUSTFS_SECRET_KEY}","/data"]
|
||||
|
||||
rustfs-init:
|
||||
image: minio/mc:latest
|
||||
container_name: lobe-rustfs-init
|
||||
depends_on:
|
||||
rustfs:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./bucket.config.json:/bucket.config.json:ro
|
||||
entrypoint: /bin/sh
|
||||
command: -c '
|
||||
set -eux;
|
||||
echo "S3_ACCESS_KEY=${RUSTFS_ACCESS_KEY}, S3_SECRET_KEY=${RUSTFS_SECRET_KEY}";
|
||||
mc --version;
|
||||
mc alias set rustfs "http://network-service:9000" "${RUSTFS_ACCESS_KEY}" "${RUSTFS_SECRET_KEY}";
|
||||
mc ls rustfs || true;
|
||||
mc mb "rustfs/lobe" --ignore-existing;
|
||||
mc admin info rustfs || true;
|
||||
mc anonymous set-json "/bucket.config.json" "rustfs/lobe";
|
||||
'
|
||||
restart: "no"
|
||||
networks:
|
||||
- lobe-network
|
||||
|
||||
searxng:
|
||||
image: searxng/searxng
|
||||
container_name: lobe-searxng
|
||||
volumes:
|
||||
- './searxng-settings.yml:/etc/searxng/settings.yml'
|
||||
environment:
|
||||
- 'SEARXNG_SETTINGS_FILE=/etc/searxng/settings.yml'
|
||||
restart: always
|
||||
networks:
|
||||
- lobe-network
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
lobe:
|
||||
image: lobehub/lobehub
|
||||
container_name: lobehub
|
||||
network_mode: 'service:network-service'
|
||||
depends_on:
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
network-service:
|
||||
condition: service_started
|
||||
rustfs:
|
||||
condition: service_healthy
|
||||
rustfs-init:
|
||||
condition: service_completed_successfully
|
||||
redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- 'KEY_VAULTS_SECRET=Kix2wcUONd4CX51E/ZPAd36BqM4wzJgKjPtz2sGztqQ='
|
||||
- 'AUTH_SECRET=NX2kaPE923dt6BL2U8e9oSre5RfoT7hg'
|
||||
- 'DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgresql:5432/${LOBE_DB_NAME}'
|
||||
- 'S3_BUCKET=${RUSTFS_LOBE_BUCKET}'
|
||||
- 'S3_ENABLE_PATH_STYLE=1'
|
||||
- 'S3_ACCESS_KEY=${RUSTFS_ACCESS_KEY}'
|
||||
- 'S3_ACCESS_KEY_ID=${RUSTFS_ACCESS_KEY}'
|
||||
- 'S3_SECRET_ACCESS_KEY=${RUSTFS_SECRET_KEY}'
|
||||
- 'LLM_VISION_IMAGE_USE_BASE64=1'
|
||||
- 'S3_SET_ACL=0'
|
||||
- 'SEARXNG_URL=http://searxng:8080'
|
||||
- 'REDIS_URL=redis://redis:6379'
|
||||
- 'REDIS_PREFIX=lobechat'
|
||||
- 'REDIS_TLS=0'
|
||||
env_file:
|
||||
- .env
|
||||
restart: always
|
||||
|
||||
volumes:
|
||||
data:
|
||||
driver: local
|
||||
redis_data:
|
||||
driver: local
|
||||
rustfs-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
lobe-network:
|
||||
driver: bridge
|
||||
File diff suppressed because it is too large
Load Diff
+37
-88
@@ -263,10 +263,10 @@ show_message() {
|
||||
tips_allow_ports)
|
||||
case $LANGUAGE in
|
||||
zh_CN)
|
||||
echo "请确保服务器以下端口未被占用且能被访问:3210, 9000, 9001, 8000"
|
||||
echo "请确保服务器以下端口未被占用且能被访问:3210, 9000, 9001"
|
||||
;;
|
||||
*)
|
||||
echo "Please make sure the following ports on the server are not occupied and can be accessed: 3210, 9000, 9001, 8000"
|
||||
echo "Please make sure the following ports on the server are not occupied and can be accessed: 3210, 9000, 9001"
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
@@ -315,12 +315,10 @@ show_message() {
|
||||
tips_init_database_failed)
|
||||
case $LANGUAGE in
|
||||
zh_CN)
|
||||
echo "无法初始化数据库,为了避免你的数据重复初始化,请在首次成功启动时运行以下指令清空 Casdoor 初始配置文件:"
|
||||
echo "echo '{}' > init_data.json"
|
||||
echo "无法初始化数据库"
|
||||
;;
|
||||
*)
|
||||
echo "Failed to initialize the database. To avoid your data being initialized repeatedly, run the following command to unmount the initial configuration file of Casdoor when you first start successfully:"
|
||||
echo "echo '{}' > init_data.json"
|
||||
echo "Failed to initialize the database."
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
@@ -338,7 +336,7 @@ show_message() {
|
||||
case $LANGUAGE in
|
||||
zh_CN)
|
||||
echo "请选择部署模式:"
|
||||
echo "(0) 域名模式(访问时无需指明端口),需要使用反向代理服务 LobeHub, RustFS, Casdoor ,并分别分配一个域名;"
|
||||
echo "(0) 域名模式(访问时无需指明端口),需要使用反向代理服务 LobeHub, RustFS,并分别分配一个域名;"
|
||||
echo "(1) 端口模式(访问时需要指明端口,如使用IP访问,或域名+端口访问),需要放开指定端口;"
|
||||
echo "(2) 本地模式(仅供本地测试使用)"
|
||||
echo "如果你对这些内容疑惑,可以先选择使用本地模式进行部署,稍后根据文档指引再进行修改。"
|
||||
@@ -346,7 +344,7 @@ show_message() {
|
||||
;;
|
||||
*)
|
||||
echo "Please select the deployment mode:"
|
||||
echo "(0) Domain mode (no need to specify the port when accessing), you need to use the reverse proxy service LobeHub, RustFS, Casdoor, and assign a domain name respectively;"
|
||||
echo "(0) Domain mode (no need to specify the port when accessing), you need to use the reverse proxy service LobeHub, RustFS, and assign a domain name respectively;"
|
||||
echo "(1) Port mode (need to specify the port when accessing, such as using IP access, or domain name + port access), you need to open the specified port;"
|
||||
echo "(2) Local mode (for local testing only)"
|
||||
echo "If you are confused about these contents, you can choose to deploy in local mode first, and then modify according to the document guide later."
|
||||
@@ -408,31 +406,33 @@ download_file() {
|
||||
}
|
||||
|
||||
print_centered() {
|
||||
# Define colors
|
||||
declare -A colors
|
||||
colors=(
|
||||
[black]="\e[30m"
|
||||
[red]="\e[31m"
|
||||
[green]="\e[32m"
|
||||
[yellow]="\e[33m"
|
||||
[blue]="\e[34m"
|
||||
[magenta]="\e[35m"
|
||||
[cyan]="\e[36m"
|
||||
[white]="\e[37m"
|
||||
[reset]="\e[0m"
|
||||
)
|
||||
local text="$1" # Get input texts
|
||||
local color="${2:-reset}" # Get color, default to reset
|
||||
local term_width=$(tput cols) # Get terminal width
|
||||
local text_length=${#text} # Get text length
|
||||
local padding=$(((term_width - text_length) / 2)) # Get padding
|
||||
# Check if the color is valid
|
||||
if [[ -z "${colors[$color]}" ]]; then
|
||||
echo "Invalid color specified. Available colors: ${!colors[@]}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Get color code (compatible with bash 3.x)
|
||||
local color_code=""
|
||||
local reset_code="\e[0m"
|
||||
case "$color" in
|
||||
black) color_code="\e[30m" ;;
|
||||
red) color_code="\e[31m" ;;
|
||||
green) color_code="\e[32m" ;;
|
||||
yellow) color_code="\e[33m" ;;
|
||||
blue) color_code="\e[34m" ;;
|
||||
magenta) color_code="\e[35m" ;;
|
||||
cyan) color_code="\e[36m" ;;
|
||||
white) color_code="\e[37m" ;;
|
||||
reset) color_code="\e[0m" ;;
|
||||
*)
|
||||
echo "Invalid color specified. Available colors: black red green yellow blue magenta cyan white reset"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Print the text with padding
|
||||
printf "%*s${colors[$color]}%s${colors[reset]}\n" $padding "" "$text"
|
||||
printf "%*s${color_code}%s${reset_code}\n" $padding "" "$text"
|
||||
}
|
||||
|
||||
# Usage:
|
||||
@@ -469,10 +469,9 @@ ask() {
|
||||
# == Variables ==
|
||||
# ===============
|
||||
# File list
|
||||
SUB_DIR="docker-compose/local"
|
||||
SUB_DIR="docker-compose/deploy"
|
||||
FILES=(
|
||||
"$SUB_DIR/docker-compose.yml"
|
||||
"$SUB_DIR/init_data.json"
|
||||
"$SUB_DIR/searxng-settings.yml"
|
||||
"$SUB_DIR/bucket.config.json"
|
||||
)
|
||||
@@ -481,10 +480,7 @@ ENV_EXAMPLES=(
|
||||
"$SUB_DIR/.env.example"
|
||||
)
|
||||
# Default values
|
||||
CASDOOR_PASSWORD="pswd123"
|
||||
CASDOOR_SECRET="CASDOOR_SECRET"
|
||||
RUSTFS_SECRET_KEY="YOUR_RUSTFS_PASSWORD"
|
||||
CASDOOR_HOST="localhost:8000"
|
||||
RUSTFS_HOST="localhost:9000"
|
||||
PROTOCOL="http"
|
||||
|
||||
@@ -514,9 +510,8 @@ section_download_files(){
|
||||
fi
|
||||
|
||||
download_file "$SOURCE_URL/${FILES[0]}" "docker-compose.yml"
|
||||
download_file "$SOURCE_URL/${FILES[1]}" "init_data.json"
|
||||
download_file "$SOURCE_URL/${FILES[2]}" "searxng-settings.yml"
|
||||
download_file "$SOURCE_URL/${FILES[3]}" "bucket.config.json"
|
||||
download_file "$SOURCE_URL/${FILES[1]}" "searxng-settings.yml"
|
||||
download_file "$SOURCE_URL/${FILES[2]}" "bucket.config.json"
|
||||
# Download .env.example with the specified language
|
||||
if [ "$LANGUAGE" = "zh_CN" ]; then
|
||||
download_file "$SOURCE_URL/${ENV_EXAMPLES[0]}" ".env"
|
||||
@@ -576,15 +571,10 @@ section_configurate_host() {
|
||||
echo "LobeHub" $(show_message "ask_domain" "example.com")
|
||||
ask "(example.com)"
|
||||
LOBE_HOST="$ask_result"
|
||||
# If user use domain mode, ask for the domain of RustFS and Casdoor
|
||||
# If user use domain mode, ask for the domain of RustFS
|
||||
echo "RustFS S3 API" $(show_message "ask_domain" "s3.example.com")
|
||||
ask "(s3.example.com)"
|
||||
RUSTFS_HOST="$ask_result"
|
||||
echo "Casdoor API" $(show_message "ask_domain" "auth.example.com")
|
||||
ask "(auth.example.com)"
|
||||
CASDOOR_HOST="$ask_result"
|
||||
# Setup callback url for Casdoor
|
||||
sed "${SED_INPLACE_ARGS[@]}" "s/"example.com"/${LOBE_HOST}/" init_data.json
|
||||
;;
|
||||
1)
|
||||
DEPLOY_MODE="ip"
|
||||
@@ -595,21 +585,15 @@ section_configurate_host() {
|
||||
# If user use ip mode, append the port to the host
|
||||
LOBE_HOST="${HOST}:3210"
|
||||
RUSTFS_HOST="${HOST}:9000"
|
||||
CASDOOR_HOST="${HOST}:8000"
|
||||
# Setup callback url for Casdoor
|
||||
sed "${SED_INPLACE_ARGS[@]}" "s/"localhost:3210"/${LOBE_HOST}/" init_data.json
|
||||
;;
|
||||
*)
|
||||
echo "Invalid deploy mode: $ask_result"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
# lobe host
|
||||
sed "${SED_INPLACE_ARGS[@]}" "s#^APP_URL=.*#APP_URL=$PROTOCOL://$LOBE_HOST#" .env
|
||||
# auth related
|
||||
sed "${SED_INPLACE_ARGS[@]}" "s#^AUTH_CASDOOR_ISSUER=.*#AUTH_CASDOOR_ISSUER=$PROTOCOL://$CASDOOR_HOST#" .env
|
||||
sed "${SED_INPLACE_ARGS[@]}" "s#^origin=.*#origin=$PROTOCOL://$CASDOOR_HOST#" .env
|
||||
# s3 related
|
||||
sed "${SED_INPLACE_ARGS[@]}" "s#^S3_PUBLIC_DOMAIN=.*#S3_PUBLIC_DOMAIN=$PROTOCOL://$RUSTFS_HOST#" .env
|
||||
sed "${SED_INPLACE_ARGS[@]}" "s#^S3_ENDPOINT=.*#S3_ENDPOINT=$PROTOCOL://$RUSTFS_HOST#" .env
|
||||
@@ -664,37 +648,7 @@ section_regenerate_secrets() {
|
||||
exit 1
|
||||
fi
|
||||
echo $(show_message "security_secrect_regenerate")
|
||||
|
||||
# Generate CASDOOR_SECRET
|
||||
CASDOOR_SECRET=$(generate_key 32)
|
||||
if [ $? -ne 0 ]; then
|
||||
echo $(show_message "security_secrect_regenerate_failed") "CASDOOR_SECRET"
|
||||
else
|
||||
# Search and replace the value of CASDOOR_SECRET in .env
|
||||
sed "${SED_INPLACE_ARGS[@]}" "s#^AUTH_CASDOOR_SECRET=.*#AUTH_CASDOOR_SECRET=${CASDOOR_SECRET}#" .env
|
||||
if [ $? -ne 0 ]; then
|
||||
echo $(show_message "security_secrect_regenerate_failed") "AUTH_CASDOOR_SECRET in \`.env\`"
|
||||
fi
|
||||
# replace `clientSecrect` in init_data.json
|
||||
sed "${SED_INPLACE_ARGS[@]}" "s#dbf205949d704de81b0b5b3603174e23fbecc354#${CASDOOR_SECRET}#" init_data.json
|
||||
if [ $? -ne 0 ]; then
|
||||
echo $(show_message "security_secrect_regenerate_failed") "AUTH_CASDOOR_SECRET in \`init_data.json\`"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Generate Casdoor User
|
||||
CASDOOR_USER="admin"
|
||||
CASDOOR_PASSWORD=$(generate_key 10)
|
||||
if [ $? -ne 0 ]; then
|
||||
echo $(show_message "security_secrect_regenerate_failed") "CASDOOR_PASSWORD"
|
||||
CASDOOR_PASSWORD="pswd123"
|
||||
else
|
||||
# replace `password` in init_data.json
|
||||
sed "${SED_INPLACE_ARGS[@]}" "s/"pswd123"/${CASDOOR_PASSWORD}/" init_data.json
|
||||
if [ $? -ne 0 ]; then
|
||||
echo $(show_message "security_secrect_regenerate_failed") "CASDOOR_PASSWORD in \`init_data.json\`"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Generate RUSTFS S3 User Password
|
||||
RUSTFS_SECRET_KEY=$(generate_key 8)
|
||||
if [ $? -ne 0 ]; then
|
||||
@@ -735,13 +689,10 @@ section_init_database() {
|
||||
fi
|
||||
|
||||
docker compose pull
|
||||
docker compose up --detach postgresql casdoor
|
||||
docker compose up --detach postgresql
|
||||
# hopefully enough time for even the slower systems
|
||||
sleep 15
|
||||
docker compose stop
|
||||
|
||||
# Init finished, remove init mount
|
||||
echo '{}' > init_data.json
|
||||
}
|
||||
|
||||
show_message "ask_init_database"
|
||||
@@ -759,16 +710,14 @@ fi
|
||||
section_display_configurated_report() {
|
||||
# Display configuration reports
|
||||
echo $(show_message "security_secrect_regenerate_report")
|
||||
|
||||
echo -e "LobeHub: \n - URL: $PROTOCOL://$LOBE_HOST \n - Username: user \n - Password: ${CASDOOR_PASSWORD} "
|
||||
echo -e "Casdoor: \n - URL: $PROTOCOL://$CASDOOR_HOST \n - Username: admin \n - Password: ${CASDOOR_PASSWORD}\n"
|
||||
|
||||
echo -e "LobeHub: \n - URL: $PROTOCOL://$LOBE_HOST"
|
||||
echo -e "RustFS: \n - URL: $PROTOCOL://$RUSTFS_HOST \n - Username: admin\n - Password: ${RUSTFS_SECRET_KEY}\n"
|
||||
|
||||
|
||||
# if user run in domain mode, diplay reverse proxy configuration
|
||||
if [[ "$DEPLOY_MODE" == "domain" ]]; then
|
||||
echo $(show_message "tips_add_reverse_proxy")
|
||||
printf "\n%s\t->\t%s\n" "$LOBE_HOST" "127.0.0.1:3210"
|
||||
printf "%s\t->\t%s\n" "$CASDOOR_HOST" "127.0.0.1:8000"
|
||||
printf "%s\t->\t%s\n" "$RUSTFS_HOST" "127.0.0.1:9000"
|
||||
fi
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ tags:
|
||||
|
||||
# Seamless DeepSeek R1 Integration, Unlock a New Chain-of-Thought Experience
|
||||
|
||||
LobeChat completed its largest AI ecosystem expansion ever in February, delivering a more powerful and flexible AI chat experience.
|
||||
LobeHub completed its largest AI ecosystem expansion ever in February, delivering a more powerful and flexible AI chat experience.
|
||||
|
||||
## 🌟 Major Updates
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ tags:
|
||||
|
||||
# 完美集成 DeepSeek R1 ,开启思维链新体验
|
||||
|
||||
LobeChat 在二月完成了史上最大规模的 AI 生态扩展,带来更强大、更灵活的 AI 对话体验。
|
||||
LobeHub 在二月完成了史上最大规模的 AI 生态扩展,带来更强大、更灵活的 AI 对话体验。
|
||||
|
||||
## 🌟 重大更新
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ tags:
|
||||
|
||||
# Seamless DeepSeek R1 Integration, Unlock a New Chain-of-Thought Experience
|
||||
|
||||
In March, LobeChat continued to refine the user experience—adding practical features like customizable hotkeys and data export, while further expanding the AI provider ecosystem.
|
||||
In March, LobeHub continued to refine the user experience—adding practical features like customizable hotkeys and data export, while further expanding the AI provider ecosystem.
|
||||
|
||||
## 🌟 Key Updates
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ tags:
|
||||
|
||||
# 完美集成 DeepSeek R1 ,开启思维链新体验
|
||||
|
||||
LobeChat 在三月持续优化用户体验,新增快捷键自定义、数据导出等实用功能,并扩展 AI 服务商生态。
|
||||
LobeHub 在三月持续优化用户体验,新增快捷键自定义、数据导出等实用功能,并扩展 AI 服务商生态。
|
||||
|
||||
## 🌟 重要更新
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Brand-New Design Style and Desktop App Release ✨
|
||||
description: LobeChat officially launches the desktop app, delivering a more modern and smoother experience.
|
||||
description: LobeHub officially launches the desktop app, delivering a more modern and smoother experience.
|
||||
tags:
|
||||
- Desktop App
|
||||
- LobeHub
|
||||
@@ -9,7 +9,7 @@ tags:
|
||||
|
||||
# Brand-New Design Style and Desktop App Release ✨
|
||||
|
||||
In April, LobeChat shipped a major visual upgrade with the brand-new Lobe UI v2 design system, and officially released the desktop app—bringing a more modern and fluid experience.
|
||||
In April, LobeHub shipped a major visual upgrade with the brand-new Lobe UI v2 design system, and officially released the desktop app—bringing a more modern and fluid experience.
|
||||
|
||||
## 🌟 Major Updates
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: 全新设计风格与桌面端发布 ✨
|
||||
description: LobeChat 正式发布桌面端应用,带来更现代、更流畅的使用体验
|
||||
description: LobeHub 正式发布桌面端应用,带来更现代、更流畅的使用体验
|
||||
tags:
|
||||
- 桌面端
|
||||
- LobeHub
|
||||
@@ -9,7 +9,7 @@ tags:
|
||||
|
||||
# 全新设计风格与桌面端发布 ✨
|
||||
|
||||
LobeChat 在四月完成重大视觉升级,推出全新 Lobe UI v2 设计系统,并正式发布桌面端应用,带来更现代、更流畅的使用体验。
|
||||
LobeHub 在四月完成重大视觉升级,推出全新 Lobe UI v2 设计系统,并正式发布桌面端应用,带来更现代、更流畅的使用体验。
|
||||
|
||||
## 🌟 重大更新
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ tags:
|
||||
|
||||
# Prompt Variables and Claude 4 Reasoning Model Support 🚀
|
||||
|
||||
From May to June, LobeChat continued to refine core capabilities—introducing a prompt-variables system, adding support for Claude 4 reasoning models, and expanding search and crawling across multiple AI providers.
|
||||
From May to June, LobeHub continued to refine core capabilities—introducing a prompt-variables system, adding support for Claude 4 reasoning models, and expanding search and crawling across multiple AI providers.
|
||||
|
||||
## 🌟 Key Updates
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ tags:
|
||||
|
||||
# 提示词变量与 Claude 4 推理模型支持 🚀
|
||||
|
||||
LobeChat 在五月至六月持续优化核心功能,新增提示词变量系统、支持 Claude 4 推理模型,并扩展多个 AI 服务商的搜索与推理能力。
|
||||
LobeHub 在五月至六月持续优化核心功能,新增提示词变量系统、支持 Claude 4 推理模型,并扩展多个 AI 服务商的搜索与推理能力。
|
||||
|
||||
## 🌟 主要更新
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ tags:
|
||||
|
||||
# MCP Marketplace and Search Provider Expansion 🔍
|
||||
|
||||
From June to July, LobeChat launched the MCP plugin marketplace, added support for multiple search providers, and integrated Amazon Cognito and Google SSO—continuing to improve both user experience and the developer ecosystem.
|
||||
From June to July, LobeHub launched the MCP plugin marketplace, added support for multiple search providers, and integrated Amazon Cognito and Google SSO—continuing to improve both user experience and the developer ecosystem.
|
||||
|
||||
## 🌟 Major Updates
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ tags:
|
||||
|
||||
# MCP 市场与搜索服务商扩展 🔍
|
||||
|
||||
LobeChat 在六月至七月推出 MCP 插件市场,新增多个搜索服务商支持,并集成 Amazon Cognito 与 Google SSO 认证,持续优化用户体验与开发者生态。
|
||||
LobeHub 在六月至七月推出 MCP 插件市场,新增多个搜索服务商支持,并集成 Amazon Cognito 与 Google SSO 认证,持续优化用户体验与开发者生态。
|
||||
|
||||
## 🌟 重大更新
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ tags:
|
||||
|
||||
# AI Image Generation and Desktop Enhancements 🎨
|
||||
|
||||
From July to August, LobeChat introduced AI image generation, added support for multiple providers, and continued improving the desktop experience and authentication system.
|
||||
From July to August, LobeHub introduced AI image generation, added support for multiple providers, and continued improving the desktop experience and authentication system.
|
||||
|
||||
## 🌟 Major Updates
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ tags:
|
||||
|
||||
# AI 图像生成与桌面端增强 🎨
|
||||
|
||||
LobeChat 在七月至八月推出 AI 图像生成功能,新增多个服务商支持,并持续优化桌面端体验与认证系统。
|
||||
LobeHub 在七月至八月推出 AI 图像生成功能,新增多个服务商支持,并持续优化桌面端体验与认证系统。
|
||||
|
||||
## 🌟 重大更新
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user