diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 08ca8c19e..ba05e9cfe 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -57,7 +57,9 @@ jobs: run: pnpm run installRuntime:win:${{ matrix.arch }} - name: Build Windows - run: pnpm run build:win:${{ matrix.arch }} + run: | + pnpm run build + pnpm exec electron-builder --win --${{ matrix.arch }} --publish=never env: VITE_GITHUB_CLIENT_ID: ${{ secrets.DC_GITHUB_CLIENT_ID }} VITE_GITHUB_CLIENT_SECRET: ${{ secrets.DC_GITHUB_CLIENT_SECRET }} @@ -111,7 +113,9 @@ jobs: # run: pnpm run installRuntime:linux:${{ matrix.arch }} - name: Build Linux - run: pnpm run build:linux:${{ matrix.arch }} + run: | + pnpm run build + pnpm exec electron-builder --linux --${{ matrix.arch }} --publish=never env: VITE_GITHUB_CLIENT_ID: ${{ secrets.DC_GITHUB_CLIENT_ID }} VITE_GITHUB_CLIENT_SECRET: ${{ secrets.DC_GITHUB_CLIENT_SECRET }} @@ -166,7 +170,9 @@ jobs: run: pnpm run installRuntime:mac:${{ matrix.arch }} - name: Build Mac - run: pnpm run build:mac:${{ matrix.arch }} + run: | + pnpm run build + pnpm exec electron-builder --mac --${{ matrix.arch }} --publish=never env: CSC_LINK: ${{ secrets.DEEPCHAT_CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.DEEPCHAT_CSC_KEY_PASS }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1209a9066..8065fdda9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,45 +1,341 @@ -name: Create Release +name: Release on: workflow_dispatch: inputs: - workflow_id: - description: 'Build workflow run ID to use for artifacts' + tag: + description: 'Release tag (e.g. v0.5.5 v0.5.6-beta.1)' required: true type: string - prerelease: - description: 'Is this a prerelease?' - required: true - type: boolean - default: false + push: + tags: + - v*.*.* + +permissions: + contents: write jobs: - create-release: + resolve-tag: runs-on: ubuntu-latest + outputs: + tag: ${{ steps.resolve.outputs.tag }} + sha: ${{ steps.resolve.outputs.sha }} steps: - - name: Download artifacts from workflow - uses: dawidd6/action-download-artifact@v6 + - name: Resolve tag + id: resolve + uses: actions/github-script@v7 with: - workflow_conclusion: success - run_id: ${{ github.event.inputs.workflow_id }} - path: artifacts + script: | + const isDispatch = context.eventName === 'workflow_dispatch' + const tag = isDispatch + ? context.payload.inputs?.tag + : context.ref.replace('refs/tags/', '') + if (!tag) { + core.setFailed('Tag is required') + return + } + + const owner = context.repo.owner + const repo = context.repo.repo + const refName = `tags/${tag}` + + const resolveTagSha = async (refData) => { + let sha = refData.object.sha + if (refData.object.type === 'tag') { + const tagObj = await github.rest.git.getTag({ + owner, + repo, + tag_sha: sha + }) + sha = tagObj.data.object.sha + } + return sha + } + + if (isDispatch) { + try { + const { data } = await github.rest.git.getRef({ + owner, + repo, + ref: refName + }) + const sha = await resolveTagSha(data) + core.setOutput('sha', sha) + } catch (error) { + const sha = context.sha + await github.rest.git.createRef({ + owner, + repo, + ref: `refs/${refName}`, + sha + }) + core.setOutput('sha', sha) + } + } else { + try { + const { data } = await github.rest.git.getRef({ + owner, + repo, + ref: refName + }) + const sha = await resolveTagSha(data) + core.setOutput('sha', sha) + } catch (error) { + core.setFailed(`Tag ${tag} not found`) + return + } + } + + core.setOutput('tag', tag) + + build-windows: + needs: resolve-tag + runs-on: windows-latest + strategy: + matrix: + arch: [x64] + include: + - arch: x64 + platform: win-x64 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.resolve-tag.outputs.sha }} + fetch-depth: 1 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22.13.1' - - name: List downloaded artifacts - run: find artifacts -type f | sort + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: 10.12.1 + + - name: Install dependencies + run: pnpm install + + - name: Configure pnpm workspace for Windows ${{ matrix.arch }} + run: pnpm run install:sharp + env: + TARGET_OS: win32 + TARGET_ARCH: ${{ matrix.arch }} + + - name: Install dependencies + run: pnpm install + env: + npm_config_build_from_source: true + npm_config_platform: win32 + npm_config_arch: ${{ matrix.arch }} + + - name: Install Node Runtime + run: pnpm run installRuntime:win:${{ matrix.arch }} + + - name: Build Windows + run: | + pnpm run build + pnpm exec electron-builder --win --${{ matrix.arch }} --publish=never + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VITE_GITHUB_CLIENT_ID: ${{ secrets.DC_GITHUB_CLIENT_ID }} + VITE_GITHUB_CLIENT_SECRET: ${{ secrets.DC_GITHUB_CLIENT_SECRET }} + VITE_GITHUB_REDIRECT_URI: ${{ secrets.DC_GITHUB_REDIRECT_URI }} + VITE_PROVIDER_DB_URL: ${{ secrets.CDN_PROVIDER_DB_URL }} + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: deepchat-${{ matrix.platform }} + path: | + dist/* + !dist/win-unpacked + !dist/win-arm64-unpacked + + build-linux: + needs: resolve-tag + runs-on: ubuntu-22.04 + strategy: + matrix: + arch: [x64] + include: + - arch: x64 + platform: linux-x64 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.resolve-tag.outputs.sha }} + fetch-depth: 1 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22.13.1' + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: 10.12.1 + + - name: Install dependencies + run: pnpm install + + - name: Configure pnpm workspace for Linux ${{ matrix.arch }} + run: pnpm run install:sharp + env: + TARGET_OS: linux + TARGET_ARCH: ${{ matrix.arch }} + + - name: Install dependencies + run: pnpm install + + - name: Build Linux + run: | + pnpm run build + pnpm exec electron-builder --linux --${{ matrix.arch }} --publish=never + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VITE_GITHUB_CLIENT_ID: ${{ secrets.DC_GITHUB_CLIENT_ID }} + VITE_GITHUB_CLIENT_SECRET: ${{ secrets.DC_GITHUB_CLIENT_SECRET }} + VITE_GITHUB_REDIRECT_URI: ${{ secrets.DC_GITHUB_REDIRECT_URI }} + VITE_PROVIDER_DB_URL: ${{ secrets.CDN_PROVIDER_DB_URL }} + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: deepchat-${{ matrix.platform }} + path: | + dist/* + !dist/linux-unpacked + + build-mac: + needs: resolve-tag + runs-on: macos-15 + strategy: + matrix: + arch: [x64, arm64] + include: + - arch: x64 + platform: mac-x64 + - arch: arm64 + platform: mac-arm64 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.resolve-tag.outputs.sha }} + fetch-depth: 1 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22.13.1' + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: 10.12.1 + + - name: Install dependencies + run: pnpm install + + - name: Configure pnpm workspace for macOS ${{ matrix.arch }} + run: pnpm run install:sharp + env: + TARGET_OS: darwin + TARGET_ARCH: ${{ matrix.arch }} + + - name: Install dependencies + run: pnpm install + + - name: Install Node Runtime + run: pnpm run installRuntime:mac:${{ matrix.arch }} + + - name: Build Mac + run: | + pnpm run build + pnpm exec electron-builder --mac --${{ matrix.arch }} --publish=never + env: + CSC_LINK: ${{ secrets.DEEPCHAT_CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.DEEPCHAT_CSC_KEY_PASS }} + DEEPCHAT_APPLE_NOTARY_USERNAME: ${{ secrets.DEEPCHAT_APPLE_NOTARY_USERNAME }} + DEEPCHAT_APPLE_NOTARY_TEAM_ID: ${{ secrets.DEEPCHAT_APPLE_NOTARY_TEAM_ID }} + DEEPCHAT_APPLE_NOTARY_PASSWORD: ${{ secrets.DEEPCHAT_APPLE_NOTARY_PASSWORD }} + build_for_release: '2' + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VITE_GITHUB_CLIENT_ID: ${{ secrets.DC_GITHUB_CLIENT_ID }} + VITE_GITHUB_CLIENT_SECRET: ${{ secrets.DC_GITHUB_CLIENT_SECRET }} + VITE_GITHUB_REDIRECT_URI: ${{ secrets.DC_GITHUB_REDIRECT_URI }} + NODE_OPTIONS: '--max-old-space-size=4096' + VITE_PROVIDER_DB_URL: ${{ secrets.CDN_PROVIDER_DB_URL }} + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: deepchat-${{ matrix.platform }} + path: | + dist/* + !dist/mac/* + !dist/mac-arm64/* + + release: + needs: + - resolve-tag + - build-windows + - build-linux + - build-mac + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.resolve-tag.outputs.sha }} + fetch-depth: 1 - name: Get version number id: get_version run: | - VERSION_FILE=$(find artifacts/deepchat-linux-x64 -name "DeepChat-*.tar.gz" | head -n 1) - if [ -n "$VERSION_FILE" ]; then - VERSION=$(echo $VERSION_FILE | grep -o 'DeepChat-[0-9]\+\.[0-9]\+\.[0-9]\+' | sed 's/DeepChat-//') - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "Found version: $VERSION" + VERSION=$(node -p "require('./package.json').version") + TAG="${{ needs.resolve-tag.outputs.tag }}" + if [ "v$VERSION" != "$TAG" ]; then + echo "Error: tag $TAG does not match package.json version v$VERSION" + exit 1 + fi + if echo "$VERSION" | grep -qE '-(beta|alpha)\.[0-9]+$'; then + echo "prerelease=true" >> $GITHUB_OUTPUT else - echo "Error: DeepChat tar.gz file not found" + echo "prerelease=false" >> $GITHUB_OUTPUT + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Build release notes from CHANGELOG + run: | + VERSION="${{ steps.get_version.outputs.version }}" + CHANGELOG="CHANGELOG.md" + if [ ! -f "$CHANGELOG" ]; then + echo "Error: CHANGELOG.md not found" + exit 1 + fi + NORMALIZED_CHANGELOG="$(mktemp)" + perl -pe 's/\x{FF08}/(/g; s/\x{FF09}/)/g; s/\r$//' "$CHANGELOG" > "$NORMALIZED_CHANGELOG" + HEADER_REGEX="^##[[:space:]]+v${VERSION}[[:space:]]*\\([0-9]{4}-[0-9]{2}-[0-9]{2}\\)[[:space:]]*$" + if ! grep -Eq "$HEADER_REGEX" "$NORMALIZED_CHANGELOG"; then + echo "Error: Changelog entry not found for v${VERSION}" + exit 1 + fi + awk -v ver="v${VERSION}" ' + $0 ~ "^##[[:space:]]+" ver "[[:space:]]*\\(" { in_section = 1 } + in_section && $0 ~ "^##[[:space:]]+" && $0 !~ "^##[[:space:]]+" ver "[[:space:]]*\\(" { exit } + in_section { print } + ' "$NORMALIZED_CHANGELOG" > release_notes.md + if [ ! -s release_notes.md ]; then + echo "Error: Release notes are empty for v${VERSION}" exit 1 fi + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + - name: Prepare release assets run: | mkdir -p release_assets @@ -49,33 +345,67 @@ jobs: cp artifacts/deepchat-win-x64/*.exe release_assets/ 2>/dev/null || true cp artifacts/deepchat-win-x64/*.msi release_assets/ 2>/dev/null || true cp artifacts/deepchat-win-x64/*.zip release_assets/ 2>/dev/null || true + cp artifacts/deepchat-win-x64/*.yml release_assets/ 2>/dev/null || true + cp artifacts/deepchat-win-x64/*.blockmap release_assets/ 2>/dev/null || true fi - # Process Windows arm64 artifacts - #if [ -d "artifacts/deepchat-win-arm64" ]; then - # cp artifacts/deepchat-win-arm64/*.exe release_assets/ 2>/dev/null || true - # cp artifacts/deepchat-win-arm64/*.msi release_assets/ 2>/dev/null || true - # cp artifacts/deepchat-win-arm64/*.zip release_assets/ 2>/dev/null || true - #fi - # Process Linux x64 artifacts if [ -d "artifacts/deepchat-linux-x64" ]; then cp artifacts/deepchat-linux-x64/*.AppImage release_assets/ 2>/dev/null || true cp artifacts/deepchat-linux-x64/*.deb release_assets/ 2>/dev/null || true cp artifacts/deepchat-linux-x64/*.rpm release_assets/ 2>/dev/null || true cp artifacts/deepchat-linux-x64/*.tar.gz release_assets/ 2>/dev/null || true + cp artifacts/deepchat-linux-x64/*.yml release_assets/ 2>/dev/null || true + cp artifacts/deepchat-linux-x64/*.blockmap release_assets/ 2>/dev/null || true fi # Process Mac x64 artifacts if [ -d "artifacts/deepchat-mac-x64" ]; then cp artifacts/deepchat-mac-x64/*.dmg release_assets/ 2>/dev/null || true cp artifacts/deepchat-mac-x64/*.zip release_assets/ 2>/dev/null || true + cp artifacts/deepchat-mac-x64/*.blockmap release_assets/ 2>/dev/null || true fi # Process Mac arm64 artifacts if [ -d "artifacts/deepchat-mac-arm64" ]; then cp artifacts/deepchat-mac-arm64/*.dmg release_assets/ 2>/dev/null || true cp artifacts/deepchat-mac-arm64/*.zip release_assets/ 2>/dev/null || true + cp artifacts/deepchat-mac-arm64/*.blockmap release_assets/ 2>/dev/null || true + fi + + merge_mac_yml() { + local name="$1" + local x64="artifacts/deepchat-mac-x64/$name" + local arm64="artifacts/deepchat-mac-arm64/$name" + if [ -f "$x64" ] && [ -f "$arm64" ]; then + ruby -ryaml -e ' + x64 = YAML.load_file(ARGV[0]) || {} + arm = YAML.load_file(ARGV[1]) || {} + merged = x64.dup + merged["version"] ||= arm["version"] + merged["releaseDate"] ||= arm["releaseDate"] + merged["releaseNotes"] ||= arm["releaseNotes"] + merged["path"] ||= arm["path"] + merged["sha512"] ||= arm["sha512"] + files = [] + files.concat(x64["files"]) if x64["files"].is_a?(Array) + files.concat(arm["files"]) if arm["files"].is_a?(Array) + merged["files"] = files.uniq { |f| f["url"] } + File.write(ARGV[2], merged.to_yaml) + ' "$x64" "$arm64" "release_assets/$name" + elif [ -f "$x64" ]; then + cp "$x64" "release_assets/$name" + elif [ -f "$arm64" ]; then + cp "$arm64" "release_assets/$name" + fi + } + + merge_mac_yml latest-mac.yml + merge_mac_yml beta-mac.yml + + if [ -z "$(ls -A release_assets)" ]; then + echo "Error: No release assets found" + exit 1 fi ls -la release_assets/ @@ -83,51 +413,12 @@ jobs: - name: Create Draft Release uses: softprops/action-gh-release@v1 with: - tag_name: v${{ steps.get_version.outputs.version }} + tag_name: ${{ needs.resolve-tag.outputs.tag }} name: DeepChat V${{ steps.get_version.outputs.version }} draft: true - prerelease: ${{ github.event.inputs.prerelease }} + prerelease: ${{ steps.get_version.outputs.prerelease == 'true' }} files: | release_assets/* - body: | - # 🚀 DeepChat ${{ steps.get_version.outputs.version }} 正式发布 | 重新定义你的 AI 对话体验! - —— 不再是简单的 ChatBot,而是你的自然语言 Agent 工具🌟 - - 🔥 为什么选择 DeepChat? - - ✅ **商业友好**:基于原版 [Apache License 2.0](https://github.com/ThinkInAIXYZ/deepchat/blob/main/LICENSE) 开源,无任何协议外的额外约束,面向开源。 - ✅ **开箱即用**:极简配置,即刻开启你的智能对话之旅。 - ✅ **极致灵活**:自由切换模型,自定义模型源,满足你多样化的对话和探索需求。 - ✅ **体验绝佳**:LaTeX 公式渲染、代码高亮、Markdown 支持,模型对话从未如此顺畅。 - ✅ **持续进化**:我们倾听用户反馈,不断迭代更新,为你带来更卓越的 AI 对话体验。 - - 📥 立即体验未来 - - 💬 反馈有礼:欢迎提交你的宝贵建议,加入 VIP 用户社群,与我们一同塑造 DeepChat 的未来! - - - 🎮 加入 Discord 社区:[https://discord.gg/6RBatENX](https://discord.gg/6RBatENX) - - --- - - # 🚀 DeepChat ${{ steps.get_version.outputs.version }} Official Release | Redefine Your AI Conversation Experience! - —— Not just a simple ChatBot, but your natural language Agent tool 🌟 - - 🔥 Why Choose DeepChat? - - ✅ **Business-Friendly**: Open source under [Apache License 2.0](https://github.com/ThinkInAIXYZ/deepchat/blob/main/LICENSE), with no additional constraints beyond the license, truly open source. - ✅ **Ready to Use**: Minimal configuration, start your intelligent conversation journey immediately. - ✅ **Ultra Flexible**: Freely switch models, customize model sources, meet your diverse conversation and exploration needs. - ✅ **Excellent Experience**: LaTeX formula rendering, code highlighting, Markdown support, model conversations have never been smoother. - ✅ **Continuous Evolution**: We listen to user feedback, continuously iterate and update, bringing you an even better AI conversation experience. - - 📥 Experience the Future Now - - 💬 Feedback Welcome: We welcome your valuable suggestions, join the user community, and shape the future of DeepChat together! - - - - 🎮 Join Discord Community: [https://discord.gg/6RBatENX](https://discord.gg/6RBatENX) - + body_path: release_notes.md env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..cc73e0d7c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +## v0.5.6-beta.1 (2025-12-23) +- Markdown 优化,修复列表元素异常 +- 修复 Ollama 视觉模型图片格式 +- Improved Markdown rendering, fixed list element issues +- Fixed Ollama vision model image format + +## v0.5.5 (2025-12-19) +- 全新 Yo Browser 功能,让你的模型畅游网络 +- All-new Yo Browser lets your model roam the web + +## v0.5.3 (2025-12-13) +- 优化 ACP 体验,增加 ACP 调试能力 +- 增加了自定义软件字体能力 +- add acp process warmup and debug panel +- add font settings +- add Hebrew (he-IL) Translation diff --git a/docs/mcp-store-colada-integration.md b/docs/mcp-store-colada-integration.md index a1719f616..3206085fe 100644 --- a/docs/mcp-store-colada-integration.md +++ b/docs/mcp-store-colada-integration.md @@ -664,11 +664,13 @@ const applyToolsSnapshot = (toolDefs: MCPToolDefinition[] = []) => { } // 特殊工具的默认值 - if (tool.function.name === 'search_files') { + if (tool.function.name === 'glob_search') { toolInputs.value[tool.function.name] = { - path: '', - regex: '\\\\.md$', - file_pattern: '*.md' + pattern: '**/*.md', + root: '', + excludePatterns: '', + maxResults: '1000', + sortBy: 'name' } } } @@ -2663,4 +2665,3 @@ const memoryMonitor = { ## 六、迁移指南 > 本节内容将由 Claude Code 完成(如果需要) - diff --git a/docs/rebrand-guide.md b/docs/rebrand-guide.md index eb9f1bc2d..6ac09de52 100644 --- a/docs/rebrand-guide.md +++ b/docs/rebrand-guide.md @@ -86,7 +86,6 @@ This script will automatically replace brand information in the following files: - `package.json` - Package configuration - `electron-builder.yml` - Build configuration -- `electron-builder-macx64.yml` - macOS x64 build configuration - `src/main/index.ts` - Main process configuration - `src/main/presenter/upgradePresenter/index.ts` - Update service configuration - `src/renderer/src/i18n/*/about.json` - Internationalization files @@ -343,7 +342,6 @@ node scripts/rebrand.js - `package.json` - 包配置 - `electron-builder.yml` - 构建配置 -- `electron-builder-macx64.yml` - macOS x64 构建配置 - `src/main/index.ts` - 主进程配置 - `src/main/presenter/upgradePresenter/index.ts` - 更新服务配置 - `src/renderer/src/i18n/*/about.json` - 国际化文件 diff --git a/docs/workspace-agent-refactoring-summary.md b/docs/workspace-agent-refactoring-summary.md index ccac911a5..951d0b800 100644 --- a/docs/workspace-agent-refactoring-summary.md +++ b/docs/workspace-agent-refactoring-summary.md @@ -82,7 +82,7 @@ graph TB **功能**: - 内置文件工具:`read_file`, `write_file`, `list_directory`, `create_directory`, `move_files`, - `edit_text`, `search_files`, `grep_search`, `text_replace`, `directory_tree`, `get_file_info` + `edit_text`, `glob_search`, `grep_search`, `text_replace`, `directory_tree`, `get_file_info` - 强制路径白名单 + `realpath` 校验,阻断越界与 symlink 绕过 - 正则工具使用 `validateRegexPattern` 防 ReDoS;`text_replace`/`edit_text` 支持 diff - 工具以 `agent-filesystem` server 标识返回 diff --git a/electron-builder-macx64.yml b/electron-builder-macx64.yml deleted file mode 100644 index beb6a7b84..000000000 --- a/electron-builder-macx64.yml +++ /dev/null @@ -1,83 +0,0 @@ -appId: com.wefonk.deepchat -productName: DeepChat -directories: - buildResources: build -files: - - '!**/.claude/*' - - '!**/.github/*' - - '!**/.cursor/*' - - '!**/.vscode/*' - - '!src/*' - - '!test/*' - - '!docs/*' - - '!electron.vite.config.{js,ts,mjs,cjs}' - - '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}' - - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}' - - '!{tsconfig.json,tsconfig.node.json,tsconfig.app.json}' - - '!keys/*' - - '!scripts/*' - - '!.github/*' - - '!electron-builder.yml' - - '!electron-builder-macx64.yml' - - '!test/*' - - '!*.config.ts' - - '!*.config.js' - - '!**/{LICENSE,LICENSE.txt,*.LICENSE.txt,NOTICE.txt,README.md,CHANGELOG.md,CONTRIBUTING.md,CONTRIBUTING.zh.md,README.zh.md,README.jp.md}' - - '!**/{.DS_Store,Thumbs.db}' - - '!*.md' -asarUnpack: - - '**/node_modules/sharp/**/*' - - '**/node_modules/@img/**/*' -extraResources: - - from: ./runtime/ - to: app.asar.unpacked/runtime - filter: ['**/*'] - - from: ./resources/cdn/ - to: app.asar.unpacked/resources/cdn - filter: ['**/*'] -afterSign: scripts/notarize.js -afterPack: scripts/afterPack.js -electronLanguages: - - zh-CN - - zh-TW - - zh-HK - - en-US - - ja-JP - - ko-KR - - fr-FR - - ru-RU - - ja - - ru - - zh_CN - - zh_TW - - zh_HK - - en - - ko - - fr - - fa-IR - - fa - - pt-BR - - pt - - da-DK - - da - - he-IL - - he -mac: - entitlementsInherit: build/entitlements.mac.plist - extendInfo: - - NSCameraUsageDescription: Application requests access to the device's camera. - - NSMicrophoneUsageDescription: Application requests access to the device's microphone. - - NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder. - - NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder. - gatekeeperAssess: false - category: public.app-category.utilities - target: - - target: dmg - arch: x64 - - target: zip - arch: x64 - artifactName: ${name}-${version}-mac-${arch}.${ext} -npmRebuild: true -publish: - provider: generic - url: https://cdn.deepchatai.cn/upgrade/ diff --git a/electron-builder.yml b/electron-builder.yml index 93c31acbb..3b2ace778 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -18,7 +18,6 @@ files: - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}' - '!{tsconfig.json,tsconfig.node.json,tsconfig.app.json}' - '!electron-builder.yml' - - '!electron-builder-macx64.yml' - '!test/*' - '!*.config.ts' - '!*.config.js' @@ -84,9 +83,7 @@ mac: category: public.app-category.utilities target: - target: dmg - arch: arm64 - target: zip - arch: arm64 artifactName: ${name}-${version}-mac-${arch}.${ext} linux: target: @@ -99,5 +96,6 @@ linux: - x-scheme-handler/deepchat npmRebuild: true publish: - provider: generic - url: https://cdn.deepchatai.cn/upgrade/ + provider: github + owner: ThinkInAIXYZ + repo: deepchat diff --git a/package.json b/package.json index e1bbee87f..9f4fd28b5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DeepChat", - "version": "0.5.5", + "version": "0.5.6-beta.1", "description": "DeepChat,一个简单易用的AI客户端", "main": "./out/main/index.js", "author": "ThinkInAIXYZ", @@ -38,18 +38,18 @@ "install:sharp": "node scripts/install-sharp-for-platform.js", "build:mac": "pnpm run build && electron-builder --mac", "build:mac:arm64": "pnpm run build && electron-builder --mac --arm64", - "build:mac:x64": "pnpm run build && electron-builder -c electron-builder-macx64.yml --mac --x64 ", + "build:mac:x64": "pnpm run build && electron-builder --mac --x64", "build:linux": "pnpm run build && electron-builder --linux", "build:linux:x64": "pnpm run build && electron-builder --linux --x64", "build:linux:arm64": "pnpm run build && electron-builder --linux --arm64", "afterSign": "scripts/notarize.js", - "installRuntime": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.8.8 && npx -y tiny-runtime-injector --type node --dir ./runtime/node", - "installRuntime:win:x64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.8.8 -a x64 -p win32 && npx -y tiny-runtime-injector --type node --dir ./runtime/node -a x64 -p win32", - "installRuntime:win:arm64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.8.8 -a arm64 -p win32 && npx -y tiny-runtime-injector --type node --dir ./runtime/node -a arm64 -p win32", - "installRuntime:mac:arm64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.8.8 -a arm64 -p darwin && npx -y tiny-runtime-injector --type node --dir ./runtime/node -a arm64 -p darwin", - "installRuntime:mac:x64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.8.8 -a x64 -p darwin && npx -y tiny-runtime-injector --type node --dir ./runtime/node -a x64 -p darwin", - "installRuntime:linux:x64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.8.8 -a x64 -p linux && npx -y tiny-runtime-injector --type node --dir ./runtime/node -a x64 -p linux", - "installRuntime:linux:arm64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.8.8 -a arm64 -p linux && npx -y tiny-runtime-injector --type node --dir ./runtime/node -a arm64 -p linux", + "installRuntime": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.9.18 && npx -y tiny-runtime-injector --type node --dir ./runtime/node && npx -y tiny-runtime-injector --type ripgrep --dir ./runtime/ripgrep", + "installRuntime:win:x64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.9.18 -a x64 -p win32 && npx -y tiny-runtime-injector --type node --dir ./runtime/node -a x64 -p win32 && npx -y tiny-runtime-injector --type ripgrep --dir ./runtime/ripgrep -a x64 -p win32", + "installRuntime:win:arm64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.9.18 -a arm64 -p win32 && npx -y tiny-runtime-injector --type node --dir ./runtime/node -a arm64 -p win32 && npx -y tiny-runtime-injector --type ripgrep --dir ./runtime/ripgrep -a arm64 -p win32", + "installRuntime:mac:arm64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.9.18 -a arm64 -p darwin && npx -y tiny-runtime-injector --type node --dir ./runtime/node -a arm64 -p darwin && npx -y tiny-runtime-injector --type ripgrep --dir ./runtime/ripgrep -a arm64 -p darwin", + "installRuntime:mac:x64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.9.18 -a x64 -p darwin && npx -y tiny-runtime-injector --type node --dir ./runtime/node -a x64 -p darwin && npx -y tiny-runtime-injector --type ripgrep --dir ./runtime/ripgrep -a x64 -p darwin", + "installRuntime:linux:x64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.9.18 -a x64 -p linux && npx -y tiny-runtime-injector --type node --dir ./runtime/node -a x64 -p linux && npx -y tiny-runtime-injector --type ripgrep --dir ./runtime/ripgrep -a x64 -p linux", + "installRuntime:linux:arm64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.9.18 -a arm64 -p linux && npx -y tiny-runtime-injector --type node --dir ./runtime/node -a arm64 -p linux && npx -y tiny-runtime-injector --type ripgrep --dir ./runtime/ripgrep -a arm64 -p linux", "installRuntime:duckdb:vss": "node scripts/installVss.js", "i18n": "i18n-check -s zh-CN -f i18next --locales src/renderer/src/i18n", "i18n:en": "i18n-check -s en-US -f i18next --locales src/renderer/src/i18n", diff --git a/scripts/rebrand.js b/scripts/rebrand.js index 797ce4984..1e1731c31 100644 --- a/scripts/rebrand.js +++ b/scripts/rebrand.js @@ -122,34 +122,6 @@ function updateElectronBuilder(config) { } } -// 更新 electron-builder-macx64.yml -function updateElectronBuilderMacX64(config) { - const builderPath = path.join(PROJECT_ROOT, 'electron-builder-macx64.yml') - - if (!fs.existsSync(builderPath)) { - return // 文件不存在则跳过 - } - - try { - let content = fs.readFileSync(builderPath, 'utf8') - - // 替换 appId - content = content.replace(/appId: .+/, `appId: ${config.app.appId}`) - - // 替换 productName - content = content.replace(/productName: .+/, `productName: ${config.app.productName}`) - - // 替换 publish URL - if (config.update && config.update.baseUrl) { - content = content.replace(/url: https:\/\/cdn\.deepchatai\.cn\/upgrade\//, `url: ${config.update.baseUrl}`) - } - - fs.writeFileSync(builderPath, content, 'utf8') - success('已更新 electron-builder-macx64.yml') - } catch (err) { - error(`更新 electron-builder-macx64.yml 失败: ${err.message}`) - } -} // 更新主进程中的 app user model ID function updateMainIndex(config) { @@ -480,7 +452,6 @@ function main() { // 执行替换 updatePackageJson(config) updateElectronBuilder(config) - updateElectronBuilderMacX64(config) updateMainIndex(config) updateUpgradePresenter(config) updateI18nFiles(config) diff --git a/src/main/lib/runtimeHelper.ts b/src/main/lib/runtimeHelper.ts index 6ac255731..16906321f 100644 --- a/src/main/lib/runtimeHelper.ts +++ b/src/main/lib/runtimeHelper.ts @@ -10,6 +10,7 @@ export class RuntimeHelper { private static instance: RuntimeHelper | null = null private nodeRuntimePath: string | null = null private uvRuntimePath: string | null = null + private ripgrepRuntimePath: string | null = null private runtimesInitialized: boolean = false private constructor() { @@ -28,7 +29,7 @@ export class RuntimeHelper { /** * Initialize runtime paths (idempotent operation) - * Caches Node.js and UV runtime paths to avoid repeated filesystem checks + * Caches Node.js, UV and Ripgrep runtime paths to avoid repeated filesystem checks */ public initializeRuntimes(): void { if (this.runtimesInitialized) { @@ -77,6 +78,24 @@ export class RuntimeHelper { } } + // Check if ripgrep runtime file exists + const ripgrepRuntimePath = path.join(runtimeBasePath, 'ripgrep') + if (process.platform === 'win32') { + const rgExe = path.join(ripgrepRuntimePath, 'rg.exe') + if (fs.existsSync(rgExe)) { + this.ripgrepRuntimePath = ripgrepRuntimePath + } else { + this.ripgrepRuntimePath = null + } + } else { + const rgBin = path.join(ripgrepRuntimePath, 'rg') + if (fs.existsSync(rgBin)) { + this.ripgrepRuntimePath = ripgrepRuntimePath + } else { + this.ripgrepRuntimePath = null + } + } + this.runtimesInitialized = true } @@ -96,6 +115,14 @@ export class RuntimeHelper { return this.uvRuntimePath } + /** + * Get Ripgrep runtime path + * @returns Ripgrep runtime path or null if not found + */ + public getRipgrepRuntimePath(): string | null { + return this.ripgrepRuntimePath + } + /** * Replace command with runtime version if needed * @param command Original command @@ -234,6 +261,35 @@ export class RuntimeHelper { } } + // Ripgrep command handling (all platforms) + if (basename === 'rg') { + if (!this.ripgrepRuntimePath) { + return command + } + + if (process.platform === 'win32') { + const rgPath = path.join(this.ripgrepRuntimePath, 'rg.exe') + if (checkExists) { + if (fs.existsSync(rgPath)) { + return rgPath + } + return command + } else { + return rgPath + } + } else { + const rgPath = path.join(this.ripgrepRuntimePath, 'rg') + if (checkExists) { + if (fs.existsSync(rgPath)) { + return rgPath + } + return command + } else { + return rgPath + } + } + } + return command } diff --git a/src/main/presenter/acpWorkspacePresenter/index.ts b/src/main/presenter/acpWorkspacePresenter/index.ts deleted file mode 100644 index fee2b4df3..000000000 --- a/src/main/presenter/acpWorkspacePresenter/index.ts +++ /dev/null @@ -1,153 +0,0 @@ -import path from 'path' -import { shell } from 'electron' -import { eventBus, SendTarget } from '@/eventbus' -import { WORKSPACE_EVENTS } from '@/events' -import { readDirectoryShallow } from './directoryReader' -import { PlanStateManager } from './planStateManager' -import type { - IAcpWorkspacePresenter, - AcpFileNode, - AcpPlanEntry, - AcpTerminalSnippet, - AcpRawPlanEntry -} from '@shared/presenter' - -export class AcpWorkspacePresenter implements IAcpWorkspacePresenter { - private readonly planManager = new PlanStateManager() - // Allowed workdir paths (registered by ACP sessions) - private readonly allowedWorkdirs = new Set() - - /** - * Register a workdir as allowed for reading - * Returns Promise to ensure IPC call completion - */ - async registerWorkdir(workdir: string): Promise { - const normalized = path.resolve(workdir) - this.allowedWorkdirs.add(normalized) - } - - /** - * Unregister a workdir - */ - async unregisterWorkdir(workdir: string): Promise { - const normalized = path.resolve(workdir) - this.allowedWorkdirs.delete(normalized) - } - - /** - * Check if a path is within allowed workdirs - */ - private isPathAllowed(targetPath: string): boolean { - const normalized = path.resolve(targetPath) - for (const workdir of this.allowedWorkdirs) { - // Check if targetPath is equal to or under the workdir - if (normalized === workdir || normalized.startsWith(workdir + path.sep)) { - return true - } - } - return false - } - - /** - * Read directory (shallow, only first level) - * Use expandDirectory to load subdirectory contents - */ - async readDirectory(dirPath: string): Promise { - // Security check: only allow reading within registered workdirs - if (!this.isPathAllowed(dirPath)) { - console.warn(`[AcpWorkspace] Blocked read attempt for unauthorized path: ${dirPath}`) - return [] - } - return readDirectoryShallow(dirPath) - } - - /** - * Expand a directory to load its children (lazy loading) - * @param dirPath Directory path to expand - */ - async expandDirectory(dirPath: string): Promise { - // Security check: only allow reading within registered workdirs - if (!this.isPathAllowed(dirPath)) { - console.warn(`[AcpWorkspace] Blocked expand attempt for unauthorized path: ${dirPath}`) - return [] - } - return readDirectoryShallow(dirPath) - } - - /** - * Reveal a file or directory in the system file manager - */ - async revealFileInFolder(filePath: string): Promise { - // Security check: only allow revealing within registered workdirs - if (!this.isPathAllowed(filePath)) { - console.warn(`[AcpWorkspace] Blocked reveal attempt for unauthorized path: ${filePath}`) - return - } - - const normalizedPath = path.resolve(filePath) - - try { - shell.showItemInFolder(normalizedPath) - } catch (error) { - console.error(`[AcpWorkspace] Failed to reveal path: ${normalizedPath}`, error) - } - } - - /** - * Open a file or directory with the system default application - */ - async openFile(filePath: string): Promise { - if (!this.isPathAllowed(filePath)) { - console.warn(`[AcpWorkspace] Blocked open attempt for unauthorized path: ${filePath}`) - return - } - - const normalizedPath = path.resolve(filePath) - - try { - const errorMessage = await shell.openPath(normalizedPath) - if (errorMessage) { - console.error(`[AcpWorkspace] Failed to open path: ${normalizedPath}`, errorMessage) - } - } catch (error) { - console.error(`[AcpWorkspace] Failed to open path: ${normalizedPath}`, error) - } - } - - /** - * Get plan entries - */ - async getPlanEntries(conversationId: string): Promise { - return this.planManager.getEntries(conversationId) - } - - /** - * Update plan entries (called by acpContentMapper) - */ - async updatePlanEntries(conversationId: string, entries: AcpRawPlanEntry[]): Promise { - const updated = this.planManager.updateEntries(conversationId, entries) - - // Send event to renderer - eventBus.sendToRenderer(WORKSPACE_EVENTS.PLAN_UPDATED, SendTarget.ALL_WINDOWS, { - conversationId, - entries: updated - }) - } - - /** - * Emit terminal output snippet (called by acpContentMapper) - */ - async emitTerminalSnippet(conversationId: string, snippet: AcpTerminalSnippet): Promise { - eventBus.sendToRenderer(WORKSPACE_EVENTS.TERMINAL_OUTPUT, SendTarget.ALL_WINDOWS, { - conversationId, - snippet - }) - } - - /** - * Clear workspace data for a conversation - */ - async clearWorkspaceData(conversationId: string): Promise { - this.planManager.clear(conversationId) - } -} diff --git a/src/main/presenter/configPresenter/index.ts b/src/main/presenter/configPresenter/index.ts index 86c03b1b6..25b329a98 100644 --- a/src/main/presenter/configPresenter/index.ts +++ b/src/main/presenter/configPresenter/index.ts @@ -73,7 +73,7 @@ interface IAppSettings { floatingButtonEnabled?: boolean // Whether floating button is enabled default_system_prompt?: string // Default system prompt webContentLengthLimit?: number // Web content truncation length limit, default 3000 characters - updateChannel?: string // Update channel: 'stable' | 'canary' + updateChannel?: string // Update channel: 'stable' | 'beta' fontFamily?: string // Custom UI font codeFontFamily?: string // Custom code font [key: string]: unknown // Allow arbitrary keys, using unknown type instead of any @@ -1475,7 +1475,12 @@ export class ConfigPresenter implements IConfigPresenter { // 获取更新渠道 getUpdateChannel(): string { - return this.getSetting('updateChannel') || 'stable' + const raw = this.getSetting('updateChannel') || 'stable' + const channel = raw === 'stable' || raw === 'beta' ? raw : 'beta' + if (channel !== raw) { + this.setSetting('updateChannel', channel) + } + return channel } // 设置更新渠道 diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index 4f4a898e0..739173499 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -23,7 +23,6 @@ import { IThreadPresenter, IUpgradePresenter, IWindowPresenter, - IAcpWorkspacePresenter, IWorkspacePresenter, IToolPresenter, IYoBrowserPresenter @@ -45,7 +44,6 @@ import { FloatingButtonPresenter } from './floatingButtonPresenter' import { YoBrowserPresenter } from './browser/YoBrowserPresenter' import { CONFIG_EVENTS, WINDOW_EVENTS } from '@/events' import { KnowledgePresenter } from './knowledgePresenter' -import { AcpWorkspacePresenter } from './acpWorkspacePresenter' import { WorkspacePresenter } from './workspacePresenter' import { ToolPresenter } from './toolPresenter' @@ -85,7 +83,6 @@ export class Presenter implements IPresenter { oauthPresenter: OAuthPresenter floatingButtonPresenter: FloatingButtonPresenter knowledgePresenter: IKnowledgePresenter - acpWorkspacePresenter: IAcpWorkspacePresenter workspacePresenter: IWorkspacePresenter toolPresenter: IToolPresenter yoBrowserPresenter: IYoBrowserPresenter @@ -132,9 +129,6 @@ export class Presenter implements IPresenter { this.filePresenter ) - // Initialize ACP Workspace presenter (legacy, kept for backward compatibility) - this.acpWorkspacePresenter = new AcpWorkspacePresenter() - // Initialize generic Workspace presenter (for all Agent modes) this.workspacePresenter = new WorkspacePresenter() diff --git a/src/main/presenter/llmProviderPresenter/agent/agentFileSystemHandler.ts b/src/main/presenter/llmProviderPresenter/agent/agentFileSystemHandler.ts index 6e27877b3..2d5076be0 100644 --- a/src/main/presenter/llmProviderPresenter/agent/agentFileSystemHandler.ts +++ b/src/main/presenter/llmProviderPresenter/agent/agentFileSystemHandler.ts @@ -4,7 +4,11 @@ import os from 'os' import { z } from 'zod' import { minimatch } from 'minimatch' import { createTwoFilesPatch } from 'diff' -import { validateRegexPattern } from '@shared/regexValidator' +import logger from '@shared/logger' +import { validateGlobPattern, validateRegexPattern } from '@shared/regexValidator' +import { spawn } from 'child_process' +import { RuntimeHelper } from '../../../lib/runtimeHelper' +import { glob } from 'glob' const ReadFileArgsSchema = z.object({ paths: z.array(z.string()).min(1).describe('Array of file paths to read') @@ -48,13 +52,19 @@ const EditTextArgsSchema = z.object({ dryRun: z.boolean().default(false) }) -const FileSearchArgsSchema = z.object({ - path: z.string().optional(), - pattern: z.string(), - searchType: z.enum(['glob', 'name']).default('glob'), - excludePatterns: z.array(z.string()).optional().default([]), - caseSensitive: z.boolean().default(false), - maxResults: z.number().default(1000) +const GlobSearchArgsSchema = z.object({ + pattern: z.string().describe('Glob pattern (e.g., **/*.ts, src/**/*.js)'), + root: z.string().optional().describe('Root directory for search (defaults to workspace root)'), + excludePatterns: z + .array(z.string()) + .optional() + .default([]) + .describe('Patterns to exclude (e.g., ["node_modules", ".git"])'), + maxResults: z.number().default(1000).describe('Maximum number of results to return'), + sortBy: z + .enum(['name', 'modified']) + .default('name') + .describe('Sort results by name or modification time') }) const GrepSearchArgsSchema = z.object({ @@ -112,6 +122,13 @@ interface TreeEntry { children?: TreeEntry[] } +interface GlobMatch { + path: string + name: string + modified?: Date + size?: number +} + export class AgentFileSystemHandler { private allowedDirectories: string[] @@ -223,7 +240,65 @@ export class AgentFileSystemHandler { } = {} ): Promise { const { - filePattern = '*', + filePattern, + recursive = true, + caseSensitive = false, + includeLineNumbers = true, + contextLines = 0, + maxResults = 100 + } = options + + // Validate pattern for ReDoS safety + validateRegexPattern(pattern) + + // Try to use ripgrep if available + const runtimeHelper = RuntimeHelper.getInstance() + runtimeHelper.initializeRuntimes() + const ripgrepPath = runtimeHelper.getRipgrepRuntimePath() + + if (ripgrepPath) { + try { + return await this.runRipgrepSearch(rootPath, pattern, { + filePattern, + recursive, + caseSensitive, + includeLineNumbers, + contextLines, + maxResults + }) + } catch (error) { + // Fall back to JavaScript implementation if ripgrep fails + logger.warn('[AgentFileSystemHandler] Ripgrep search failed, falling back to JS', { + error + }) + } + } + + // Fallback to JavaScript implementation + return this.runJavaScriptGrepSearch(rootPath, pattern, { + filePattern: filePattern || '*', + recursive, + caseSensitive, + includeLineNumbers, + contextLines, + maxResults + }) + } + + private async runRipgrepSearch( + rootPath: string, + pattern: string, + options: { + filePattern?: string + recursive?: boolean + caseSensitive?: boolean + includeLineNumbers?: boolean + contextLines?: number + maxResults?: number + } + ): Promise { + const { + filePattern, recursive = true, caseSensitive = false, includeLineNumbers = true, @@ -237,8 +312,160 @@ export class AgentFileSystemHandler { matches: [] } - // Validate pattern for ReDoS safety before constructing RegExp - validateRegexPattern(pattern) + const runtimeHelper = RuntimeHelper.getInstance() + const ripgrepPath = runtimeHelper.getRipgrepRuntimePath() + if (!ripgrepPath) { + throw new Error('Ripgrep runtime path not found') + } + + const rgExecutable = + process.platform === 'win32' ? path.join(ripgrepPath, 'rg.exe') : path.join(ripgrepPath, 'rg') + + // Build ripgrep arguments + const args: string[] = [] + + // Search pattern + args.push('-e', pattern) + + // Case sensitivity + if (caseSensitive) { + args.push('--case-sensitive') + } else { + args.push('-i') + } + + // Context lines + if (contextLines > 0) { + args.push(`-C${contextLines}`) + } + + // Max count + args.push('-m', String(maxResults)) + + // File pattern (glob) + if (filePattern) { + args.push('-g', filePattern) + } + + // Recursive (default for rg, but add --no-recursive if not wanted) + if (!recursive) { + args.push('--no-recursive') + } + + // Output format with line numbers + args.push('--with-filename') + args.push('--line-number') + args.push('--no-heading') + + // Search path + const validatedPath = await this.validatePath(rootPath) + args.push(validatedPath) + + return new Promise((resolve, reject) => { + const ripgrep = spawn(rgExecutable, args, { + stdio: ['ignore', 'pipe', 'pipe'] + }) + + let stdout = '' + let stderr = '' + let settled = false + const timeout = setTimeout(() => { + if (settled) return + settled = true + ripgrep.kill('SIGKILL') + reject(new Error('Ripgrep search timed out after 30000ms')) + }, 30_000) + + ripgrep.stdout.on('data', (data) => { + stdout += data.toString() + }) + + ripgrep.stderr.on('data', (data) => { + stderr += data.toString() + }) + + ripgrep.on('close', (code) => { + if (settled) return + settled = true + clearTimeout(timeout) + if (code === 0 || code === 1) { + // 0 = matches found, 1 = no matches (both are OK) + // Parse ripgrep output + const lines = stdout.split('\n').filter((line) => line.trim()) + const currentFileMatches = new Map() + const uniqueFiles = new Set() + + for (const line of lines) { + // Parse ripgrep output format: file:line:content + const lastColonIndex = line.lastIndexOf(':') + const lineNumberSeparator = line.lastIndexOf(':', lastColonIndex - 1) + if (lineNumberSeparator !== -1 && lastColonIndex !== -1) { + const file = line.slice(0, lineNumberSeparator) + const lineNum = line.slice(lineNumberSeparator + 1, lastColonIndex) + const content = line.slice(lastColonIndex + 1) + if (!/^\d+$/.test(lineNum)) { + continue + } + uniqueFiles.add(file) + + const grepMatch: GrepMatch = { + file, + line: includeLineNumbers ? parseInt(lineNum, 10) : 0, + content + } + + if (!currentFileMatches.has(file)) { + currentFileMatches.set(file, []) + } + currentFileMatches.get(file)!.push(grepMatch) + result.totalMatches++ + } + } + + result.files = Array.from(uniqueFiles) + result.matches = Array.from(currentFileMatches.values()).flat() + + resolve(result) + } else { + reject(new Error(`Ripgrep failed with code ${code}: ${stderr}`)) + } + }) + + ripgrep.on('error', (error) => { + if (settled) return + settled = true + clearTimeout(timeout) + reject(new Error(`Ripgrep spawn error: ${error.message}`)) + }) + }) + } + + private async runJavaScriptGrepSearch( + rootPath: string, + pattern: string, + options: { + filePattern?: string + recursive?: boolean + caseSensitive?: boolean + includeLineNumbers?: boolean + contextLines?: number + maxResults?: number + } + ): Promise { + const { + filePattern = '*', + recursive = true, + caseSensitive = false, + includeLineNumbers = true, + contextLines = 0, + maxResults = 100 + } = options + + const result: GrepResult = { + totalMatches: 0, + files: [], + matches: [] + } const regexFlags = caseSensitive ? 'g' : 'gi' let regex: RegExp @@ -630,44 +857,103 @@ export class AgentFileSystemHandler { .join('\n') } - async searchFiles(args: unknown): Promise { - const parsed = FileSearchArgsSchema.safeParse(args) + async globSearch(args: unknown): Promise { + const parsed = GlobSearchArgsSchema.safeParse(args) if (!parsed.success) { throw new Error(`Invalid arguments: ${parsed.error}`) } - const rootPath = parsed.data.path - ? await this.validatePath(parsed.data.path) - : this.allowedDirectories[0] - const results: string[] = [] - const search = async (currentPath: string) => { - const entries = await fs.readdir(currentPath, { withFileTypes: true }) - for (const entry of entries) { - const fullPath = path.join(currentPath, entry.name) - try { - await this.validatePath(fullPath) - const isMatch = - parsed.data.searchType === 'glob' - ? minimatch(entry.name, parsed.data.pattern, { - dot: true, - nocase: !parsed.data.caseSensitive - }) - : parsed.data.caseSensitive - ? entry.name.includes(parsed.data.pattern) - : entry.name.toLowerCase().includes(parsed.data.pattern.toLowerCase()) - if (isMatch) { - results.push(fullPath) + const { pattern, root, excludePatterns = [], maxResults = 1000, sortBy = 'name' } = parsed.data + validateGlobPattern(pattern) + + // Determine root directory + const searchRoot = root ? await this.validatePath(root) : this.allowedDirectories[0] + + // Default exclusions + const defaultExclusions = [ + '**/node_modules/**', + '**/.git/**', + '**/dist/**', + '**/build/**', + '**/.next/**' + ] + const allExclusions = [...defaultExclusions, ...excludePatterns] + + // Use glob library for fast file matching + const globOptions = { + cwd: searchRoot, + ignore: allExclusions, + absolute: true, + nodir: true, + maxResults: maxResults + 100 // Get extra results for filtering + } + + try { + const matches = await glob(pattern, globOptions) + + // Filter matches to ensure they're in allowed directories + const validMatches = await Promise.all( + matches.map(async (filePath) => { + try { + await this.validatePath(filePath) + return filePath + } catch { + return null } - if (entry.isDirectory()) { - await search(fullPath) + }) + ) + + const filteredMatches = validMatches.filter((match): match is string => match !== null) + + // Get file stats for sorting + const matchesWithStats: GlobMatch[] = await Promise.all( + filteredMatches.slice(0, maxResults).map(async (filePath) => { + try { + const stats = await fs.stat(filePath) + return { + path: filePath, + name: path.basename(filePath), + modified: stats.mtime, + size: stats.size + } + } catch { + return { + path: filePath, + name: path.basename(filePath) + } } - } catch { - continue - } + }) + ) + + // Sort results + if (sortBy === 'modified') { + matchesWithStats.sort((a, b) => { + const aTime = a.modified?.getTime() || 0 + const bTime = b.modified?.getTime() || 0 + return bTime - aTime // Descending (newest first) + }) + } else { + // Sort by name (default) + matchesWithStats.sort((a, b) => a.path.localeCompare(b.path)) } - } - await search(rootPath) - return results.slice(0, parsed.data.maxResults).join('\n') + // Format output + const formatted = matchesWithStats.map((match) => { + let output = match.path + if (match.modified !== undefined && sortBy === 'modified') { + output += ` (${match.modified.toISOString()})` + } + if (match.size !== undefined) { + output += ` [${match.size} bytes]` + } + return output + }) + + return `Found ${formatted.length} files matching pattern "${pattern}":\n\n${formatted.join('\n')}` + } catch (error) { + throw new Error( + `Glob search failed: ${error instanceof Error ? error.message : String(error)}` + ) + } } } diff --git a/src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts b/src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts index 43c566ab1..258438c36 100644 --- a/src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts +++ b/src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts @@ -60,13 +60,22 @@ export class AgentToolManager { .optional(), dryRun: z.boolean().default(false) }), - search_files: z.object({ - path: z.string().optional(), - pattern: z.string(), - searchType: z.enum(['glob', 'name']).default('glob'), - excludePatterns: z.array(z.string()).optional().default([]), - caseSensitive: z.boolean().default(false), - maxResults: z.number().default(1000) + glob_search: z.object({ + pattern: z.string().describe('Glob pattern (e.g., **/*.ts, src/**/*.js)'), + root: z + .string() + .optional() + .describe('Root directory for search (defaults to workspace root)'), + excludePatterns: z + .array(z.string()) + .optional() + .default([]) + .describe('Patterns to exclude (e.g., ["node_modules", ".git"])'), + maxResults: z.number().default(1000).describe('Maximum number of results to return'), + sortBy: z + .enum(['name', 'modified']) + .default('name') + .describe('Sort results by name or modification time') }), grep_search: z.object({ path: z.string(), @@ -288,9 +297,10 @@ export class AgentToolManager { { type: 'function', function: { - name: 'search_files', - description: 'Search for files matching a pattern', - parameters: zodToJsonSchema(schemas.search_files) as { + name: 'glob_search', + description: + 'Search for files using glob patterns (e.g., **/*.ts, src/**/*.js). Automatically excludes common directories like node_modules and .git.', + parameters: zodToJsonSchema(schemas.glob_search) as { type: string properties: Record required?: string[] @@ -383,7 +393,7 @@ export class AgentToolManager { 'create_directory', 'move_files', 'edit_text', - 'search_files', + 'glob_search', 'directory_tree', 'get_file_info', 'grep_search', @@ -425,8 +435,8 @@ export class AgentToolManager { return await this.fileSystemHandler.moveFiles(parsedArgs) case 'edit_text': return await this.fileSystemHandler.editText(parsedArgs) - case 'search_files': - return await this.fileSystemHandler.searchFiles(parsedArgs) + case 'glob_search': + return await this.fileSystemHandler.globSearch(parsedArgs) case 'directory_tree': return await this.fileSystemHandler.directoryTree(parsedArgs) case 'get_file_info': diff --git a/src/main/presenter/upgradePresenter/index.ts b/src/main/presenter/upgradePresenter/index.ts index 9d9584eb1..737dc5a0e 100644 --- a/src/main/presenter/upgradePresenter/index.ts +++ b/src/main/presenter/upgradePresenter/index.ts @@ -8,13 +8,22 @@ import { import { eventBus, SendTarget } from '@/eventbus' import { UPDATE_EVENTS, WINDOW_EVENTS } from '@/events' import electronUpdater from 'electron-updater' -import axios from 'axios' -import { compare } from 'compare-versions' +import type { UpdateInfo } from 'electron-updater' import fs from 'fs' import path from 'path' const { autoUpdater } = electronUpdater +const GITHUB_OWNER = 'ThinkInAIXYZ' +const GITHUB_REPO = 'deepchat' +const UPDATE_CHANNEL_STABLE = 'stable' +const UPDATE_CHANNEL_BETA = 'beta' + +type ReleaseNoteItem = { + version?: string | null + note?: string | null +} + // 版本信息接口 interface VersionInfo { version: string @@ -24,26 +33,41 @@ interface VersionInfo { downloadUrl: string } -// 获取平台和架构信息 -const getPlatformInfo = () => { - const platform = process.platform - const arch = process.arch - let platformString = '' - - if (platform === 'win32') { - platformString = arch === 'arm64' ? 'winarm' : 'winx64' - } else if (platform === 'darwin') { - platformString = arch === 'arm64' ? 'macarm' : 'macx64' - } else if (platform === 'linux') { - platformString = arch === 'arm64' ? 'linuxarm' : 'linuxx64' - } +const normalizeUpdateChannel = (channel?: string): 'stable' | 'beta' => { + return channel === UPDATE_CHANNEL_BETA ? UPDATE_CHANNEL_BETA : UPDATE_CHANNEL_STABLE +} - return platformString +const formatTagVersion = (version: string): string => { + return version.startsWith('v') ? version : `v${version}` } -// 获取版本检查的基础URL -const getVersionCheckBaseUrl = () => { - return 'https://cdn.deepchatai.cn' +const buildReleaseUrl = (version: string): string => { + return `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}/releases/tag/${formatTagVersion(version)}` +} + +const formatReleaseNotes = (notes?: string | ReleaseNoteItem[] | null): string => { + if (!notes) return '' + if (typeof notes === 'string') return notes + if (!Array.isArray(notes)) return String(notes) + const blocks = notes + .map((note) => { + const title = note.version ? `## ${note.version}` : '' + const body = note.note ?? '' + return [title, body].filter(Boolean).join('\n') + }) + .filter((entry) => entry.length > 0) + return blocks.join('\n\n') +} + +const toVersionInfo = (info: UpdateInfo): VersionInfo => { + const releaseUrl = buildReleaseUrl(info.version) + return { + version: info.version, + releaseDate: info.releaseDate || '', + releaseNotes: formatReleaseNotes(info.releaseNotes), + githubUrl: releaseUrl, + downloadUrl: releaseUrl + } } // 获取自动更新状态文件路径 @@ -57,8 +81,8 @@ export class UpgradePresenter implements IUpgradePresenter { private _progress: UpdateProgress | null = null private _error: string | null = null private _versionInfo: VersionInfo | null = null - private _baseUrl: string private _lastCheckTime: number = 0 // 上次检查更新的时间戳 + private _lastCheckType?: string private _updateMarkerPath: string private _previousUpdateFailed: boolean = false // 标记上次更新是否失败 private _configPresenter: IConfigPresenter // 配置presenter @@ -66,7 +90,6 @@ export class UpgradePresenter implements IUpgradePresenter { constructor(configPresenter: IConfigPresenter) { this._configPresenter = configPresenter - this._baseUrl = getVersionCheckBaseUrl() this._updateMarkerPath = getUpdateMarkerFilePath() // 配置自动更新 @@ -98,18 +121,33 @@ export class UpgradePresenter implements IUpgradePresenter { this._lock = false this._status = 'not-available' eventBus.sendToRenderer(UPDATE_EVENTS.STATUS_CHANGED, SendTarget.ALL_WINDOWS, { - status: this._status + status: this._status, + type: this._lastCheckType }) }) // 有可用更新 autoUpdater.on('update-available', (info) => { console.log('检测到新版本', info) - this._status = 'available' + this._versionInfo = toVersionInfo(info) - // 重要:这里不再使用info中的信息更新this._versionInfo - // 而是确保使用之前从versionUrl获取的原始信息 - console.log('使用已保存的版本信息:', this._versionInfo) + if (this._previousUpdateFailed) { + console.log('上次更新失败,本次不进行自动更新,改为手动更新') + this._status = 'error' + this._error = '自动更新可能不稳定,请手动下载更新' + eventBus.sendToRenderer(UPDATE_EVENTS.STATUS_CHANGED, SendTarget.ALL_WINDOWS, { + status: this._status, + error: this._error, + info: this._versionInfo + }) + return + } + + this._status = 'available' + eventBus.sendToRenderer(UPDATE_EVENTS.STATUS_CHANGED, SendTarget.ALL_WINDOWS, { + status: this._status, + info: this._versionInfo + }) // 检测到更新后自动开始下载 this.startDownloadUpdate() }) @@ -137,6 +175,10 @@ export class UpgradePresenter implements IUpgradePresenter { this._lock = false this._status = 'downloaded' + if (!this._versionInfo) { + this._versionInfo = toVersionInfo(info) + } + // 写入更新标记文件 this.writeUpdateMarker(this._versionInfo?.version || info.version) @@ -250,83 +292,17 @@ export class UpgradePresenter implements IUpgradePresenter { try { this._status = 'checking' + this._lastCheckType = type eventBus.sendToRenderer(UPDATE_EVENTS.STATUS_CHANGED, SendTarget.ALL_WINDOWS, { status: this._status }) - // 首先获取版本信息文件 - const platformString = getPlatformInfo() - const rawChannel = this._configPresenter.getUpdateChannel() - const updateChannel = rawChannel === 'canary' ? 'canary' : 'upgrade' // Sanitize channel - const randomId = Math.floor(Date.now() / 3600000) // Timestamp truncated to hour - const versionPath = updateChannel - const versionUrl = `${this._baseUrl}/${versionPath}/${platformString}.json?noCache=${randomId}` - console.log('versionUrl', versionUrl) - const response = await axios.get(versionUrl, { timeout: 60000 }) // Add network timeout - const remoteVersion = response.data - const currentVersion = app.getVersion() - - // 保存完整的远程版本信息到内存中,作为唯一的标准信息源 - this._versionInfo = { - version: remoteVersion.version, - releaseDate: remoteVersion.releaseDate, - releaseNotes: remoteVersion.releaseNotes, - githubUrl: remoteVersion.githubUrl, - downloadUrl: remoteVersion.downloadUrl - } - - console.log('cache versionInfo:', this._versionInfo) + const updateChannel = normalizeUpdateChannel(this._configPresenter.getUpdateChannel()) + autoUpdater.allowPrerelease = updateChannel === UPDATE_CHANNEL_BETA + autoUpdater.channel = updateChannel === UPDATE_CHANNEL_BETA ? UPDATE_CHANNEL_BETA : 'latest' - // 更新上次检查时间 + await autoUpdater.checkForUpdates() this._lastCheckTime = Date.now() - - // 比较版本号 - if (compare(remoteVersion.version, currentVersion, '>')) { - // 有新版本 - - // 如果上次更新失败,这次不再尝试自动更新,直接进入错误状态让用户手动更新 - if (this._previousUpdateFailed) { - console.log('上次更新失败,本次不进行自动更新,改为手动更新') - this._status = 'error' - this._error = '自动更新可能不稳定,请手动下载更新' - - eventBus.sendToRenderer(UPDATE_EVENTS.STATUS_CHANGED, SendTarget.ALL_WINDOWS, { - status: this._status, - error: this._error, - info: this._versionInfo - }) - return - } - - // 设置自动更新的URL - const autoUpdateUrl = - updateChannel === 'canary' - ? `${this._baseUrl}/canary/${platformString}` - : `${this._baseUrl}/upgrade/v${remoteVersion.version}/${platformString}` - console.log('设置自动更新URL:', autoUpdateUrl) - autoUpdater.setFeedURL(autoUpdateUrl) - - try { - // 使用electron-updater检查更新,但不自动下载 - await autoUpdater.checkForUpdates() - } catch (err) { - console.error('自动更新检查失败,回退到手动更新', err) - // 如果自动更新失败,回退到手动更新 - this._status = 'available' - - eventBus.sendToRenderer(UPDATE_EVENTS.STATUS_CHANGED, SendTarget.ALL_WINDOWS, { - status: this._status, - info: this._versionInfo // 使用已保存的版本信息 - }) - } - } else { - // 没有新版本 - this._status = 'not-available' - eventBus.sendToRenderer(UPDATE_EVENTS.STATUS_CHANGED, SendTarget.ALL_WINDOWS, { - status: this._status, - type - }) - } } catch (error: Error | unknown) { this._status = 'error' this._error = error instanceof Error ? error.message : String(error) @@ -355,13 +331,14 @@ export class UpgradePresenter implements IUpgradePresenter { } async goDownloadUpgrade(type: 'github' | 'netdisk'): Promise { + const fallbackUrl = `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}/releases` if (type === 'github') { - const url = this._versionInfo?.githubUrl + const url = this._versionInfo?.githubUrl || fallbackUrl if (url) { shell.openExternal(url) } } else if (type === 'netdisk') { - const url = this._versionInfo?.downloadUrl + const url = this._versionInfo?.downloadUrl || fallbackUrl if (url) { shell.openExternal(url) } diff --git a/src/main/presenter/workspacePresenter/concurrencyLimiter.ts b/src/main/presenter/workspacePresenter/concurrencyLimiter.ts new file mode 100644 index 000000000..d6456dfe9 --- /dev/null +++ b/src/main/presenter/workspacePresenter/concurrencyLimiter.ts @@ -0,0 +1,25 @@ +export class ConcurrencyLimiter { + private activeCount = 0 + private readonly queue: Array<() => void> = [] + + constructor(private readonly limit: number = 10) {} + + async run(task: () => Promise): Promise { + if (this.activeCount >= this.limit) { + await new Promise((resolve) => { + this.queue.push(resolve) + }) + } + + this.activeCount += 1 + try { + return await task() + } finally { + this.activeCount -= 1 + const next = this.queue.shift() + if (next) { + next() + } + } + } +} diff --git a/src/main/presenter/acpWorkspacePresenter/directoryReader.ts b/src/main/presenter/workspacePresenter/directoryReader.ts similarity index 87% rename from src/main/presenter/acpWorkspacePresenter/directoryReader.ts rename to src/main/presenter/workspacePresenter/directoryReader.ts index 483a77be0..b23ab436d 100644 --- a/src/main/presenter/acpWorkspacePresenter/directoryReader.ts +++ b/src/main/presenter/workspacePresenter/directoryReader.ts @@ -1,6 +1,6 @@ import fs from 'fs/promises' import path from 'path' -import type { AcpFileNode } from '@shared/presenter' +import type { WorkspaceFileNode } from '@shared/presenter' // Ignored directory/file patterns const IGNORED_PATTERNS = [ @@ -27,10 +27,10 @@ const IGNORED_PATTERNS = [ * Directories will have children = undefined, indicating not yet loaded * @param dirPath Directory path */ -export async function readDirectoryShallow(dirPath: string): Promise { +export async function readDirectoryShallow(dirPath: string): Promise { try { const entries = await fs.readdir(dirPath, { withFileTypes: true }) - const nodes: AcpFileNode[] = [] + const nodes: WorkspaceFileNode[] = [] for (const entry of entries) { // Skip ignored files/directories @@ -44,7 +44,7 @@ export async function readDirectoryShallow(dirPath: string): Promise { +): Promise { // Boundary check: depth limit if (currentDepth >= maxDepth) { return [] @@ -90,7 +90,7 @@ export async function readDirectoryTree( try { const entries = await fs.readdir(dirPath, { withFileTypes: true }) - const nodes: AcpFileNode[] = [] + const nodes: WorkspaceFileNode[] = [] for (const entry of entries) { // Skip ignored files/directories @@ -104,7 +104,7 @@ export async function readDirectoryTree( } const fullPath = path.join(dirPath, entry.name) - const node: AcpFileNode = { + const node: WorkspaceFileNode = { name: entry.name, path: fullPath, isDirectory: entry.isDirectory() @@ -127,7 +127,7 @@ export async function readDirectoryTree( return a.name.localeCompare(b.name) }) } catch (error) { - console.error(`[AcpWorkspace] Failed to read directory ${dirPath}:`, error) + console.error(`[Workspace] Failed to read directory ${dirPath}:`, error) return [] } } diff --git a/src/main/presenter/workspacePresenter/fileCache.ts b/src/main/presenter/workspacePresenter/fileCache.ts new file mode 100644 index 000000000..e92e79d95 --- /dev/null +++ b/src/main/presenter/workspacePresenter/fileCache.ts @@ -0,0 +1,77 @@ +export type FileCacheEntry = { + content: string + mtimeMs: number + cachedAt: number +} + +export interface FileCacheOptions { + maxEntries?: number + ttlMs?: number + maxBytes?: number +} + +const DEFAULT_MAX_ENTRIES = 200 +const DEFAULT_TTL_MS = 60_000 +const DEFAULT_MAX_BYTES = 256 * 1024 + +export class FileCache { + private readonly cache = new Map() + private readonly maxEntries: number + private readonly ttlMs: number + private readonly maxBytes: number + + constructor(options: FileCacheOptions = {}) { + this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES + this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS + this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES + } + + get(filePath: string, mtimeMs?: number): FileCacheEntry | null { + const entry = this.cache.get(filePath) + if (!entry) return null + + if (Date.now() - entry.cachedAt > this.ttlMs) { + this.cache.delete(filePath) + return null + } + + if (mtimeMs !== undefined && entry.mtimeMs !== mtimeMs) { + this.cache.delete(filePath) + return null + } + + // Refresh LRU order + this.cache.delete(filePath) + this.cache.set(filePath, entry) + + return entry + } + + set(filePath: string, entry: FileCacheEntry): void { + if (Buffer.byteLength(entry.content, 'utf8') > this.maxBytes) { + return + } + + this.cache.delete(filePath) + this.cache.set(filePath, entry) + this.prune() + } + + delete(filePath: string): void { + this.cache.delete(filePath) + } + + clear(): void { + this.cache.clear() + } + + private prune(): void { + while (this.cache.size > this.maxEntries) { + const oldestKey = this.cache.keys().next().value + if (!oldestKey) { + return + } + this.cache.delete(oldestKey) + } + } +} diff --git a/src/main/presenter/workspacePresenter/fileSearcher.ts b/src/main/presenter/workspacePresenter/fileSearcher.ts new file mode 100644 index 000000000..8a1e0cdd1 --- /dev/null +++ b/src/main/presenter/workspacePresenter/fileSearcher.ts @@ -0,0 +1,184 @@ +import fs from 'fs/promises' +import path from 'path' +import { ConcurrencyLimiter } from './concurrencyLimiter' +import { RipgrepSearcher } from './ripgrepSearcher' + +export interface SearchOptions { + maxResults?: number + cursor?: string + sortBy?: 'name' | 'modified' + excludePatterns?: string[] +} + +export interface SearchResult { + files: string[] + hasMore: boolean + nextCursor?: string + total?: number +} + +const DEFAULT_PAGE_SIZE = 50 +const DEFAULT_CACHE_LIMIT = 200 +const MAX_CACHE_FILES = 500 +const CACHE_TTL_MS = 30_000 +const MAX_CACHE_ENTRIES = 50 +const MTIME_CACHE_TTL_MS = 60_000 + +const statLimiter = new ConcurrencyLimiter(10) +const mtimeCache = new Map() + +type CacheEntry = { + files: string[] + createdAt: number + complete: boolean +} + +const searchCache = new Map() + +const encodeCursor = (offset: number) => Buffer.from(String(offset)).toString('base64') + +const decodeCursor = (cursor?: string) => { + if (!cursor) return 0 + try { + const decoded = Buffer.from(cursor, 'base64').toString('utf8') + const offset = Number(decoded) + return Number.isFinite(offset) && offset >= 0 ? offset : 0 + } catch { + return 0 + } +} + +const getCacheKey = ( + workspacePath: string, + pattern: string, + sortBy: SearchOptions['sortBy'], + excludePatterns?: string[] +) => { + const excludes = excludePatterns?.slice().sort().join(',') ?? '' + return `${workspacePath}::${pattern}::${sortBy ?? 'name'}::${excludes}` +} + +const getCachedEntry = (key: string) => { + const entry = searchCache.get(key) + if (!entry) return null + if (Date.now() - entry.createdAt > CACHE_TTL_MS) { + searchCache.delete(key) + return null + } + + // Refresh LRU order + searchCache.delete(key) + searchCache.set(key, entry) + + return entry +} + +const setCacheEntry = (key: string, entry: CacheEntry) => { + searchCache.set(key, entry) + while (searchCache.size > MAX_CACHE_ENTRIES) { + const oldestKey = searchCache.keys().next().value + if (!oldestKey) return + searchCache.delete(oldestKey) + } +} + +const getMtime = async (filePath: string): Promise => { + const cached = mtimeCache.get(filePath) + if (cached && Date.now() - cached.cachedAt <= MTIME_CACHE_TTL_MS) { + return cached.mtimeMs + } + + const mtimeMs = await statLimiter.run(async () => { + try { + const stats = await fs.stat(filePath) + return stats.mtimeMs + } catch { + return 0 + } + }) + + mtimeCache.set(filePath, { mtimeMs, cachedAt: Date.now() }) + return mtimeMs +} + +const sortFilesByName = (files: string[]) => files.sort((a, b) => a.localeCompare(b)) + +const sortFilesByModified = async (files: string[]) => { + const entries = await Promise.all( + files.map(async (file) => ({ file, mtimeMs: await getMtime(file) })) + ) + + entries.sort((a, b) => { + if (a.mtimeMs !== b.mtimeMs) { + return b.mtimeMs - a.mtimeMs + } + return a.file.localeCompare(b.file) + }) + + return entries.map((entry) => entry.file) +} + +export async function searchFiles( + workspacePath: string, + pattern: string, + options: SearchOptions = {} +): Promise { + const pageSize = options.maxResults ?? DEFAULT_PAGE_SIZE + const offset = decodeCursor(options.cursor) + const sortBy = options.sortBy ?? 'name' + + const cacheKey = getCacheKey(workspacePath, pattern, sortBy, options.excludePatterns) + let cached = getCachedEntry(cacheKey) + + if (!cached) { + const targetLimit = Math.min( + Math.max(offset + pageSize + 1, DEFAULT_CACHE_LIMIT), + MAX_CACHE_FILES + ) + const maxResults = Math.min(targetLimit + 1, MAX_CACHE_FILES + 1) + + const seen = new Set() + const files: string[] = [] + + try { + for await (const filePath of RipgrepSearcher.files(pattern, workspacePath, { + maxResults, + excludePatterns: options.excludePatterns + })) { + const normalized = path.normalize(filePath) + if (seen.has(normalized)) continue + seen.add(normalized) + files.push(normalized) + } + } catch (error) { + console.warn('[WorkspaceSearch] Ripgrep search failed:', error) + } + + const complete = files.length <= targetLimit + const trimmedFiles = complete ? files : files.slice(0, targetLimit) + + const sortedFiles = + sortBy === 'modified' + ? await sortFilesByModified(trimmedFiles) + : sortFilesByName(trimmedFiles) + + cached = { + files: sortedFiles, + createdAt: Date.now(), + complete + } + + setCacheEntry(cacheKey, cached) + } + + const files = cached.files.slice(offset, offset + pageSize) + const hasMore = offset + pageSize < cached.files.length || !cached.complete + const nextCursor = hasMore ? encodeCursor(offset + pageSize) : undefined + + return { + files, + hasMore, + nextCursor, + total: cached.complete ? cached.files.length : undefined + } +} diff --git a/src/main/presenter/workspacePresenter/fileSecurity.ts b/src/main/presenter/workspacePresenter/fileSecurity.ts new file mode 100644 index 000000000..cb2dad920 --- /dev/null +++ b/src/main/presenter/workspacePresenter/fileSecurity.ts @@ -0,0 +1,76 @@ +import path from 'path' + +const SENSITIVE_PATTERNS = ['.env', '.pem', '.key', 'credentials', 'secret', 'password'] + +const DEFAULT_ALLOWLIST = ['.env.example'] + +const BINARY_EXTENSIONS = new Set([ + 'exe', + 'dll', + 'bin', + 'so', + 'dylib', + 'class', + 'jar', + 'zip', + 'tar', + 'gz', + '7z', + 'rar', + 'pdf', + 'png', + 'jpg', + 'jpeg', + 'gif', + 'webp', + 'ico', + 'mp3', + 'wav', + 'flac', + 'mp4', + 'mov', + 'avi', + 'mkv' +]) + +export function checkSensitiveFile( + filePath: string, + allowList: string[] = DEFAULT_ALLOWLIST +): void { + const normalized = filePath.toLowerCase() + + for (const allow of allowList) { + const allowNormalized = path.normalize(allow).toLowerCase() + if (normalized === allowNormalized || normalized.endsWith(path.sep + allowNormalized)) { + return + } + } + + for (const pattern of SENSITIVE_PATTERNS) { + if (normalized.includes(pattern)) { + throw new Error(`Sensitive file access blocked: ${filePath}`) + } + } +} + +export function isBinaryFile(filePath: string): boolean { + const ext = path.extname(filePath).slice(1).toLowerCase() + if (!ext) return false + return BINARY_EXTENSIONS.has(ext) +} + +export function isBinaryContent(content: string): boolean { + const length = Math.min(content.length, 10000) + if (length === 0) return false + + let nonPrintable = 0 + for (let i = 0; i < length; i++) { + const code = content.charCodeAt(i) + if (code === 0) return true + if (code < 32 && code !== 9 && code !== 10 && code !== 13) { + nonPrintable++ + } + } + + return nonPrintable / length > 0.3 +} diff --git a/src/main/presenter/workspacePresenter/index.ts b/src/main/presenter/workspacePresenter/index.ts index 909fe9044..4fdb054dd 100644 --- a/src/main/presenter/workspacePresenter/index.ts +++ b/src/main/presenter/workspacePresenter/index.ts @@ -3,8 +3,9 @@ import fs from 'fs' import { shell } from 'electron' import { eventBus, SendTarget } from '@/eventbus' import { WORKSPACE_EVENTS } from '@/events' -import { readDirectoryShallow } from '../acpWorkspacePresenter/directoryReader' -import { PlanStateManager } from '../acpWorkspacePresenter/planStateManager' +import { readDirectoryShallow } from './directoryReader' +import { PlanStateManager } from './planStateManager' +import { searchWorkspaceFiles } from './workspaceFileSearch' import type { IWorkspacePresenter, WorkspaceFileNode, @@ -15,8 +16,8 @@ import type { export class WorkspacePresenter implements IWorkspacePresenter { private readonly planManager = new PlanStateManager() - // Allowed workspace paths (registered by Agent sessions) - private readonly allowedWorkspaces = new Set() + // Allowed workspace paths (registered by Agent and ACP sessions) + private readonly allowedPaths = new Set() /** * Register a workspace path as allowed for reading @@ -24,7 +25,14 @@ export class WorkspacePresenter implements IWorkspacePresenter { */ async registerWorkspace(workspacePath: string): Promise { const normalized = path.resolve(workspacePath) - this.allowedWorkspaces.add(normalized) + this.allowedPaths.add(normalized) + } + + /** + * Register a workdir path as allowed for reading (ACP alias) + */ + async registerWorkdir(workdir: string): Promise { + await this.registerWorkspace(workdir) } /** @@ -32,7 +40,14 @@ export class WorkspacePresenter implements IWorkspacePresenter { */ async unregisterWorkspace(workspacePath: string): Promise { const normalized = path.resolve(workspacePath) - this.allowedWorkspaces.delete(normalized) + this.allowedPaths.delete(normalized) + } + + /** + * Unregister a workdir path (ACP alias) + */ + async unregisterWorkdir(workdir: string): Promise { + await this.unregisterWorkspace(workdir) } /** @@ -48,7 +63,7 @@ export class WorkspacePresenter implements IWorkspacePresenter { ? normalizedTarget : `${normalizedTarget}${path.sep}` - for (const workspace of this.allowedWorkspaces) { + for (const workspace of this.allowedPaths) { try { // Resolve symlinks for each allowed workspace const realWorkspace = fs.realpathSync(workspace) @@ -86,9 +101,7 @@ export class WorkspacePresenter implements IWorkspacePresenter { console.warn(`[Workspace] Blocked read attempt for unauthorized path: ${dirPath}`) return [] } - // AcpFileNode and WorkspaceFileNode have the same structure - const nodes = await readDirectoryShallow(dirPath) - return nodes as unknown as WorkspaceFileNode[] + return readDirectoryShallow(dirPath) } /** @@ -101,9 +114,7 @@ export class WorkspacePresenter implements IWorkspacePresenter { console.warn(`[Workspace] Blocked expand attempt for unauthorized path: ${dirPath}`) return [] } - // AcpFileNode and WorkspaceFileNode have the same structure - const nodes = await readDirectoryShallow(dirPath) - return nodes as unknown as WorkspaceFileNode[] + return readDirectoryShallow(dirPath) } /** @@ -150,19 +161,14 @@ export class WorkspacePresenter implements IWorkspacePresenter { * Get plan entries */ async getPlanEntries(conversationId: string): Promise { - // WorkspacePlanEntry and AcpPlanEntry have the same structure - return this.planManager.getEntries(conversationId) as unknown as WorkspacePlanEntry[] + return this.planManager.getEntries(conversationId) } /** * Update plan entries (called by agent content mapper) */ async updatePlanEntries(conversationId: string, entries: WorkspaceRawPlanEntry[]): Promise { - // WorkspaceRawPlanEntry and AcpRawPlanEntry have the same structure - const updated = this.planManager.updateEntries( - conversationId, - entries as unknown as import('@shared/presenter').AcpRawPlanEntry[] - ) as unknown as WorkspacePlanEntry[] + const updated = this.planManager.updateEntries(conversationId, entries) // Send event to renderer eventBus.sendToRenderer(WORKSPACE_EVENTS.PLAN_UPDATED, SendTarget.ALL_WINDOWS, { @@ -190,4 +196,16 @@ export class WorkspacePresenter implements IWorkspacePresenter { async clearWorkspaceData(conversationId: string): Promise { this.planManager.clear(conversationId) } + + /** + * Search workspace files by query (query does not include @) + */ + async searchFiles(workspacePath: string, query: string): Promise { + if (!this.isPathAllowed(workspacePath)) { + console.warn(`[Workspace] Blocked search attempt for unauthorized path: ${workspacePath}`) + return [] + } + const results = await searchWorkspaceFiles(workspacePath, query) + return results + } } diff --git a/src/main/presenter/workspacePresenter/pathResolver.ts b/src/main/presenter/workspacePresenter/pathResolver.ts new file mode 100644 index 000000000..5908e0422 --- /dev/null +++ b/src/main/presenter/workspacePresenter/pathResolver.ts @@ -0,0 +1,34 @@ +import fs from 'fs' +import os from 'os' +import path from 'path' + +export function resolveWorkspacePath(workspaceRoot: string, inputPath: string): string | null { + const trimmed = inputPath.trim() + if (!trimmed) return null + + const expanded = trimmed.replace(/^~(?=$|[\\/])/, os.homedir()) + const absolute = path.isAbsolute(expanded) + ? path.resolve(expanded) + : path.resolve(workspaceRoot, expanded) + const normalized = path.normalize(absolute) + + let realPath: string + let workspaceReal: string + + try { + realPath = fs.realpathSync(normalized) + workspaceReal = fs.realpathSync(workspaceRoot) + } catch { + return null + } + + const workspaceWithSep = workspaceReal.endsWith(path.sep) + ? workspaceReal + : `${workspaceReal}${path.sep}` + + if (realPath === workspaceReal || realPath.startsWith(workspaceWithSep)) { + return realPath + } + + return null +} diff --git a/src/main/presenter/acpWorkspacePresenter/planStateManager.ts b/src/main/presenter/workspacePresenter/planStateManager.ts similarity index 82% rename from src/main/presenter/acpWorkspacePresenter/planStateManager.ts rename to src/main/presenter/workspacePresenter/planStateManager.ts index ab548ae20..7edd7a355 100644 --- a/src/main/presenter/acpWorkspacePresenter/planStateManager.ts +++ b/src/main/presenter/workspacePresenter/planStateManager.ts @@ -1,6 +1,10 @@ import crypto from 'crypto' import { nanoid } from 'nanoid' -import type { AcpPlanEntry, AcpPlanStatus, AcpRawPlanEntry } from '@shared/presenter' +import type { + WorkspacePlanEntry, + WorkspacePlanStatus, + WorkspaceRawPlanEntry +} from '@shared/presenter' // Maximum number of completed entries to retain per conversation const MAX_COMPLETED_ENTRIES = 10 @@ -10,8 +14,8 @@ const MAX_COMPLETED_ENTRIES = 10 * Maintains plan entries for each conversation, supports incremental updates */ export class PlanStateManager { - // Map> - private readonly planStore = new Map>() + // Map> + private readonly planStore = new Map>() /** * Update plan entries (incremental merge) @@ -19,7 +23,7 @@ export class PlanStateManager { * @param rawEntries Raw plan entries * @returns Updated complete entries list */ - updateEntries(conversationId: string, rawEntries: AcpRawPlanEntry[]): AcpPlanEntry[] { + updateEntries(conversationId: string, rawEntries: WorkspaceRawPlanEntry[]): WorkspacePlanEntry[] { if (!this.planStore.has(conversationId)) { this.planStore.set(conversationId, new Map()) } @@ -55,7 +59,7 @@ export class PlanStateManager { /** * Get all plan entries for a conversation */ - getEntries(conversationId: string): AcpPlanEntry[] { + getEntries(conversationId: string): WorkspacePlanEntry[] { const store = this.planStore.get(conversationId) if (!store) return [] return Array.from(store.values()) @@ -71,8 +75,8 @@ export class PlanStateManager { /** * Prune completed entries, keeping only the latest MAX_COMPLETED_ENTRIES */ - private pruneCompletedEntries(store: Map): void { - const completedEntries: Array<{ key: string; entry: AcpPlanEntry }> = [] + private pruneCompletedEntries(store: Map): void { + const completedEntries: Array<{ key: string; entry: WorkspacePlanEntry }> = [] for (const [key, entry] of store) { if (entry.status === 'completed') { @@ -101,7 +105,7 @@ export class PlanStateManager { return crypto.createHash('sha256').update(normalized).digest('hex') } - private normalizeStatus(status?: string | null): AcpPlanStatus { + private normalizeStatus(status?: string | null): WorkspacePlanStatus { switch (status) { case 'completed': case 'done': diff --git a/src/main/presenter/workspacePresenter/ripgrepSearcher.ts b/src/main/presenter/workspacePresenter/ripgrepSearcher.ts new file mode 100644 index 000000000..78e40e88c --- /dev/null +++ b/src/main/presenter/workspacePresenter/ripgrepSearcher.ts @@ -0,0 +1,176 @@ +import { spawn } from 'child_process' +import os from 'os' +import path from 'path' +import readline from 'readline' +import { RuntimeHelper } from '@/lib/runtimeHelper' + +export interface RipgrepSearchOptions { + maxResults?: number + excludePatterns?: string[] + timeoutMs?: number +} + +const DEFAULT_EXCLUDES = [ + '.git', + 'node_modules', + '.DS_Store', + 'dist', + 'build', + 'out', + '.turbo', + '.next', + '.nuxt', + '.cache', + 'coverage' +] + +const DEFAULT_TIMEOUT_MS = 15_000 +const DEFAULT_MAX_COLUMNS = 2_000 +const DEFAULT_MAX_FILESIZE = '5M' + +export class RipgrepSearcher { + static async *files( + pattern: string, + workspacePath: string, + options: RipgrepSearchOptions = {} + ): AsyncGenerator { + const runtimeHelper = RuntimeHelper.getInstance() + runtimeHelper.initializeRuntimes() + const ripgrepPath = runtimeHelper.getRipgrepRuntimePath() + + const rgExecutable = ripgrepPath + ? path.join(ripgrepPath, process.platform === 'win32' ? 'rg.exe' : 'rg') + : 'rg' + + const excludePatterns = [...new Set([...(options.excludePatterns ?? []), ...DEFAULT_EXCLUDES])] + const maxResults = options.maxResults + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS + const threads = Math.max(1, Math.min(os.cpus().length, 4)) + + const args: string[] = [ + '--files', + '--json', + '--threads', + String(threads), + '--max-filesize', + DEFAULT_MAX_FILESIZE, + '--max-columns', + String(DEFAULT_MAX_COLUMNS) + ] + + // Handle glob pattern + // For "**/*" or "**", we want to match all files, so we don't need --glob + // For other patterns, use --glob + // Note: ripgrep uses gitignore-style globs + // Patterns like "*query*" work better than "**/*query*" for filename matching + if (pattern && pattern !== '**/*' && pattern !== '**' && pattern !== '*') { + // If pattern starts with "**/*", simplify it to just "*" + rest + // e.g., "**/*src*" -> "*src*" (matches files with "src" in name anywhere) + let simplifiedPattern = pattern + if (pattern.startsWith('**/*')) { + simplifiedPattern = '*' + pattern.slice(4) // Remove "**/" prefix + } else if (pattern.startsWith('**/')) { + simplifiedPattern = pattern.slice(3) // Remove "**/" prefix + } + args.push('--glob', simplifiedPattern) + } + + for (const exclude of excludePatterns) { + args.push('--glob', `!${exclude}`) + } + + // For --files mode, we need a search pattern (even if it's just '.') + // The pattern is used to match file contents, but with --files we only care about file paths + args.push('.') // Search pattern: match any character (will match all files) + args.push(workspacePath) + + const proc = spawn(rgExecutable, args, { stdio: ['ignore', 'pipe', 'pipe'] }) + const rl = readline.createInterface({ input: proc.stdout }) + + let count = 0 + let terminatedEarly = false + let timeoutHandle: NodeJS.Timeout | null = null + let stderrOutput = '' // Move stderrOutput to outer scope + let exitCode: number | null = null + let exitError: Error | null = null + let runError: unknown = null + + const exitPromise = new Promise<{ code: number | null }>((resolve, reject) => { + proc.once('close', (code) => resolve({ code })) + proc.once('error', (error) => reject(error)) + }) + + if (timeoutMs > 0) { + timeoutHandle = setTimeout(() => { + terminatedEarly = true + proc.kill() + }, timeoutMs) + } + + // Capture stderr for debugging + proc.stderr?.on('data', (chunk) => { + stderrOutput += chunk.toString() + }) + + try { + for await (const line of rl) { + if (!line.trim()) continue + + let parsed: { type?: string; data?: { path?: { text?: string } | string } } + try { + parsed = JSON.parse(line) + } catch { + continue + } + + const pathValue = + typeof parsed.data?.path === 'string' ? parsed.data.path : parsed.data?.path?.text + + // ripgrep with --files returns 'begin' type for each file + if (parsed.type === 'begin' && pathValue) { + yield pathValue + count += 1 + if (maxResults && count >= maxResults) { + terminatedEarly = true + proc.kill() + break + } + } + } + } catch (error) { + runError = error + } finally { + if (timeoutHandle) { + clearTimeout(timeoutHandle) + } + rl.close() + if (!proc.killed && proc.exitCode === null) { + proc.kill() + } + try { + const { code } = await exitPromise + exitCode = code + } catch (error) { + exitError = error instanceof Error ? error : new Error(String(error)) + } + } + + if (runError) { + throw runError + } + + // Exit code 0: matches found + // Exit code 1: no matches found (not an error) + // Exit code 2: error (e.g., "no files were searched" due to glob filter) + // For code 2, we've already logged stderr, and count is 0, so just return empty + // Only throw for unexpected errors (code > 2) + if (!terminatedEarly) { + if (exitError) { + throw exitError + } + if (exitCode !== null && exitCode > 2) { + throw new Error(`Ripgrep exited with code ${exitCode}: ${stderrOutput.substring(0, 200)}`) + } + } + } +} diff --git a/src/main/presenter/workspacePresenter/workspaceFileSearch.ts b/src/main/presenter/workspacePresenter/workspaceFileSearch.ts new file mode 100644 index 000000000..cab82a904 --- /dev/null +++ b/src/main/presenter/workspacePresenter/workspaceFileSearch.ts @@ -0,0 +1,113 @@ +import fs from 'fs/promises' +import path from 'path' +import type { WorkspaceFileNode } from '@shared/presenter' +import { searchFiles } from './fileSearcher' +import { resolveWorkspacePath } from './pathResolver' +import { checkSensitiveFile, isBinaryFile } from './fileSecurity' + +const DEFAULT_RESULT_LIMIT = 50 + +const escapeGlob = (input: string) => input.replace(/[\\*?\[\]]/g, '\\$&') + +const buildFileNode = (filePath: string): WorkspaceFileNode => ({ + name: path.basename(filePath), + path: filePath, + isDirectory: false +}) + +const scoreMatch = (workspaceRoot: string, filePath: string, query: string): number => { + const normalizedPath = path.normalize(filePath) + const normalizedQuery = query.toLowerCase() + const baseName = path.basename(normalizedPath).toLowerCase() + const relativePath = path + .relative(workspaceRoot, normalizedPath) + .split(path.sep) + .join('/') + .toLowerCase() + + if (normalizedPath.toLowerCase() === query.toLowerCase()) return 100 + if (relativePath === normalizedQuery) return 95 + if (baseName === normalizedQuery) return 90 + if (baseName.startsWith(normalizedQuery)) return 80 + if (baseName.includes(normalizedQuery)) return 70 + if (relativePath.includes(normalizedQuery)) return 60 + return 50 +} + +export async function searchWorkspaceFiles( + workspacePath: string, + query: string +): Promise { + const trimmed = query.trim() + if (!trimmed) { + return [] + } + + // Handle special case: if query is "**/*", use it directly as glob pattern + // This is used when user just types "@" to show some files + if (trimmed === '**/*') { + const result = await searchFiles(workspacePath, trimmed, { + maxResults: DEFAULT_RESULT_LIMIT, + sortBy: 'name' + }) + + const filtered = result.files + .filter((filePath) => { + try { + checkSensitiveFile(filePath) + return !isBinaryFile(filePath) + } catch { + return false + } + }) + .map((filePath) => buildFileNode(filePath)) + return filtered + } + + const resolved = resolveWorkspacePath(workspacePath, trimmed) + if (resolved) { + try { + const stats = await fs.stat(resolved) + if (stats.isFile()) { + checkSensitiveFile(resolved) + if (!isBinaryFile(resolved)) { + return [buildFileNode(resolved)] + } + } + } catch { + // Fall through to fuzzy search. + } + } + + const hasSeparator = trimmed.includes('/') || trimmed.includes('\\') + const escaped = escapeGlob(trimmed) + // For ripgrep, use simpler glob patterns + // If has separator, use the path as-is with wildcards + // Otherwise, use *query* to match anywhere in filename + const globPattern = hasSeparator + ? `**/${escaped}*` // Path-based: **/path/to/file* + : `*${escaped}*` // Filename-based: *query* (matches anywhere in filename) + + const result = await searchFiles(workspacePath, globPattern, { + maxResults: DEFAULT_RESULT_LIMIT, + sortBy: 'name' + }) + + const ranked = result.files + .map((filePath) => ({ + filePath, + score: scoreMatch(workspacePath, filePath, trimmed) + })) + .filter(({ filePath }) => { + try { + checkSensitiveFile(filePath) + return !isBinaryFile(filePath) + } catch { + return false + } + }) + .sort((a, b) => b.score - a.score || a.filePath.localeCompare(b.filePath)) + + const finalResults = ranked.map(({ filePath }) => buildFileNode(filePath)) + return finalResults +} diff --git a/src/renderer/settings/components/AboutUsSettings.vue b/src/renderer/settings/components/AboutUsSettings.vue index 5d008ec8e..f88225443 100644 --- a/src/renderer/settings/components/AboutUsSettings.vue +++ b/src/renderer/settings/components/AboutUsSettings.vue @@ -57,8 +57,8 @@ {{ t('about.stableChannel') }} - - {{ t('about.canaryChannel') }} + + {{ t('about.betaChannel') }} diff --git a/src/renderer/src/components/chat-input/ChatInput.vue b/src/renderer/src/components/chat-input/ChatInput.vue index 26ba39223..97dc56219 100644 --- a/src/renderer/src/components/chat-input/ChatInput.vue +++ b/src/renderer/src/components/chat-input/ChatInput.vue @@ -459,6 +459,7 @@ import { useAcpWorkdir } from './composables/useAcpWorkdir' import { useAcpMode } from './composables/useAcpMode' import { useChatMode, type ChatMode } from './composables/useChatMode' import { useAgentWorkspace } from './composables/useAgentWorkspace' +import { useWorkspaceMention } from './composables/useWorkspaceMention' // === Stores === import { useChatStore } from '@/stores/chat' @@ -467,7 +468,10 @@ import { useThemeStore } from '@/stores/theme' // === Mention System === import { Mention } from '../editor/mention/mention' -import suggestion, { setPromptFilesHandler } from '../editor/mention/suggestion' +import suggestion, { + setPromptFilesHandler, + setWorkspaceMention +} from '../editor/mention/suggestion' import { mentionData } from '../editor/mention/suggestion' import { useEventListener } from '@vueuse/core' @@ -740,6 +744,13 @@ const workspace = useAgentWorkspace({ chatMode }) +const workspaceMention = useWorkspaceMention({ + workspacePath: workspace.workspacePath, + chatMode: chatMode.currentMode, + conversationId +}) +setWorkspaceMention(workspaceMention) + // Extract isStreaming first so we can pass it to useAcpMode const { disabledSend, isStreaming } = sendButtonState @@ -909,6 +920,8 @@ onUnmounted(() => { if (caretAnimationFrame) { cancelAnimationFrame(caretAnimationFrame) } + + setWorkspaceMention(null) }) // === Watchers === diff --git a/src/renderer/src/components/chat-input/composables/useWorkspaceMention.ts b/src/renderer/src/components/chat-input/composables/useWorkspaceMention.ts new file mode 100644 index 000000000..d2e8a3e8c --- /dev/null +++ b/src/renderer/src/components/chat-input/composables/useWorkspaceMention.ts @@ -0,0 +1,101 @@ +import { computed, ref, watch, type Ref } from 'vue' +import { useDebounceFn } from '@vueuse/core' +import { usePresenter } from '@/composables/usePresenter' +import type { WorkspaceFileNode } from '@shared/presenter' +import type { CategorizedData } from '../../editor/mention/suggestion' + +export function useWorkspaceMention(options: { + workspacePath: Ref + chatMode: Ref<'chat' | 'agent' | 'acp agent'> + conversationId: Ref +}) { + const workspacePresenter = usePresenter('workspacePresenter') + const workspaceFileResults = ref([]) + + const isEnabled = computed(() => { + const hasPath = !!options.workspacePath.value + const isAgentMode = options.chatMode.value === 'agent' || options.chatMode.value === 'acp agent' + const enabled = hasPath && isAgentMode + return enabled + }) + + const toDisplayPath = (filePath: string) => { + const root = options.workspacePath.value + if (!root) return filePath + const trimmedRoot = root.replace(/[\\/]+$/, '') + if (!filePath.startsWith(trimmedRoot)) return filePath + const relative = filePath.slice(trimmedRoot.length).replace(/^[\\/]+/, '') + return relative || filePath + } + + const mapResults = (files: WorkspaceFileNode[]) => + files.map((file) => { + const relativePath = toDisplayPath(file.path) + return { + id: file.path, + label: relativePath || file.name, + description: file.path, + icon: file.isDirectory ? 'lucide:folder' : 'lucide:file', + type: 'item' as const, + category: 'workspace' as const + } + }) + + const clearResults = () => { + workspaceFileResults.value = [] + } + + const searchWorkspaceFiles = useDebounceFn(async (query: string) => { + // Allow empty query to show some files when user just types "@" + // Empty query means show a limited list of files + if (!isEnabled.value || !options.workspacePath.value) { + clearResults() + return + } + + const trimmed = query.trim() + // If query is empty, use "**/*" to show some files (limited by searchFiles) + // This is a standard glob pattern to match all files + const searchQuery = trimmed || '**/*' + + try { + if (options.chatMode.value === 'acp agent') { + await workspacePresenter.registerWorkdir(options.workspacePath.value) + } else { + await workspacePresenter.registerWorkspace(options.workspacePath.value) + } + const results = + (await workspacePresenter.searchFiles(options.workspacePath.value, searchQuery)) ?? [] + workspaceFileResults.value = mapResults(results) + } catch (error) { + console.error('[WorkspaceMention] Failed to search workspace files:', error) + clearResults() + } + }, 300) + + watch(isEnabled, (enabled) => { + if (!enabled) { + clearResults() + } + }) + + watch( + () => options.workspacePath.value, + () => { + clearResults() + } + ) + + watch( + () => options.conversationId.value, + () => { + clearResults() + } + ) + + return { + searchWorkspaceFiles, + workspaceFileResults, + isEnabled + } +} diff --git a/src/renderer/src/components/editor/mention/suggestion.ts b/src/renderer/src/components/editor/mention/suggestion.ts index f95cf6ea2..e23ec847d 100644 --- a/src/renderer/src/components/editor/mention/suggestion.ts +++ b/src/renderer/src/components/editor/mention/suggestion.ts @@ -28,6 +28,18 @@ const categorizedData: CategorizedData[] = [ export const mentionSelected = ref(false) export const mentionData: Ref = ref(categorizedData) +export type WorkspaceMentionHandler = { + searchWorkspaceFiles: (query: string) => void + workspaceFileResults: Ref + isEnabled: Ref +} + +let workspaceMentionHandler: WorkspaceMentionHandler | null = null + +export const setWorkspaceMention = (handler: WorkspaceMentionHandler | null) => { + workspaceMentionHandler = handler +} + // 存储文件处理回调函数 let promptFilesHandler: | (( @@ -53,24 +65,39 @@ export const setPromptFilesHandler = (handler: typeof promptFilesHandler) => { export const getPromptFilesHandler = () => promptFilesHandler export default { - allowedPrefixes: null, + char: '@', + allowedPrefixes: null, // null means allow @ after any character items: ({ query }) => { - // If there's a query, search across all categories - if (query) { - const allItems: CategorizedData[] = [] - // Flatten the structure and search in all categories + // Note: TipTap mention passes query WITHOUT the trigger character (@) + // So if user types "@", query is "" + // If user types "@p", query is "p" + + // Collect workspace results if enabled + let workspaceResults: CategorizedData[] = [] + if (workspaceMentionHandler?.isEnabled.value) { + workspaceMentionHandler.searchWorkspaceFiles(query) + workspaceResults = workspaceMentionHandler.workspaceFileResults.value + } + // Collect other mention data (prompts, tools, files, resources) + let otherItems: CategorizedData[] = [] + if (query) { + // Search across all categories for (const item of mentionData.value) { if (item.label.toLowerCase().includes(query.toLowerCase())) { - allItems.push(item) + otherItems.push(item) } } - - return allItems.slice(0, 5) + otherItems = otherItems.slice(0, 5) + } else { + // If no query, return all mention data + otherItems = mentionData.value } - // If no query, return the full list - return mentionData.value + // Combine workspace results with other mention data + // Workspace results come first, then other mention data + const combined = [...workspaceResults, ...otherItems] + return combined }, render: () => { diff --git a/src/renderer/src/components/workspace/WorkspaceFileNode.vue b/src/renderer/src/components/workspace/WorkspaceFileNode.vue index 5a8bf436b..3f32d365f 100644 --- a/src/renderer/src/components/workspace/WorkspaceFileNode.vue +++ b/src/renderer/src/components/workspace/WorkspaceFileNode.vue @@ -62,7 +62,6 @@ import { computed } from 'vue' import { Icon } from '@iconify/vue' import { useI18n } from 'vue-i18n' import { usePresenter } from '@/composables/usePresenter' -import { useChatMode } from '@/components/chat-input/composables/useChatMode' import { ContextMenu, ContextMenuContent, @@ -83,13 +82,7 @@ const emit = defineEmits<{ }>() const { t } = useI18n() -const chatMode = useChatMode() const workspacePresenter = usePresenter('workspacePresenter') -const acpWorkspacePresenter = usePresenter('acpWorkspacePresenter') - -const presenter = computed(() => - chatMode.currentMode.value === 'acp agent' ? acpWorkspacePresenter : workspacePresenter -) const extensionIconMap: Record = { pdf: 'lucide:file-text', @@ -146,7 +139,7 @@ const handleOpenFile = async () => { } try { - await presenter.value.openFile(props.node.path) + await workspacePresenter.openFile(props.node.path) } catch (error) { console.error(`[Workspace] Failed to open file: ${props.node.path}`, error) } @@ -154,7 +147,7 @@ const handleOpenFile = async () => { const handleRevealInFolder = async () => { try { - await presenter.value.revealFileInFolder(props.node.path) + await workspacePresenter.revealFileInFolder(props.node.path) } catch (error) { console.error(`[Workspace] Failed to reveal path: ${props.node.path}`, error) } diff --git a/src/renderer/src/i18n/da-DK/about.json b/src/renderer/src/i18n/da-DK/about.json index 3b15f1a33..b035590f7 100644 --- a/src/renderer/src/i18n/da-DK/about.json +++ b/src/renderer/src/i18n/da-DK/about.json @@ -1,5 +1,5 @@ { - "canaryChannel": "Intern testversion", + "betaChannel": "Beta", "checkUpdateButton": "Tjek for opdateringer", "description": "DeepChat er en tværplatform AI-klient, der har til formål at gøre det nemmere for flere mennesker at bruge AI.", "deviceInfo": { diff --git a/src/renderer/src/i18n/en-US/about.json b/src/renderer/src/i18n/en-US/about.json index c4a66a843..3a2ebc9aa 100644 --- a/src/renderer/src/i18n/en-US/about.json +++ b/src/renderer/src/i18n/en-US/about.json @@ -15,5 +15,5 @@ "checkUpdateButton": "Check for Updates", "updateChannel": "Update Channel", "stableChannel": "Stable", - "canaryChannel": "Canary" + "betaChannel": "Beta" } diff --git a/src/renderer/src/i18n/fa-IR/about.json b/src/renderer/src/i18n/fa-IR/about.json index 77b59e799..b3a7c107e 100644 --- a/src/renderer/src/i18n/fa-IR/about.json +++ b/src/renderer/src/i18n/fa-IR/about.json @@ -15,5 +15,5 @@ "checkUpdateButton": "بررسی به‌روزرسانی", "updateChannel": "کانال به‌روزرسانی", "stableChannel": "پایدار", - "canaryChannel": "کاناری" + "betaChannel": "Beta" } diff --git a/src/renderer/src/i18n/fr-FR/about.json b/src/renderer/src/i18n/fr-FR/about.json index c5d9af7d9..d77768bb0 100644 --- a/src/renderer/src/i18n/fr-FR/about.json +++ b/src/renderer/src/i18n/fr-FR/about.json @@ -15,5 +15,5 @@ "checkUpdateButton": "Vérifier les mises à jour", "updateChannel": "Canal de mise à jour", "stableChannel": "Stable", - "canaryChannel": "Canary" + "betaChannel": "Beta" } diff --git a/src/renderer/src/i18n/he-IL/about.json b/src/renderer/src/i18n/he-IL/about.json index 03e17c5bb..2626806ca 100644 --- a/src/renderer/src/i18n/he-IL/about.json +++ b/src/renderer/src/i18n/he-IL/about.json @@ -15,5 +15,5 @@ "checkUpdateButton": "בדוק עדכונים", "updateChannel": "ערוץ עדכון", "stableChannel": "יציב (Stable)", - "canaryChannel": "קנרית (Canary)" + "betaChannel": "Beta" } diff --git a/src/renderer/src/i18n/ja-JP/about.json b/src/renderer/src/i18n/ja-JP/about.json index e4ce28faa..da646cdc0 100644 --- a/src/renderer/src/i18n/ja-JP/about.json +++ b/src/renderer/src/i18n/ja-JP/about.json @@ -7,7 +7,7 @@ "checkUpdateButton": "アップデートを確認", "updateChannel": "アップデートチャンネル", "stableChannel": "安定版", - "canaryChannel": "テスト版", + "betaChannel": "Beta", "deviceInfo": { "title": "デバイス情報", "platform": "プラットフォーム", diff --git a/src/renderer/src/i18n/ko-KR/about.json b/src/renderer/src/i18n/ko-KR/about.json index 114506c81..ef1666753 100644 --- a/src/renderer/src/i18n/ko-KR/about.json +++ b/src/renderer/src/i18n/ko-KR/about.json @@ -7,7 +7,7 @@ "checkUpdateButton": "업데이트 확인", "updateChannel": "업데이트 채널", "stableChannel": "안정 버전", - "canaryChannel": "테스트 버전", + "betaChannel": "Beta", "deviceInfo": { "title": "장치 정보", "platform": "플랫폼", diff --git a/src/renderer/src/i18n/pt-BR/about.json b/src/renderer/src/i18n/pt-BR/about.json index e1e02d593..433bfc7a2 100644 --- a/src/renderer/src/i18n/pt-BR/about.json +++ b/src/renderer/src/i18n/pt-BR/about.json @@ -15,5 +15,5 @@ "checkUpdateButton": "Verificar Atualizações", "updateChannel": "Canal de Atualização", "stableChannel": "Estável", - "canaryChannel": "Canary" + "betaChannel": "Beta" } diff --git a/src/renderer/src/i18n/ru-RU/about.json b/src/renderer/src/i18n/ru-RU/about.json index 04db38aef..ac4af0d6d 100644 --- a/src/renderer/src/i18n/ru-RU/about.json +++ b/src/renderer/src/i18n/ru-RU/about.json @@ -7,7 +7,7 @@ "checkUpdateButton": "Проверить обновления", "updateChannel": "Канал обновлений", "stableChannel": "Стабильный", - "canaryChannel": "Канареечный", + "betaChannel": "Beta", "deviceInfo": { "title": "Сведения об устройстве", "platform": "Платформа", diff --git a/src/renderer/src/i18n/zh-CN/about.json b/src/renderer/src/i18n/zh-CN/about.json index 88f9bfc1d..092dc8273 100644 --- a/src/renderer/src/i18n/zh-CN/about.json +++ b/src/renderer/src/i18n/zh-CN/about.json @@ -7,7 +7,7 @@ "checkUpdateButton": "检查更新", "updateChannel": "更新渠道", "stableChannel": "正式版", - "canaryChannel": "内测版", + "betaChannel": "内测版", "deviceInfo": { "title": "设备信息", "platform": "平台", diff --git a/src/renderer/src/i18n/zh-HK/about.json b/src/renderer/src/i18n/zh-HK/about.json index 091bf17e5..317079238 100644 --- a/src/renderer/src/i18n/zh-HK/about.json +++ b/src/renderer/src/i18n/zh-HK/about.json @@ -7,7 +7,7 @@ "checkUpdateButton": "檢查更新", "updateChannel": "更新頻道", "stableChannel": "正式版", - "canaryChannel": "測試版", + "betaChannel": "内测版", "deviceInfo": { "title": "設備信息", "platform": "平台", diff --git a/src/renderer/src/i18n/zh-TW/about.json b/src/renderer/src/i18n/zh-TW/about.json index 80ccdb87e..515c09a40 100644 --- a/src/renderer/src/i18n/zh-TW/about.json +++ b/src/renderer/src/i18n/zh-TW/about.json @@ -7,7 +7,7 @@ "checkUpdateButton": "檢查更新", "updateChannel": "更新頻道", "stableChannel": "正式版", - "canaryChannel": "測試版", + "betaChannel": "内测版", "deviceInfo": { "title": "裝置資訊", "platform": "平台", diff --git a/src/renderer/src/stores/mcp.ts b/src/renderer/src/stores/mcp.ts index 85099d859..5bb4b0657 100644 --- a/src/renderer/src/stores/mcp.ts +++ b/src/renderer/src/stores/mcp.ts @@ -7,7 +7,6 @@ import { MCP_EVENTS } from '@/events' import { useI18n } from 'vue-i18n' import { useChatStore } from './chat' import { useQuery, type UseMutationReturn, type UseQueryReturn } from '@pinia/colada' -import { isSafeRegexPattern } from '@shared/regexValidator' import type { McpClient, MCPConfig, @@ -279,11 +278,13 @@ export const useMcpStore = defineStore('mcp', () => { }) } - if (tool.function.name === 'search_files') { + if (tool.function.name === 'glob_search') { toolInputs.value[tool.function.name] = { - path: '', - regex: '\\.md$', - file_pattern: '*.md' + pattern: '**/*.md', + root: '', + excludePatterns: '', + maxResults: '1000', + sortBy: 'name' } } } @@ -739,24 +740,44 @@ export const useMcpStore = defineStore('mcp', () => { toolLoadingStates.value[toolName] = true try { // 准备工具参数 - const params = toolInputs.value[toolName] || {} - - // 特殊处理search_files工具 - if (toolName === 'search_files') { - if (!params.regex) params.regex = '\\.md$' - if (!params.path) params.path = '.' - // Validate regex pattern for ReDoS safety - if (params.regex && typeof params.regex === 'string' && !isSafeRegexPattern(params.regex)) { - throw new Error( - 'Regular expression pattern is potentially unsafe and may cause ReDoS. Please use a simpler, safer pattern.' - ) + const rawParams = toolInputs.value[toolName] || {} + const params = { ...rawParams } as Record + + // 特殊处理 glob_search 工具 + if (toolName === 'glob_search') { + const pattern = typeof params.pattern === 'string' ? params.pattern.trim() : '' + if (!pattern) { + params.pattern = '**/*.md' } - if (!params.file_pattern) { - const match = params.regex.match(/\.(\w+)\$/) - if (match) { - params.file_pattern = `*.${match[1]}` + + if (typeof params.root === 'string' && params.root.trim() === '') { + delete params.root + } + + if (typeof params.excludePatterns === 'string') { + const parsed = params.excludePatterns + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + if (parsed.length > 0) { + params.excludePatterns = parsed + } else { + delete params.excludePatterns + } + } + + if (typeof params.maxResults === 'string') { + const parsed = Number(params.maxResults) + if (!Number.isNaN(parsed)) { + params.maxResults = parsed + } else { + delete params.maxResults } } + + if (typeof params.sortBy === 'string' && params.sortBy.trim() === '') { + delete params.sortBy + } } // 创建工具调用请求 diff --git a/src/renderer/src/stores/workspace.ts b/src/renderer/src/stores/workspace.ts index 75df4688a..2210f8f02 100644 --- a/src/renderer/src/stores/workspace.ts +++ b/src/renderer/src/stores/workspace.ts @@ -6,54 +6,19 @@ import { WORKSPACE_EVENTS } from '@/events' import type { WorkspacePlanEntry, WorkspaceFileNode, - WorkspaceTerminalSnippet, - AcpPlanEntry, - AcpFileNode, - AcpTerminalSnippet + WorkspaceTerminalSnippet } from '@shared/presenter' import { useChatMode } from '@/components/chat-input/composables/useChatMode' // Debounce delay for file tree refresh (ms) const FILE_REFRESH_DEBOUNCE_MS = 500 -// Type conversion helpers -const convertAcpPlanEntry = (entry: AcpPlanEntry): WorkspacePlanEntry => ({ - id: entry.id, - content: entry.content, - status: entry.status, - priority: entry.priority, - updatedAt: entry.updatedAt -}) - -const convertAcpFileNode = (node: AcpFileNode): WorkspaceFileNode => ({ - name: node.name, - path: node.path, - isDirectory: node.isDirectory, - children: node.children?.map(convertAcpFileNode), - expanded: node.expanded -}) - -const convertAcpTerminalSnippet = (snippet: AcpTerminalSnippet): WorkspaceTerminalSnippet => ({ - id: snippet.id, - command: snippet.command, - cwd: snippet.cwd, - output: snippet.output, - truncated: snippet.truncated, - exitCode: snippet.exitCode, - timestamp: snippet.timestamp -}) - export const useWorkspaceStore = defineStore('workspace', () => { const chatStore = useChatStore() const workspacePresenter = usePresenter('workspacePresenter') - const acpWorkspacePresenter = usePresenter('acpWorkspacePresenter') const chatMode = useChatMode() - // Select presenter based on mode const isAcpAgentMode = computed(() => chatMode.currentMode.value === 'acp agent') - const presenter = computed(() => - isAcpAgentMode.value ? acpWorkspacePresenter : workspacePresenter - ) // === State === const isOpen = ref(false) @@ -114,7 +79,7 @@ export const useWorkspaceStore = defineStore('workspace', () => { // Register workspace/workdir before reading (security boundary) - await to ensure completion if (isAcpAgentMode.value) { - await (acpWorkspacePresenter as any).registerWorkdir(workspacePath) + await (workspacePresenter as any).registerWorkdir(workspacePath) } else { await (workspacePresenter as any).registerWorkspace(workspacePath) } @@ -122,13 +87,10 @@ export const useWorkspaceStore = defineStore('workspace', () => { isLoading.value = true try { // Only read first level (lazy loading) - const result = (await presenter.value.readDirectory(workspacePath)) ?? [] + const result = (await workspacePresenter.readDirectory(workspacePath)) ?? [] // Guard against race condition: only update if still on the same conversation if (chatStore.getActiveThreadId() === conversationIdBefore) { - // Convert Acp* types to Workspace* types if needed - fileTree.value = isAcpAgentMode.value - ? (result as AcpFileNode[]).map(convertAcpFileNode) - : (result as WorkspaceFileNode[]) + fileTree.value = result as WorkspaceFileNode[] lastSuccessfulWorkspace.value = workspacePath } } catch (error) { @@ -165,11 +127,8 @@ export const useWorkspaceStore = defineStore('workspace', () => { if (!node.isDirectory) return try { - const children = (await presenter.value.expandDirectory(node.path)) ?? [] - // Convert Acp* types to Workspace* types if needed - node.children = isAcpAgentMode.value - ? (children as AcpFileNode[]).map(convertAcpFileNode) - : (children as WorkspaceFileNode[]) + const children = (await workspacePresenter.expandDirectory(node.path)) ?? [] + node.children = children as WorkspaceFileNode[] node.expanded = true } catch (error) { console.error('[Workspace] Failed to load directory children:', error) @@ -186,13 +145,10 @@ export const useWorkspaceStore = defineStore('workspace', () => { } try { - const result = (await presenter.value.getPlanEntries(conversationId)) ?? [] + const result = (await workspacePresenter.getPlanEntries(conversationId)) ?? [] // Guard against race condition: only update if still on the same conversation if (chatStore.getActiveThreadId() === conversationId) { - // Convert Acp* types to Workspace* types if needed - planEntries.value = isAcpAgentMode.value - ? (result as AcpPlanEntry[]).map(convertAcpPlanEntry) - : (result as WorkspacePlanEntry[]) + planEntries.value = result as WorkspacePlanEntry[] } } catch (error) { console.error('[Workspace] Failed to load plan entries:', error) @@ -231,18 +187,9 @@ export const useWorkspaceStore = defineStore('workspace', () => { // Plan update event window.electron.ipcRenderer.on( WORKSPACE_EVENTS.PLAN_UPDATED, - ( - _, - payload: { - conversationId: string - entries: WorkspacePlanEntry[] | AcpPlanEntry[] - } - ) => { + (_, payload: { conversationId: string; entries: WorkspacePlanEntry[] }) => { if (payload.conversationId === chatStore.getActiveThreadId()) { - // Convert Acp* types to Workspace* types if needed - planEntries.value = isAcpAgentMode.value - ? (payload.entries as AcpPlanEntry[]).map(convertAcpPlanEntry) - : (payload.entries as WorkspacePlanEntry[]) + planEntries.value = payload.entries } } ) @@ -250,20 +197,10 @@ export const useWorkspaceStore = defineStore('workspace', () => { // Terminal output event window.electron.ipcRenderer.on( WORKSPACE_EVENTS.TERMINAL_OUTPUT, - ( - _, - payload: { - conversationId: string - snippet: WorkspaceTerminalSnippet | AcpTerminalSnippet - } - ) => { + (_, payload: { conversationId: string; snippet: WorkspaceTerminalSnippet }) => { if (payload.conversationId === chatStore.getActiveThreadId()) { - // Convert Acp* types to Workspace* types if needed - const snippet = isAcpAgentMode.value - ? convertAcpTerminalSnippet(payload.snippet as AcpTerminalSnippet) - : (payload.snippet as WorkspaceTerminalSnippet) // Keep latest 10 items - terminalSnippets.value = [snippet, ...terminalSnippets.value.slice(0, 9)] + terminalSnippets.value = [payload.snippet, ...terminalSnippets.value.slice(0, 9)] } } ) diff --git a/src/shared/regexValidator.ts b/src/shared/regexValidator.ts index 5aca2530e..81c997c8f 100644 --- a/src/shared/regexValidator.ts +++ b/src/shared/regexValidator.ts @@ -1,3 +1,4 @@ +import { minimatch } from 'minimatch' import safeRegex from 'safe-regex2' const DEFAULT_MAX_PATTERN_LENGTH = 1000 @@ -27,6 +28,40 @@ export function validateRegexPattern( } } +/** + * Validate glob pattern for ReDoS safety + * @param pattern The glob pattern to validate + * @param maxLength Maximum allowed pattern length (default: 1000) + * @throws Error if pattern is unsafe or exceeds length limit + */ +export function validateGlobPattern( + pattern: string, + maxLength: number = DEFAULT_MAX_PATTERN_LENGTH +): void { + if (pattern.length > maxLength) { + throw new Error( + `Glob pattern exceeds maximum length of ${maxLength} characters. Pattern length: ${pattern.length}` + ) + } + + let compiled: RegExp | false + try { + compiled = minimatch.makeRe(pattern) + } catch (error) { + throw new Error(`Invalid glob pattern: ${pattern}. Error: ${error}`) + } + + if (!compiled) { + throw new Error(`Invalid glob pattern: ${pattern}`) + } + + if (!safeRegex(compiled)) { + throw new Error( + 'Glob pattern is potentially unsafe and may cause ReDoS (Regular Expression Denial of Service). Please use a simpler, safer pattern.' + ) + } +} + /** * Check if a regex pattern is safe (non-throwing version) * @param pattern The regex pattern to check diff --git a/src/shared/types/index.d.ts b/src/shared/types/index.d.ts index 3889c34eb..b98577a4f 100644 --- a/src/shared/types/index.d.ts +++ b/src/shared/types/index.d.ts @@ -1,7 +1,6 @@ // Temporary barrel: keep legacy presenters to avoid breaking changes during migration export type * from './presenters/legacy.presenters' export type * from './presenters/agent-provider' -export type * from './presenters/acp-workspace' export type * from './presenters/workspace' export type * from './presenters/tool.presenter' export * from './browser' diff --git a/src/shared/types/presenters/acp-workspace.d.ts b/src/shared/types/presenters/acp-workspace.d.ts deleted file mode 100644 index b1b551356..000000000 --- a/src/shared/types/presenters/acp-workspace.d.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * ACP Workspace Types - * Types for the ACP workspace panel functionality - */ - -/** - * Plan entry status - */ -export type AcpPlanStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'skipped' - -/** - * Plan entry - task from ACP agent - */ -export type AcpPlanEntry = { - /** Unique identifier (system generated) */ - id: string - /** Task content description */ - content: string - /** Task status */ - status: AcpPlanStatus - /** Priority (optional, from agent) */ - priority?: string | null - /** Update timestamp */ - updatedAt: number -} - -/** - * File tree node - */ -export type AcpFileNode = { - /** File/directory name */ - name: string - /** Full path */ - path: string - /** Whether it's a directory */ - isDirectory: boolean - /** Child nodes (directories only) */ - children?: AcpFileNode[] - /** Whether expanded (frontend state) */ - expanded?: boolean -} - -/** - * Terminal output snippet - from ACP tool_call terminal output - */ -export type AcpTerminalSnippet = { - /** Unique identifier */ - id: string - /** Executed command */ - command: string - /** Working directory */ - cwd?: string - /** Output content (truncated) */ - output: string - /** Whether truncated */ - truncated: boolean - /** Exit code (after command completion) */ - exitCode?: number | null - /** Timestamp */ - timestamp: number -} - -/** - * Raw plan entry from acpContentMapper - */ -export type AcpRawPlanEntry = { - content: string - status?: string | null - priority?: string | null -} - -/** - * Workspace Presenter interface - */ -export interface IAcpWorkspacePresenter { - /** - * Register a workdir as allowed for reading (security boundary) - * @param workdir Workspace directory path - */ - registerWorkdir(workdir: string): Promise - - /** - * Unregister a workdir - * @param workdir Workspace directory path - */ - unregisterWorkdir(workdir: string): Promise - - /** - * Read directory (shallow, only first level) - * Use expandDirectory to load subdirectory contents - * @param dirPath Directory path - * @returns Array of file tree nodes (directories have children = undefined) - */ - readDirectory(dirPath: string): Promise - - /** - * Expand a directory to load its children (lazy loading) - * @param dirPath Directory path to expand - * @returns Array of child file tree nodes - */ - expandDirectory(dirPath: string): Promise - - /** - * Reveal a file or directory in the system file manager - * @param filePath Path to reveal - */ - revealFileInFolder(filePath: string): Promise - - /** - * Open a file or directory using the system default application - * @param filePath Path to open - */ - openFile(filePath: string): Promise - - /** - * Get plan entries for a conversation - * @param conversationId Conversation ID - */ - getPlanEntries(conversationId: string): Promise - - /** - * Update plan entries for a conversation (called internally by ACP events) - * @param conversationId Conversation ID - * @param entries Raw plan entries from agent - */ - updatePlanEntries(conversationId: string, entries: AcpRawPlanEntry[]): Promise - - /** - * Emit terminal snippet (called internally by ACP events) - * @param conversationId Conversation ID - * @param snippet Terminal snippet - */ - emitTerminalSnippet(conversationId: string, snippet: AcpTerminalSnippet): Promise - - /** - * Clear workspace data for a conversation - * @param conversationId Conversation ID - */ - clearWorkspaceData(conversationId: string): Promise -} diff --git a/src/shared/types/presenters/index.d.ts b/src/shared/types/presenters/index.d.ts index 786d07e2b..59c1216f7 100644 --- a/src/shared/types/presenters/index.d.ts +++ b/src/shared/types/presenters/index.d.ts @@ -35,16 +35,6 @@ export type { export type * from './agent-provider' -// ACP Workspace types (legacy, kept for backward compatibility) -export type { - AcpPlanStatus, - AcpPlanEntry, - AcpFileNode, - AcpTerminalSnippet, - AcpRawPlanEntry, - IAcpWorkspacePresenter -} from './acp-workspace' - // Generic Workspace types (for all Agent modes) export type { WorkspacePlanStatus, diff --git a/src/shared/types/presenters/legacy.presenters.d.ts b/src/shared/types/presenters/legacy.presenters.d.ts index 2c112f700..76acf4512 100644 --- a/src/shared/types/presenters/legacy.presenters.d.ts +++ b/src/shared/types/presenters/legacy.presenters.d.ts @@ -7,7 +7,6 @@ import { ModelType } from '@shared/model' import type { NowledgeMemThread, NowledgeMemExportSummary } from '../nowledgeMem' import { ProviderChange, ProviderBatchUpdate } from './provider-operations' import type { AgentSessionLifecycleStatus } from './agent-provider' -import type { IAcpWorkspacePresenter } from './acp-workspace' import type { IWorkspacePresenter } from './workspace' import type { IToolPresenter } from './tool.presenter' import type { @@ -443,7 +442,6 @@ export interface IPresenter { oauthPresenter: IOAuthPresenter dialogPresenter: IDialogPresenter knowledgePresenter: IKnowledgePresenter - acpWorkspacePresenter: IAcpWorkspacePresenter workspacePresenter: IWorkspacePresenter toolPresenter: IToolPresenter init(): void diff --git a/src/shared/types/presenters/workspace.d.ts b/src/shared/types/presenters/workspace.d.ts index 5a40ed750..6b5234f25 100644 --- a/src/shared/types/presenters/workspace.d.ts +++ b/src/shared/types/presenters/workspace.d.ts @@ -79,12 +79,24 @@ export interface IWorkspacePresenter { */ registerWorkspace(workspacePath: string): Promise + /** + * Register a workdir path as allowed for reading (ACP alias) + * @param workdir Workspace directory path + */ + registerWorkdir(workdir: string): Promise + /** * Unregister a workspace path * @param workspacePath Workspace directory path */ unregisterWorkspace(workspacePath: string): Promise + /** + * Unregister a workdir path (ACP alias) + * @param workdir Workspace directory path + */ + unregisterWorkdir(workdir: string): Promise + /** * Read directory (shallow, only first level) * Use expandDirectory to load subdirectory contents @@ -137,4 +149,11 @@ export interface IWorkspacePresenter { * @param conversationId Conversation ID */ clearWorkspaceData(conversationId: string): Promise + + /** + * Search workspace files by query (query does not include @) + * @param workspacePath Workspace directory path + * @param query Search query (plain string) + */ + searchFiles(workspacePath: string, query: string): Promise }