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
}